diff options
| author | Vidhin Mehta (OpenERP) <vme@tinyerp.com> | 2011-09-14 11:07:46 +0530 |
|---|---|---|
| committer | Vidhin Mehta (OpenERP) <vme@tinyerp.com> | 2011-09-14 11:07:46 +0530 |
| commit | b6076c3cc3ca5141349b99821268b8fd5e148b77 (patch) | |
| tree | bb875d3ef5127cf5b8c35de6475de14e5477dbbd /addons/web/static/src/js/list.js | |
| parent | 243e1bdb0079ea9f214696ead8228d464ecb2cfc (diff) | |
| parent | c7c2b5846d3775780bbaa9a84a93ad755bdb8756 (diff) | |
[Merge] upto revno 1024.
bzr revid: vme@tinyerp.com-20110914053746-ri6d5s341mm6tc7n
Diffstat (limited to 'addons/web/static/src/js/list.js')
| -rw-r--r-- | addons/web/static/src/js/list.js | 1523 |
1 files changed, 1523 insertions, 0 deletions
diff --git a/addons/web/static/src/js/list.js b/addons/web/static/src/js/list.js new file mode 100644 index 00000000000..95a8207144b --- /dev/null +++ b/addons/web/static/src/js/list.js @@ -0,0 +1,1523 @@ +openerp.web.list = function (openerp) { +var QWeb = openerp.web.qweb; +openerp.web.views.add('list', 'openerp.web.ListView'); +openerp.web.ListView = openerp.web.View.extend( /** @lends openerp.web.ListView# */ { + defaults: { + // records can be selected one by one + 'selectable': true, + // list rows can be deleted + 'deletable': true, + // whether the column headers should be displayed + 'header': true, + // display addition button, with that label + 'addable': "New", + // whether the list view can be sorted, note that once a view has been + // sorted it can not be reordered anymore + 'sortable': true, + // whether the view rows can be reordered (via vertical drag & drop) + 'reorderable': true + }, + /** + * Core class for list-type displays. + * + * As a view, needs a number of view-related parameters to be correctly + * instantiated, provides options and overridable methods for behavioral + * customization. + * + * See constructor parameters and method documentations for information on + * the default behaviors and possible options for the list view. + * + * @constructs openerp.web.ListView + * @extends openerp.web.View + * + * @param parent parent object + * @param element_id the id of the DOM elements this view should link itself to + * @param {openerp.web.DataSet} dataset the dataset the view should work with + * @param {String} view_id the listview's identifier, if any + * @param {Object} options A set of options used to configure the view + * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox) + * @param {Boolean} [options.header=true] should the list's header be displayed + * @param {Boolean} [options.deletable=true] are the list rows deletable + * @param {void|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button. + * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers + * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows + * + * @borrows openerp.web.ActionExecutor#execute_action as #execute_action + */ + init: function(parent, element_id, dataset, view_id, options) { + var self = this; + this._super(parent, element_id); + this.set_default_options(_.extend({}, this.defaults, options || {})); + this.dataset = dataset; + this.model = dataset.model; + this.view_id = view_id; + this.previous_colspan = null; + + this.columns = []; + + this.records = new Collection(); + + this.set_groups(new openerp.web.ListView.Groups(this)); + + if (this.dataset instanceof openerp.web.DataSetStatic) { + this.groups.datagroup = new openerp.web.StaticDataGroup(this.dataset); + } else { + this.groups.datagroup = new openerp.web.DataGroup( + this, this.model, + dataset.get_domain(), + dataset.get_context(), + {}); + this.groups.datagroup.sort = this.dataset._sort; + } + + this.page = 0; + this.records.bind('change', function (event, record, key) { + if (!_(self.aggregate_columns).chain() + .pluck('name').contains(key).value()) { + return; + } + self.compute_aggregates(); + }); + }, + /** + * Retrieves the view's number of records per page (|| section) + * + * options > defaults > parent.action.limit > indefinite + * + * @returns {Number|null} + */ + limit: function () { + if (this._limit === undefined) { + this._limit = (this.options.limit + || this.defaults.limit + || (this.widget_parent.action || {}).limit + || null); + } + return this._limit; + }, + /** + * Set a custom Group construct as the root of the List View. + * + * @param {openerp.web.ListView.Groups} groups + */ + set_groups: function (groups) { + var self = this; + if (this.groups) { + $(this.groups).unbind("selected deleted action row_link"); + delete this.groups; + } + + this.groups = groups; + $(this.groups).bind({ + 'selected': function (e, ids, records) { + self.do_select(ids, records); + }, + 'deleted': function (e, ids) { + self.do_delete(ids); + }, + 'action': function (e, action_name, id, callback) { + self.do_button_action(action_name, id, callback); + }, + 'row_link': function (e, id, dataset) { + self.do_activate_record(dataset.index, id, dataset); + } + }); + }, + /** + * View startup method, the default behavior is to set the ``oe-listview`` + * class on its root element and to perform an RPC load call. + * + * @returns {$.Deferred} loading promise + */ + start: function() { + this.$element.addClass('oe-listview'); + return this.reload_view(null, null, true); + }, + /** + * Called after loading the list view's description, sets up such things + * as the view table's columns, renders the table itself and hooks up the + * various table-level and row-level DOM events (action buttons, deletion + * buttons, selection of records, [New] button, selection of a given + * record, ...) + * + * Sets up the following: + * + * * Processes arch and fields to generate a complete field descriptor for each field + * * Create the table itself and allocate visible columns + * * Hook in the top-level (header) [New|Add] and [Delete] button + * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not + * * Sets up event handlers for action buttons and per-row deletion button + * * Hooks global callback for clicking on a row + * * Sets up its sidebar, if any + * + * @param {Object} data wrapped fields_view_get result + * @param {Object} data.fields_view fields_view_get result (processed) + * @param {Object} data.fields_view.fields mapping of fields for the current model + * @param {Object} data.fields_view.arch current list view descriptor + * @param {Boolean} grouped Is the list view grouped + */ + on_loaded: function(data, grouped) { + var self = this; + this.fields_view = data; + this.name = "" + this.fields_view.arch.attrs.string; + + this.setup_columns(this.fields_view.fields, grouped); + + this.$element.html(QWeb.render("ListView", this)); + + // Head hook + this.$element.find('.oe-list-add') + .click(this.do_add_record) + .attr('disabled', grouped && this.options.editable); + this.$element.find('.oe-list-delete') + .attr('disabled', true) + .click(this.do_delete_selected); + this.$element.find('thead').delegate('th.oe-sortable[data-id]', 'click', function (e) { + e.stopPropagation(); + + var $this = $(this); + self.dataset.sort($this.data('id')); + if ($this.find('span').length) { + $this.find('span').toggleClass( + 'ui-icon-triangle-1-s ui-icon-triangle-1-n'); + } else { + $this.append('<span class="ui-icon ui-icon-triangle-1-s">') + .siblings('.oe-sortable').find('span').remove(); + } + + self.reload_content(); + }); + + this.$element.find('.oe-list-pager') + .delegate('button', 'click', function () { + var $this = $(this); + switch ($this.data('pager-action')) { + case 'first': + self.page = 0; break; + case 'last': + self.page = Math.floor( + self.dataset.ids.length / self.limit()); + break; + case 'next': + self.page += 1; break; + case 'previous': + self.page -= 1; break; + } + self.reload_content(); + }).find('.oe-pager-state') + .click(function (e) { + e.stopPropagation(); + var $this = $(this); + + var $select = $('<select>') + .appendTo($this.empty()) + .click(function (e) {e.stopPropagation();}) + .append('<option value="80">80</option>' + + '<option value="100">100</option>' + + '<option value="200">200</option>' + + '<option value="500">500</option>' + + '<option value="NaN">Unlimited</option>') + .change(function () { + var val = parseInt($select.val(), 10); + self._limit = (isNaN(val) ? null : val); + self.page = 0; + self.reload_content(); + }) + .val(self._limit || 'NaN'); + }); + if (!this.sidebar && this.options.sidebar && this.options.sidebar_id) { + this.sidebar = new openerp.web.Sidebar(this, this.options.sidebar_id); + this.sidebar.start(); + this.sidebar.add_toolbar(this.fields_view.toolbar); + this.set_common_sidebar_sections(this.sidebar); + } + }, + /** + * Configures the ListView pager based on the provided dataset's information + * + * Horrifying side-effect: sets the dataset's data on this.dataset? + * + * @param {openerp.web.DataSet} dataset + */ + configure_pager: function (dataset) { + this.dataset.ids = dataset.ids; + + var limit = this.limit(), + total = dataset.ids.length, + first = (this.page * limit), + last; + if (!limit || (total - first) < limit) { + last = total; + } else { + last = first + limit; + } + this.$element.find('span.oe-pager-state').empty().text(_.sprintf( + "[%d to %d] of %d", first + 1, last, total)); + + this.$element + .find('button[data-pager-action=first], button[data-pager-action=previous]') + .attr('disabled', this.page === 0) + .end() + .find('button[data-pager-action=last], button[data-pager-action=next]') + .attr('disabled', last === total); + }, + /** + * Sets up the listview's columns: merges view and fields data, move + * grouped-by columns to the front of the columns list and make them all + * visible. + * + * @param {Object} fields fields_view_get's fields section + * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed + */ + setup_columns: function (fields, grouped) { + var domain_computer = openerp.web.form.compute_domain; + + var noop = function () { return {}; }; + var field_to_column = function (field) { + var name = field.attrs.name; + var column = _.extend({id: name, tag: field.tag}, + field.attrs, fields[name]); + // modifiers computer + if (column.modifiers) { + var modifiers = JSON.parse(column.modifiers); + column.modifiers_for = function (fields) { + if (!modifiers.invisible) { + return {}; + } + return { + 'invisible': domain_computer(modifiers.invisible, fields) + }; + }; + if (modifiers['tree_invisible']) { + column.invisible = '1'; + } + } else { + column.modifiers_for = noop; + } + return column; + }; + + this.columns.splice(0, this.columns.length); + this.columns.push.apply( + this.columns, + _(this.fields_view.arch.children).map(field_to_column)); + if (grouped) { + this.columns.unshift({ + id: '_group', tag: '', string: "Group", meta: true, + modifiers_for: function () { return {}; } + }, { + id: '_count', tag: '', string: '#', meta: true, + modifiers_for: function () { return {}; } + }); + } + + this.visible_columns = _.filter(this.columns, function (column) { + return column.invisible !== '1'; + }); + + this.aggregate_columns = _(this.visible_columns) + .map(function (column) { + if (column.type !== 'integer' && column.type !== 'float') { + return {}; + } + var aggregation_func = column['group_operator'] || 'sum'; + + return _.extend({}, column, { + 'function': aggregation_func, + label: column[aggregation_func] + }); + }); + }, + /** + * Used to handle a click on a table row, if no other handler caught the + * event. + * + * The default implementation asks the list view's view manager to switch + * to a different view (by calling + * :js:func:`~openerp.web.ViewManager.on_mode_switch`), using the + * provided record index (within the current list view's dataset). + * + * If the index is null, ``switch_to_record`` asks for the creation of a + * new record. + * + * @param {Number|void} index the record index (in the current dataset) to switch to + * @param {String} [view="form"] the view type to switch to + */ + select_record:function (index, view) { + view = view || 'form'; + this.dataset.index = index; + _.delay(_.bind(function () { + this.do_switch_view(view); + }, this)); + }, + do_show: function () { + this.$element.show(); + if (this.sidebar) { + this.sidebar.$element.show(); + } + if (!_(this.dataset.ids).isEmpty()) { + this.reload_content(); + } + }, + do_hide: function () { + this.$element.hide(); + if (this.sidebar) { + this.sidebar.$element.hide(); + } + }, + /** + * Reloads the list view based on the current settings (dataset & al) + * + * @param {Boolean} [grouped] Should the list be displayed grouped + * @param {Object} [context] context to send the server while loading the view + */ + reload_view: function (grouped, context, initial) { + var self = this; + var callback = function (field_view_get) { + self.on_loaded(field_view_get, grouped); + }; + if (this.embedded_view) { + return $.Deferred().then(callback).resolve(this.embedded_view); + } else { + return this.rpc('/web/listview/load', { + model: this.model, + view_id: this.view_id, + view_type: "tree", + context: this.dataset.get_context(context), + toolbar: this.options.sidebar + }, callback); + } + }, + /** + * re-renders the content of the list view + */ + reload_content: function () { + var self = this; + this.records.reset(); + this.$element.find('.oe-listview-content').append( + this.groups.render(function () { + if (self.dataset.index == null) { + var has_one = false; + self.records.each(function () { has_one = true; }); + if (has_one) { + self.dataset.index = 0; + } + } + self.compute_aggregates(); + })); + }, + /** + * Event handler for a search, asks for the computation/folding of domains + * and contexts (and group-by), then reloads the view's content. + * + * @param {Array} domains a sequence of literal and non-literal domains + * @param {Array} contexts a sequence of literal and non-literal contexts + * @param {Array} groupbys a sequence of literal and non-literal group-by contexts + * @returns {$.Deferred} fold request evaluation promise + */ + do_search: function (domains, contexts, groupbys) { + return this.rpc('/web/session/eval_domain_and_context', { + domains: [this.dataset.get_domain()].concat(domains), + contexts: [this.dataset.get_context()].concat(contexts), + group_by_seq: groupbys + }, $.proxy(this, 'do_actual_search')); + }, + /** + * Handler for the result of eval_domain_and_context, actually perform the + * searching + * + * @param {Object} results results of evaluating domain and process for a search + */ + do_actual_search: function (results) { + this.groups.datagroup = new openerp.web.DataGroup( + this, this.model, + results.domain, + results.context, + results.group_by); + this.groups.datagroup.sort = this.dataset._sort; + + if (_.isEmpty(results.group_by) && !results.context['group_by_no_leaf']) { + results.group_by = null; + } + + this.reload_view(!!results.group_by, results.context).then( + $.proxy(this, 'reload_content')); + }, + /** + * Handles the signal to delete lines from the records list + * + * @param {Array} ids the ids of the records to delete + */ + do_delete: function (ids) { + if (!ids.length) { + return; + } + var self = this; + return $.when(this.dataset.unlink(ids)).then(function () { + _(ids).each(function (id) { + self.records.remove(self.records.get(id)); + }); + self.compute_aggregates(); + }); + }, + /** + * Handles the signal indicating that a new record has been selected + * + * @param {Array} ids selected record ids + * @param {Array} records selected record values + */ + do_select: function (ids, records) { + this.$element.find('.oe-list-delete') + .attr('disabled', !ids.length); + + if (!records.length) { + this.compute_aggregates(); + return; + } + this.compute_aggregates(_(records).map(function (record) { + return {count: 1, values: record}; + })); + }, + /** + * Handles action button signals on a record + * + * @param {String} name action name + * @param {Object} id id of the record the action should be called on + * @param {Function} callback should be called after the action is executed, if non-null + */ + do_button_action: function (name, id, callback) { + var action = _.detect(this.columns, function (field) { + return field.name === name; + }); + if (!action) { return; } + this.execute_action(action, this.dataset, id, callback); + }, + /** + * Handles the activation of a record (clicking on it) + * + * @param {Number} index index of the record in the dataset + * @param {Object} id identifier of the activated record + * @param {openerp.web.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups) + */ + do_activate_record: function (index, id, dataset) { + var self = this; + // TODO is it needed ? + this.dataset.read_slice([],{ + context: dataset.get_context(), + domain: dataset.get_domain() + }, function () { + self.select_record(index); + }); + }, + /** + * Handles signal for the addition of a new record (can be a creation, + * can be the addition from a remote source, ...) + * + * The default implementation is to switch to a new record on the form view + */ + do_add_record: function () { + this.select_record(null); + }, + /** + * Handles deletion of all selected lines + */ + do_delete_selected: function () { + this.do_delete(this.groups.get_selection().ids); + }, + /** + * Computes the aggregates for the current list view, either on the + * records provided or on the records of the internal + * :js:class:`~openerp.web.ListView.Group`, by calling + * :js:func:`~openerp.web.ListView.group.get_records`. + * + * Then displays the aggregates in the table through + * :js:method:`~openerp.web.ListView.display_aggregates`. + * + * @param {Array} [records] + */ + compute_aggregates: function (records) { + var columns = _(this.aggregate_columns).filter(function (column) { + return column['function']; }); + if (_.isEmpty(columns)) { return; } + + if (_.isEmpty(records)) { + records = this.groups.get_records(); + } + + var count = 0, sums = {}; + _(columns).each(function (column) { + switch (column['function']) { + case 'max': + sums[column.id] = -Infinity; + break; + case 'min': + sums[column.id] = Infinity; + break; + default: + sums[column.id] = 0; + } + }); + _(records).each(function (record) { + count += record.count || 1; + _(columns).each(function (column) { + var field = column.id, + value = record.values[field]; + switch (column['function']) { + case 'sum': + sums[field] += value; + break; + case 'avg': + sums[field] += record.count * value; + break; + case 'min': + if (sums[field] > value) { + sums[field] = value; + } + break; + case 'max': + if (sums[field] < value) { + sums[field] = value; + } + break; + } + }); + }); + + var aggregates = {}; + _(columns).each(function (column) { + var field = column.id; + switch (column['function']) { + case 'avg': + aggregates[field] = {value: sums[field] / count}; + break; + default: + aggregates[field] = {value: sums[field]}; + } + }); + + this.display_aggregates(aggregates); + }, + display_aggregates: function (aggregation) { + var $footer_cells = this.$element.find('.oe-list-footer'); + _(this.aggregate_columns).each(function (column) { + if (!column['function']) { + return; + } + + $footer_cells.filter(_.sprintf('[data-field=%s]', column.id)) + .html(openerp.web.format_cell(aggregation, column)); + }); + }, + get_selected_ids: function() { + var ids = this.groups.get_selection().ids; + return ids; + }, + /** + * Adds padding columns at the start or end of all table rows (including + * field names row) + * + * @param {Number} count number of columns to add + * @param {Object} options + * @param {"before"|"after"} [position="after"] insertion position for the new columns + * @param {Object} [except] content row to not pad + */ + pad_columns: function (count, options) { + options = options || {}; + // padding for action/pager header + var $first_header = this.$element.find('thead tr:first th'); + var colspan = $first_header.attr('colspan'); + if (colspan) { + if (!this.previous_colspan) { + this.previous_colspan = colspan; + } + $first_header.attr('colspan', parseInt(colspan, 10) + count); + } + // Padding for column titles, footer and data rows + var $rows = this.$element + .find('.oe-listview-header-columns, tr:not(thead tr)') + .not(options['except']); + var newcols = new Array(count+1).join('<td class="oe-listview-padding"></td>'); + if (options.position === 'before') { + $rows.prepend(newcols); + } else { + $rows.append(newcols); + } + }, + /** + * Removes all padding columns of the table + */ + unpad_columns: function () { + this.$element.find('.oe-listview-padding').remove(); + if (this.previous_colspan) { + this.$element + .find('thead tr:first th') + .attr('colspan', this.previous_colspan); + this.previous_colspan = null; + } + } +}); +openerp.web.ListView.List = openerp.web.Class.extend( /** @lends openerp.web.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 openerp.web.ListView.List + * @extends openerp.web.Class + * + * @param {Object} opts display options, identical to those of :js:class:`openerp.web.ListView` + */ + init: function (group, opts) { + var self = this; + this.group = group; + this.view = group.view; + this.session = this.view.session; + + this.options = opts.options; + this.columns = opts.columns; + this.dataset = opts.dataset; + this.records = opts.records; + + this.record_callbacks = { + 'remove': function (event, record) { + var $row = self.$current.find( + '[data-id=' + record.get('id') + ']'); + var index = $row.data('index'); + $row.remove(); + self.refresh_zebra(index); + }, + 'reset': $.proxy(this, 'on_records_reset'), + 'change': function (event, record) { + var $row = self.$current.find('[data-id=' + record.get('id') + ']'); + $row.replaceWith(self.render_record(record)); + }, + 'add': function (ev, records, record, index) { + var $new_row = $('<tr>').attr({ + 'data-id': record.get('id') + }); + + if (index === 0) { + $new_row.prependTo(self.$current); + } else { + var previous_record = records.at(index-1), + $previous_sibling = self.$current.find( + '[data-id=' + previous_record.get('id') + ']'); + $new_row.insertAfter($previous_sibling); + } + + self.refresh_zebra(index, 1); + } + }; + _(this.record_callbacks).each(function (callback, event) { + this.records.bind(event, callback); + }, this); + + this.$_element = $('<tbody class="ui-widget-content">') + .appendTo(document.body) + .delegate('th.oe-record-selector', 'click', function (e) { + e.stopPropagation(); + var selection = self.get_selection(); + $(self).trigger( + 'selected', [selection.ids, selection.records]); + }) + .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'), + $row = $target.closest('tr'), + record_id = self.row_id($row); + + $(self).trigger('action', [field, record_id, function () { + return self.reload_record(self.records.get(record_id)); + }]); + }) + .delegate('tr', 'click', function (e) { + e.stopPropagation(); + self.dataset.index = self.records.indexOf( + self.records.get( + self.row_id(e.currentTarget))); + self.row_clicked(e); + }); + }, + row_clicked: function () { + $(this).trigger( + 'row_link', + [this.records.at(this.dataset.index).get('id'), + this.dataset]); + }, + render: function () { + if (this.$current) { + this.$current.remove(); + } + this.$current = this.$_element.clone(true); + this.$current.empty().append( + QWeb.render('ListView.rows', _.extend({ + render_cell: openerp.web.format_cell}, this))); + }, + /** + * Gets the ids of all currently selected records, if any + * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves. + */ + get_selection: function () { + if (!this.options.selectable) { + return []; + } + var records = this.records; + var result = {ids: [], records: []}; + this.$current.find('th.oe-record-selector input:checked') + .closest('tr').each(function () { + var record = records.get($(this).data('id')); + result.ids.push(record.get('id')); + result.records.push(record.attributes); + }); + return result; + }, + /** + * 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 $(row).data('id'); + }, + /** + * Death signal, cleans up list display + */ + on_records_reset: function () { + _(this.record_callbacks).each(function (callback, event) { + this.records.unbind(event, callback); + }, this); + if (!this.$current) { return; } + this.$current.remove(); + this.$current = null; + this.$_element.remove(); + }, + get_records: function () { + return this.records.map(function (record) { + return {count: 1, values: record.attributes}; + }); + }, + /** + * Reloads the provided record by re-reading its content from the server. + * + * @param {Record} record + * @returns {$.Deferred} promise to the finalization of the reloading + */ + reload_record: function (record) { + return this.dataset.read_ids( + [record.get('id')], + _.pluck(_(this.columns).filter(function (r) { + return r.tag === 'field'; + }), 'name'), + function (records) { + _(records[0]).each(function (value, key) { + record.set(key, value, {silent: true}); + }); + record.trigger('change', record); + } + ); + }, + /** + * Renders a list record to HTML + * + * @param {Record} record index of the record to render in ``this.rows`` + * @returns {String} QWeb rendering of the selected record + */ + render_record: function (record) { + var index = this.records.indexOf(record); + return QWeb.render('ListView.row', { + columns: this.columns, + options: this.options, + record: record, + row_parity: (index % 2 === 0) ? 'even' : 'odd', + render_cell: openerp.web.format_cell + }); + }, + /** + * Fixes fixes the even/odd classes + * + * @param {Number} [from_index] index from which to resequence + * @param {Number} [offset = 0] selection offset for DOM, in case there are rows to ignore in the table + */ + refresh_zebra: function (from_index, offset) { + offset = offset || 0; + from_index = from_index || 0; + var dom_offset = offset + from_index; + var sel = dom_offset ? ':gt(' + (dom_offset - 1) + ')' : null; + this.$current.children(sel).each(function (i, e) { + var index = from_index + i; + // reset record-index accelerators on rows and even/odd + var even = index%2 === 0; + $(e).toggleClass('even', even) + .toggleClass('odd', !even); + }); + } +}); +openerp.web.ListView.Groups = openerp.web.Class.extend( /** @lends openerp.web.ListView.Groups# */{ + passtrough_events: 'action deleted row_link', + /** + * Grouped display for the ListView. Handles basic DOM events and interacts + * with the :js:class:`~openerp.web.DataGroup` bound to it. + * + * Provides events similar to those of + * :js:class:`~openerp.web.ListView.List` + * + * @constructs openerp.web.ListView.Groups + * @extends openerp.web.Class + * + * @param {openerp.web.ListView} view + * @param {Object} [options] + * @param {Collection} [options.records] + * @param {Object} [options.options] + * @param {Array} [options.columns] + */ + init: function (view, options) { + options = options || {}; + this.view = view; + this.records = options.records || view.records; + this.options = options.options || view.options; + this.columns = options.columns || view.columns; + this.datagroup = null; + + this.$row = null; + this.children = {}; + + this.page = 0; + + this.records.bind('reset', $.proxy(this, 'on_records_reset')); + }, + make_fragment: function () { + return document.createDocumentFragment(); + }, + /** + * Returns a DOM node after which a new tbody can be inserted, so that it + * follows the provided row. + * + * Necessary to insert the result of a new group or list view within an + * existing groups render, without losing track of the groups's own + * elements + * + * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body + * @returns {HTMLTableSectionElement} element after which a tbody can be inserted + */ + point_insertion: function (row) { + var $row = $(row); + var red_letter_tboday = $row.closest('tbody')[0]; + + var $next_siblings = $row.nextAll(); + if ($next_siblings.length) { + var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday); + + $root_kanal.append($next_siblings); + this.elements.splice( + _.indexOf(this.elements, red_letter_tboday), + 0, + $root_kanal[0]); + } + return red_letter_tboday; + }, + make_paginator: function () { + var self = this; + var $prev = $('<button type="button" data-pager-action="previous"><</button>') + .click(function (e) { + e.stopPropagation(); + self.page -= 1; + + self.$row.closest('tbody').next() + .replaceWith(self.render()); + }); + var $next = $('<button type="button" data-pager-action="next">></button>') + .click(function (e) { + e.stopPropagation(); + self.page += 1; + + self.$row.closest('tbody').next() + .replaceWith(self.render()); + }); + this.$row.children().last() + .append($prev) + .append('<span class="oe-pager-state"></span>') + .append($next); + }, + open: function (point_insertion) { + this.render().insertAfter(point_insertion); + this.make_paginator(); + }, + close: function () { + this.$row.children().last().empty(); + this.records.reset(); + }, + /** + * Prefixes ``$node`` with floated spaces in order to indent it relative + * to its own left margin/baseline + * + * @param {jQuery} $node jQuery object to indent + * @param {Number} level current nesting level, >= 1 + * @returns {jQuery} the indentation node created + */ + indent: function ($node, level) { + return $('<span>') + .css({'float': 'left', 'white-space': 'pre'}) + .text(new Array(level).join(' ')) + .prependTo($node); + }, + render_groups: function (datagroups) { + var self = this; + var placeholder = this.make_fragment(); + _(datagroups).each(function (group) { + if (self.children[group.value]) { + self.records.proxy(group.value).reset(); + delete self.children[group.value]; + } + var child = self.children[group.value] = new openerp.web.ListView.Groups(self.view, { + records: self.records.proxy(group.value), + options: self.options, + columns: self.columns + }); + self.bind_child_events(child); + child.datagroup = group; + + var $row = child.$row = $('<tr>'); + if (group.openable) { + $row.click(function (e) { + if (!$row.data('open')) { + $row.data('open', true) + .find('span.ui-icon') + .removeClass('ui-icon-triangle-1-e') + .addClass('ui-icon-triangle-1-s'); + child.open(self.point_insertion(e.currentTarget)); + } else { + $row.removeData('open') + .find('span.ui-icon') + .removeClass('ui-icon-triangle-1-s') + .addClass('ui-icon-triangle-1-e'); + child.close(); + } + }); + } + placeholder.appendChild($row[0]); + + var $group_column = $('<th class="oe-group-name">').appendTo($row); + // Don't fill this if group_by_no_leaf but no group_by + if (group.grouped_on) { + var row_data = {}; + row_data[group.grouped_on] = group; + var group_column = _(self.columns).detect(function (column) { + return column.id === group.grouped_on; }); + $group_column.html(openerp.web.format_cell( + row_data, group_column, "Undefined" + )); + if (group.openable) { + // Make openable if not terminal group & group_by_no_leaf + $group_column + .prepend('<span class="ui-icon ui-icon-triangle-1-e" style="float: left;">'); + } + } + self.indent($group_column, group.level); + // count column + $('<td>').text(group.length).appendTo($row); + + if (self.options.selectable) { + $row.append('<td>'); + } + _(self.columns).chain() + .filter(function (column) {return !column.invisible;}) + .each(function (column) { + if (column.meta) { + // do not do anything + } else if (column.id in group.aggregates) { + var value = group.aggregates[column.id]; + var format; + if (column.type === 'integer') { + format = "%.0f"; + } else if (column.type === 'float') { + format = "%.2f"; + } + $('<td>') + .text(_.sprintf(format, value)) + .appendTo($row); + } else { + $row.append('<td>'); + } + }); + if (self.options.deletable) { + $row.append('<td class="oe-group-pagination">'); + } + }); + return placeholder; + }, + bind_child_events: function (child) { + var $this = $(this), + self = this; + $(child).bind('selected', function (e) { + // can have selections spanning multiple links + var selection = self.get_selection(); + $this.trigger(e, [selection.ids, selection.records]); + }).bind(this.passtrough_events, function (e) { + // additional positional parameters are provided to trigger as an + // Array, following the event type or event object, but are + // provided to the .bind event handler as *args. + // Convert our *args back into an Array in order to trigger them + // on the group itself, so it can ultimately be forwarded wherever + // it's supposed to go. + var args = Array.prototype.slice.call(arguments, 1); + $this.trigger.call($this, e, args); + }); + }, + render_dataset: function (dataset) { + var self = this, + list = new openerp.web.ListView.List(this, { + options: this.options, + columns: this.columns, + dataset: dataset, + records: this.records + }); + this.bind_child_events(list); + + var view = this.view, + limit = view.limit(), + d = new $.Deferred(), + page = this.datagroup.openable ? this.page : view.page; + + var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field"}), 'name'); + var options = { offset: page * limit, limit: limit }; + dataset.read_slice(fields, options , function (records) { + if (!self.datagroup.openable) { + view.configure_pager(dataset); + } else { + var pages = Math.ceil(dataset.ids.length / limit); + self.$row + .find('.oe-pager-state') + .text(_.sprintf('%d/%d', page + 1, pages)) + .end() + .find('button[data-pager-action=previous]') + .attr('disabled', page === 0) + .end() + .find('button[data-pager-action=next]') + .attr('disabled', page === pages - 1); + } + + self.records.add(records, {silent: true}); + list.render(); + d.resolve(list); + }); + return d.promise(); + }, + setup_resequence_rows: function (list, dataset) { + // drag and drop enabled if list is not sorted and there is a + // "sequence" column in the view. + if ((dataset.sort && dataset.sort()) + || !_(this.columns).any(function (column) { + return column.name === 'sequence'; })) { + return; + } + // ondrop, move relevant record & fix sequences + list.$current.sortable({ + stop: function (event, ui) { + var to_move = list.records.get(ui.item.data('id')), + target_id = ui.item.prev().data('id'); + + list.records.remove(to_move); + var to = target_id ? list.records.indexOf(list.records.get(target_id)) : 0; + list.records.add(to_move, { at: to }); + + // resequencing time! + var record, index = to, + // if drag to 1st row (to = 0), start sequencing from 0 + // (exclusive lower bound) + seq = to ? list.records.at(to - 1).get('sequence') : 0; + while (++seq, record = list.records.at(index++)) { + // write are independent from one another, so we can just + // launch them all at the same time and we don't really + // give a fig about when they're done + dataset.write(record.get('id'), {sequence: seq}); + record.set('sequence', seq); + } + + list.refresh_zebra(); + } + }); + }, + render: function (post_render) { + var self = this; + var $element = $('<tbody>'); + this.elements = [$element[0]]; + + this.datagroup.list( + _(this.view.visible_columns).chain() + .filter(function (column) { return column.tag === 'field' }) + .pluck('name').value(), + function (groups) { + $element[0].appendChild( + self.render_groups(groups)); + if (post_render) { post_render(); } + }, function (dataset) { + self.render_dataset(dataset).then(function (list) { + self.children[null] = list; + self.elements = + [list.$current.replaceAll($element)[0]]; + self.setup_resequence_rows(list, dataset); + if (post_render) { post_render(); } + }); + }); + return $element; + }, + /** + * Returns the ids of all selected records for this group, and the records + * themselves + */ + get_selection: function () { + var ids = [], records = []; + + _(this.children) + .each(function (child) { + var selection = child.get_selection(); + ids.push.apply(ids, selection.ids); + records.push.apply(records, selection.records); + }); + + return {ids: ids, records: records}; + }, + on_records_reset: function () { + this.children = {}; + $(this.elements).remove(); + }, + get_records: function () { + if (_(this.children).isEmpty()) { + return { + count: this.datagroup.length, + values: this.datagroup.aggregates + } + } + return _(this.children).chain() + .map(function (child) { + return child.get_records(); + }).flatten().value(); + } +}); + +/** + * @mixin Events + */ +var Events = /** @lends Events# */{ + /** + * @param {String} event event to listen to on the current object, null for all events + * @param {Function} handler event handler to bind to the relevant event + * @returns this + */ + bind: function (event, handler) { + var calls = this['_callbacks'] || (this._callbacks = {}); + + if (event in calls) { + calls[event].push(handler); + } else { + calls[event] = [handler]; + } + return this; + }, + /** + * @param {String} event event to unbind on the current object + * @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event) + * @returns this + */ + unbind: function (event, handler) { + var calls = this._callbacks || {}; + if (!(event in calls)) { return this; } + if (!handler) { + delete calls[event]; + } else { + var handlers = calls[event]; + handlers.splice( + _(handlers).indexOf(handler), + 1); + } + return this; + }, + /** + * @param {String} event + * @returns this + */ + trigger: function (event) { + var calls; + if (!(calls = this._callbacks)) { return this; } + var callbacks = (calls[event] || []).concat(calls[null] || []); + for(var i=0, length=callbacks.length; i<length; ++i) { + callbacks[i].apply(this, arguments); + } + return this; + } +}; +var Record = openerp.web.Class.extend(/** @lends Record# */{ + /** + * @constructs Record + * @extends openerp.web.Class + * + * @mixes Events + * @param {Object} [data] + */ + init: function (data) { + this.attributes = data || {}; + }, + /** + * @param {String} key + * @returns {Object} + */ + get: function (key) { + return this.attributes[key]; + }, + /** + * @param key + * @param value + * @param {Object} [options] + * @param {Boolean} [options.silent=false] + * @returns {Record} + */ + set: function (key, value, options) { + options = options || {}; + var old_value = this.attributes[key]; + if (old_value === value) { + return this; + } + this.attributes[key] = value; + if (!options.silent) { + this.trigger('change:' + key, this, value, old_value); + this.trigger('change', this, key, value, old_value); + } + return this; + }, + /** + * Converts the current record to the format expected by form views: + * + * .. code-block:: javascript + * + * data: { + * $fieldname: { + * value: $value + * } + * } + * + * + * @returns {Object} record displayable in a form view + */ + toForm: function () { + var form_data = {}; + _(this.attributes).each(function (value, key) { + form_data[key] = {value: value}; + }); + + return {data: form_data}; + } +}); +Record.include(Events); +var Collection = openerp.web.Class.extend(/** @lends Collection# */{ + /** + * Smarter collections, with events, very strongly inspired by Backbone's. + * + * Using a "dumb" array of records makes synchronization between the + * various serious + * + * @constructs Collection + * @extends openerp.web.Class + * + * @mixes Events + * @param {Array} [records] records to initialize the collection with + * @param {Object} [options] + */ + init: function (records, options) { + options = options || {}; + _.bindAll(this, '_onRecordEvent'); + this.length = 0; + this.records = []; + this._byId = {}; + this._proxies = {}; + this._key = options.key; + this._parent = options.parent; + + if (records) { + this.add(records); + } + }, + /** + * @param {Object|Array} record + * @param {Object} [options] + * @param {Number} [options.at] + * @param {Boolean} [options.silent=false] + * @returns this + */ + add: function (record, options) { + options = options || {}; + var records = record instanceof Array ? record : [record]; + + for(var i=0, length=records.length; i<length; ++i) { + var instance = (records[i] instanceof Record) ? records[i] : new Record(records[i]); + instance.bind(null, this._onRecordEvent); + this._byId[instance.get('id')] = instance; + if (options.at == undefined) { + this.records.push(instance); + if (!options.silent) { + this.trigger('add', this, instance, this.records.length-1); + } + } else { + var insertion_index = options.at + i; + this.records.splice(insertion_index, 0, instance); + if (!options.silent) { + this.trigger('add', this, instance, insertion_index); + } + } + this.length++; + } + return this; + }, + + /** + * Get a record by its index in the collection, can also take a group if + * the collection is not degenerate + * + * @param {Number} index + * @param {String} [group] + * @returns {Record|undefined} + */ + at: function (index, group) { + if (group) { + var groups = group.split('.'); + return this._proxies[groups[0]].at(index, groups.join('.')); + } + return this.records[index]; + }, + /** + * Get a record by its database id + * + * @param {Number} id + * @returns {Record|undefined} + */ + get: function (id) { + if (!_(this._proxies).isEmpty()) { + var record = null; + _(this._proxies).detect(function (proxy) { + return record = proxy.get(id); + }); + return record; + } + return this._byId[id]; + }, + /** + * Builds a proxy (insert/retrieve) to a subtree of the collection, by + * the subtree's group + * + * @param {String} section group path section + * @returns {Collection} + */ + proxy: function (section) { + return this._proxies[section] = new Collection(null, { + parent: this, + key: section + }).bind(null, this._onRecordEvent); + }, + /** + * @param {Array} [records] + * @returns this + */ + reset: function (records) { + _(this._proxies).each(function (proxy) { + proxy.reset(); + }); + this._proxies = {}; + this.length = 0; + this.records = []; + this._byId = {}; + if (records) { + this.add(records); + } + this.trigger('reset', this); + return this; + }, + /** + * Removes the provided record from the collection + * + * @param {Record} record + * @returns this + */ + remove: function (record) { + var self = this; + var index = _(this.records).indexOf(record); + if (index === -1) { + _(this._proxies).each(function (proxy) { + proxy.remove(record); + }); + return this; + } + + this.records.splice(index, 1); + delete this._byId[record.get('id')]; + this.length--; + this.trigger('remove', record, this); + return this; + }, + + _onRecordEvent: function (event, record, options) { + // don't propagate reset events + if (event === 'reset') { return; } + this.trigger.apply(this, arguments); + }, + + // underscore-type methods + each: function (callback) { + for(var section in this._proxies) { + if (this._proxies.hasOwnProperty(section)) { + this._proxies[section].each(callback); + } + } + for(var i=0; i<this.length; ++i) { + callback(this.records[i]); + } + }, + map: function (callback) { + var results = []; + this.each(function (record) { + results.push(callback(record)); + }); + return results; + }, + pluck: function (fieldname) { + return this.map(function (record) { + return record.get(fieldname); + }); + }, + indexOf: function (record) { + return _(this.records).indexOf(record); + } +}); +Collection.include(Events); +openerp.web.list = { + Events: Events, + Record: Record, + Collection: Collection +} +}; +// vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: |
