diff --git a/web_widget_x2many_2d_matrix/README.rst b/web_widget_x2many_2d_matrix/README.rst index 52eb81b1..52b2f3cf 100644 --- a/web_widget_x2many_2d_matrix/README.rst +++ b/web_widget_x2many_2d_matrix/README.rst @@ -39,16 +39,18 @@ Use this widget by saying:: This assumes that my_field refers to a model with the fields `x`, `y` and `value`. If your fields are named differently, pass the correct names as -attributes:: +attributes: - - - - - - - - +.. code-block:: xml + + + + + + + + + You can pass the following parameters: @@ -80,49 +82,53 @@ You need a data structure already filled with values. Let's assume we want to use this widget in a wizard that lets the user fill in planned hours for one task per project per user. In this case, we can use ``project.task`` as our data model and point to it from our wizard. The crucial part is that we fill -the field in the default function:: - - from odoo import fields, models - - class MyWizard(models.TransientModel): - _name = 'my.wizard' - - def _default_task_ids(self): - # your list of project should come from the context, some selection - # in a previous wizard or wherever else - projects = self.env['project.project'].browse([1, 2, 3]) - # same with users - users = self.env['res.users'].browse([1, 2, 3]) - return [ - (0, 0, { - 'name': 'Sample task name', - 'project_id': p.id, - 'user_id': u.id, - 'planned_hours': 0, - 'message_needaction': False, - 'date_deadline': fields.Date.today(), - }) - # if the project doesn't have a task for the user, create a new one - if not p.task_ids.filtered(lambda x: x.user_id == u) else - # otherwise, return the task - (4, p.task_ids.filtered(lambda x: x.user_id == u)[0].id) - for p in projects - for u in users - ] - - task_ids = fields.Many2many('project.task', default=_default_task_ids) - -Now in our wizard, we can use:: - - - - - - - - - - +the field in the default function: + +.. code-block:: python + + from odoo import fields, models + + class MyWizard(models.TransientModel): + _name = 'my.wizard' + + def _default_task_ids(self): + # your list of project should come from the context, some selection + # in a previous wizard or wherever else + projects = self.env['project.project'].browse([1, 2, 3]) + # same with users + users = self.env['res.users'].browse([1, 2, 3]) + return [ + (0, 0, { + 'name': 'Sample task name', + 'project_id': p.id, + 'user_id': u.id, + 'planned_hours': 0, + 'message_needaction': False, + 'date_deadline': fields.Date.today(), + }) + # if the project doesn't have a task for the user, + # create a new one + if not p.task_ids.filtered(lambda x: x.user_id == u) else + # otherwise, return the task + (4, p.task_ids.filtered(lambda x: x.user_id == u)[0].id) + for p in projects + for u in users + ] + + task_ids = fields.Many2many('project.task', default=_default_task_ids) + +Now in our wizard, we can use: + +.. code-block:: xml + + + + + + + + + Known issues / Roadmap ====================== @@ -134,6 +140,13 @@ Known issues / Roadmap * Support limit total records in the matrix. Ref: https://github.com/OCA/web/issues/901 +* Support cell traversal through keyboard arrows. + +* Entering the widget from behind by pressing ``Shift+TAB`` in your keyboard + will enter into the 1st cell until https://github.com/odoo/odoo/pull/26490 + is merged. + +* Support extra invisible fields inside each cell. Bug Tracker =========== @@ -154,7 +167,7 @@ Contributors * Artem Kostyuk * Simone Orsi * Timon Tschanz - +* Jairo Llopis Maintainer ---------- diff --git a/web_widget_x2many_2d_matrix/static/src/js/2d_matrix_renderer.js b/web_widget_x2many_2d_matrix/static/src/js/2d_matrix_renderer.js index 092278a5..d3c98afa 100644 --- a/web_widget_x2many_2d_matrix/static/src/js/2d_matrix_renderer.js +++ b/web_widget_x2many_2d_matrix/static/src/js/2d_matrix_renderer.js @@ -2,422 +2,554 @@ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ odoo.define('web_widget_x2many_2d_matrix.X2Many2dMatrixRenderer', function (require) { - "use strict"; - - // heavily inspired by Odoo's `ListRenderer` - var BasicRenderer = require('web.BasicRenderer'); - var config = require('web.config'); - var field_utils = require('web.field_utils'); - var utils = require('web.utils'); - var core = require('web.core'); - var _t = core._t - var FIELD_CLASSES = { - // copied from ListRenderer - float: 'o_list_number', - integer: 'o_list_number', - monetary: 'o_list_number', - text: 'o_list_text', - }; - - var X2Many2dMatrixRenderer = BasicRenderer.extend({ - - init: function (parent, state, params) { - this._super.apply(this, arguments); - this.editable = params.editable; - this.columns = params.matrix_data.columns; - this.rows = params.matrix_data.rows; - this.matrix_data = params.matrix_data; - }, - /** - * Main render function for the matrix widget. It is rendered as a table. For now, - * this method does not wait for the field widgets to be ready. - * - * @override - * @private - * returns {Deferred} this deferred is resolved immediately - */ - _renderView: function () { - var self = this; - - // Display a nice message if there's no data to display - this.$el.empty(); - if (!self.rows.length){ - var $alert = $('
', {'class': 'alert alert-info'}); - $alert.text(_t('Sorry no matrix data to display.')); - this.$el.append($alert); - return this._super(); - } - - var $table = $('').addClass('o_list_view table table-condensed table-striped'); - this.$el - .addClass('table-responsive') - .append($table); - - this._computeColumnAggregates(); - this._computeRowAggregates(); - - $table - .append(this._renderHeader()) - .append(this._renderBody()); - if (self.matrix_data.show_column_totals) { - $table.append(this._renderFooter()); - } - return this._super(); - }, - /** - * Render the table body. Looks for the table body and renders the rows in it. - * Also it sets the tabindex on every input element. - * - * @private - * return {jQueryElement} The table body element that was just filled. - */ - _renderBody: function () { - var $body = $('').append(this._renderRows()); - _.each($body.find('input'), function (td, i) { - $(td).attr('tabindex', i); - }); - return $body; - }, - /** - * Render the table head of our matrix. Looks for the first table head - * and inserts the header into it. - * - * @private - * @return {jQueryElement} The thead element that was inserted into. - */ - _renderHeader: function () { - var $tr = $('').append('').append($tr); - }, - /** - * Render a single header cell. Creates a th and adds the description as text. - * - * @private - * @param {jQueryElement} node - * @returns {jQueryElement} the created . - * If aggregate is set on the row it also will generate the aggregate cell. - * - * @private - * @param {Object} row: The row that will be rendered. - * @returns {jQueryElement} the element that has been rendered. - */ - _renderRow: function (row) { - var self = this; - var $tr = $('', {class: 'o_data_row'}); - $tr = $tr.append(self._renderLabelCell(row.data[0])); - var $cells = _.map(this.columns, function (node, index) { - var record = row.data[index]; - // make the widget use our field value for each cell - node.attrs.name = self.matrix_data.field_value; - return self._renderBodyCell(record, node, index, {mode:''}); - }); - $tr = $tr.append($cells); - if (row.aggregate) { - $tr.append(self._renderAggregateRowCell(row)); - } - return $tr; - }, - /** - * Renders the label for a specific row. - * - * @private - * @params {Object} record: Contains the information about the record. - * @params {jQueryElement} the cell that was rendered. - */ - _renderLabelCell: function(record) { - var $td = $('').append($('').append('
'); - $tr= $tr.append(_.map(this.columns, this._renderHeaderCell.bind(this))); - if (this.matrix_data.show_row_totals) { - $tr.append($('', {class: 'total'})); - } - return $('
node. - */ - _renderHeaderCell: function (node) { - var name = node.attrs.name; - var field = this.state.fields[name]; - var $th = $(''); - if (!field) { - return $th; - } - var description; - if (node.attrs.widget) { - description = this.state.fieldsInfo.list[name].Widget.prototype.description; - } - if (description === undefined) { - description = node.attrs.string || field.string; - } - $th.text(description).data('name', name); - - if (field.type === 'float' || field.type === 'integer' || field.type === 'monetary') { - $th.addClass('text-right'); - } - - if (config.debug) { - var fieldDescr = { - field: field, - name: name, - string: description || name, - record: this.state, - attrs: node.attrs, - }; - this._addFieldTooltip(fieldDescr, $th); - } - return $th; - }, - /** - * Proxy call to function rendering single row. - * - * @private - * @returns {String} a string with the generated html. - * - */ - - _renderRows: function () { - return _.map(this.rows, this._renderRow.bind(this)); - }, - /** - * Render a single row with all its columns. Renders all the cells and then wraps them with a
'); - var value = record.data[this.matrix_data.field_y_axis]; - if (value.type == 'record') { - // we have a related record - value = value.data.display_name; - } - // get 1st column filled w/ Y label - $td.text(value); - return $td; - }, - /** - * Create a cell and fill it with the aggregate value. - * - * @private - * @param {Object} row: the row object to aggregate. - * @returns {jQueryElement} The rendered cell. - */ - _renderAggregateRowCell: function (row) { - var $cell = $('', {class: 'row-total text-right'}); - this._apply_aggregate_value($cell, row.aggregate); - return $cell; - }, - /** - * Render a single body Cell. - * Gets the field and renders the widget. We force the edit mode, since - * we always want the widget to be editable. - * - * @private - * @param {Object} record: Contains the data for this cell - * @param {jQueryElement} node: The HTML of the field. - * @param {int} colIndex: The index of the current column. - * @param {Object} options: The obtions used for the widget - * @returns {jQueryElement} the rendered cell. - */ - _renderBodyCell: function (record, node, colIndex, options) { - var tdClassName = 'o_data_cell'; - if (node.tag === 'button') { - tdClassName += ' o_list_button'; - } else if (node.tag === 'field') { - var typeClass = FIELD_CLASSES[this.state.fields[node.attrs.name].type]; - if (typeClass) { - tdClassName += (' ' + typeClass); - } - if (node.attrs.widget) { - tdClassName += (' o_' + node.attrs.widget + '_cell'); - } - } - // TODO roadmap: here we should collect possible extra params - // the user might want to attach to each single cell. - var $td = $('', { - 'class': tdClassName, - 'data-form-id': record.id, - 'data-id': record.data.id, - }); - // We register modifiers on the element so that it gets the correct - // modifiers classes (for styling) - var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode')); - // If the invisible modifiers is true, the element is left empty. - // Indeed, if the modifiers was to change the whole cell would be - // rerendered anyway. - if (modifiers.invisible && !(options && options.renderInvisible)) { - return $td; - } - options.mode = this.getParent().mode; // enforce mode of the parent - var widget = this._renderFieldWidget(node, record, _.pick(options, 'mode')); - this._handleAttributes(widget.$el, node); - return $td.append(widget.$el); - }, - /** - * Wraps the column aggregate with a tfoot element - * - * @private - * @returns {jQueryElement} The footer element with the cells in it. - */ - _renderFooter: function () { - var $cells = this._renderAggregateColCells(); - if ($cells) { - return $('
').append($cells)); - } - return; - }, - /** - * Render the Aggregate cells for the column. - * - * @private - * @returns {List} the rendered cells - */ - _renderAggregateColCells: function () { - var self = this; - return _.map(this.columns, function (column, index) { - var $cell = $('', {class: 'col-total text-right'}); - if (column.aggregate) { - self._apply_aggregate_value($cell, column.aggregate); - } - return $cell; - }); - }, - /** - * Compute the column aggregates. - * This function is called everytime the value is changed. - * - * @private - */ - _computeColumnAggregates: function () { - if (!this.matrix_data.show_column_totals) { - return; - } - var self = this, - fname = this.matrix_data.field_value, - field = this.state.fields[fname]; - if (!field) { return; } - var type = field.type; - if (type !== 'integer' && type !== 'float' && type !== 'monetary') { - return; - } - _.each(self.columns, function (column, index) { - column.aggregate = { - fname: fname, - ftype: type, - // TODO: translate - help: 'Sum', - value: 0 - }; - _.each(self.rows, function (row) { - // var record = _.findWhere(self.state.data, {id: col.data.id}); - column.aggregate.value += row.data[index].data[fname]; - }); - }); - }, + "use strict"; + + // Heavily inspired by Odoo's `ListRenderer` + var BasicRenderer = require('web.BasicRenderer'); + var config = require('web.config'); + var core = require('web.core'); + var field_utils = require('web.field_utils'); + var _t = core._t; + var FIELD_CLASSES = { + // Copied from ListRenderer + float: 'o_list_number', + integer: 'o_list_number', + monetary: 'o_list_number', + text: 'o_list_text', + }; + + var X2Many2dMatrixRenderer = BasicRenderer.extend({ + + /** + * @override + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.editable = params.editable; + this._saveMatrixData(params.matrix_data); + }, + + /** + * Update matrix data in current renderer instance. + * + * @param {Object} matrixData Contains the matrix data + */ + _saveMatrixData: function (matrixData) { + this.columns = matrixData.columns; + this.rows = matrixData.rows; + this.matrix_data = matrixData; + }, + + /** + * Main render function for the matrix widget. + * + * It is rendered as a table. For now, + * this method does not wait for the field widgets to be ready. + * + * @override + * @private + * @returns {Deferred} this deferred is resolved immediately + */ + _renderView: function () { + var self = this; + + // Display a nice message if there's no data to display + this.$el.empty(); + if (!self.rows.length) { + var $alert = $('
', {'class': 'alert alert-info'}); + $alert.text(_t('Sorry no matrix data to display.')); + this.$el.append($alert); + return this._super(); + } + + var $table = $('').addClass( + 'o_list_view table table-condensed table-striped' + ); + this.$el + .addClass('table-responsive') + .append($table); + + this._computeColumnAggregates(); + this._computeRowAggregates(); + + $table + .append(this._renderHeader()) + .append(this._renderBody()); + if (self.matrix_data.show_column_totals) { + $table.append(this._renderFooter()); + } + return this._super(); + }, + + /** + * Render the table body. + * + * Looks for the table body and renders the rows in it. + * Also it sets the tabindex on every input element. + * + * @private + * @returns {jQueryElement} The table body element just filled. + */ + _renderBody: function () { + var $body = $('').append(this._renderRows()); + _.each($body.find('input'), function (td, i) { + $(td).attr('tabindex', i); + }); + return $body; + }, + + /** + * Render the table head of our matrix. Looks for the first table head + * and inserts the header into it. + * + * @private + * @returns {jQueryElement} The thead element that was inserted into. + */ + _renderHeader: function () { + var $tr = $('').append('').append($tr); + }, + + /** + * Render a single header cell. + * + * Creates a th and adds the description as text. + * + * @private + * @param {jQueryElement} node + * @returns {jQueryElement} the created . + * If aggregate is set on the row it also will generate + * the aggregate cell. + * + * @private + * @param {Object} row The row that will be rendered. + * @returns {jQueryElement} the element that has been rendered. + */ + _renderRow: function (row) { + var $tr = $('', {class: 'o_data_row'}), + _data = _.without(row.data, undefined); + $tr = $tr.append(this._renderLabelCell(_data[0])); + var $cells = _.map(this.columns, function (node, index) { + var record = row.data[index]; + // Make the widget use our field value for each cell + node.attrs.name = this.matrix_data.field_value; + return this._renderBodyCell(record, node, index, {mode:''}); + }.bind(this)); + $tr = $tr.append($cells); + if (row.aggregate) { + $tr.append(this._renderAggregateRowCell(row)); + } + return $tr; + }, + + /** + * Renders the label for a specific row. + * + * @private + * @param {Object} record Contains the information about the record. + * @returns {jQueryElement} the cell that was rendered. + */ + _renderLabelCell: function (record) { + var $td = $('').append( + $('').append('
'); + $tr = $tr.append(_.map( + this.columns, + this._renderHeaderCell.bind(this) + )); + if (this.matrix_data.show_row_totals) { + $tr.append($('', {class: 'total'})); + } + return $('
node. + */ + _renderHeaderCell: function (node) { + var name = node.attrs.name; + var field = this.state.fields[name]; + var $th = $(''); + if (!field) { + return $th; + } + var description = null; + if (node.attrs.widget) { + description = this.state.fieldsInfo.list[name] + .Widget.prototype.description; + } + if (_.isNull(description)) { + description = node.attrs.string || field.string; + } + $th.text(description).data('name', name); + + if ( + field.type === 'float' || field.type === 'integer' || + field.type === 'monetary' + ) { + $th.addClass('text-right'); + } + + if (config.debug) { + var fieldDescr = { + field: field, + name: name, + string: description || name, + record: this.state, + attrs: node.attrs, + }; + this._addFieldTooltip(fieldDescr, $th); + } + return $th; + }, + + /** + * Proxy call to function rendering single row. + * + * @private + * @returns {String} a string with the generated html. + */ + _renderRows: function () { + return _.map(this.rows, this._renderRow.bind(this)); + }, + + /** + * Render a single row with all its columns. + * Renders all the cells and then wraps them with a
'); + var value = record.data[this.matrix_data.field_y_axis]; + if (value.type === 'record') { + // We have a related record + value = value.data.display_name; + } + // Get 1st column filled w/ Y label + $td.text(value); + return $td; + }, + + /** + * Create a cell and fill it with the aggregate value. + * + * @private + * @param {Object} row the row object to aggregate. + * @returns {jQueryElement} The rendered cell. + */ + _renderAggregateRowCell: function (row) { + var $cell = $('', {class: 'row-total text-right'}); + this._apply_aggregate_value($cell, row.aggregate); + return $cell; + }, + /** - * Compute the row aggregates. - * This function is called everytime the value is changed. - * - * @private - */ - _computeRowAggregates: function () { - if (!this.matrix_data.show_row_totals) { - return; - } - var self = this, - fname = this.matrix_data.field_value, - field = this.state.fields[fname]; - if (!field) { return; } - var type = field.type; - if (type !== 'integer' && type !== 'float' && type !== 'monetary') { - return; - } - _.each(self.rows, function (row) { - row.aggregate = { - fname: fname, - ftype: type, - // TODO: translate - help: 'Sum', - value: 0 - }; - _.each(row.data, function (col) { - row.aggregate.value += col.data[fname]; - }); - }); - }, - /** - * Takes the given Value, formats it and adds it to the given cell. - * - * @private - * @param {jQueryElement} $cell: The Cell where the aggregate should be added. - * @param {Object} aggregate: The object which contains the information about the aggregate value - */ - _apply_aggregate_value: function ($cell, aggregate) { - var field = this.state.fields[aggregate.fname], - formatter = field_utils.format[field.type]; - var formattedValue = formatter(aggregate.value, field, {escape: true, }); - $cell.addClass('total').attr('title', aggregate.help).html(formattedValue); - }, - /** - * Check if the change was successful and then update the grid. - * This function is required on relational fields. - * - * @params {Object} state: Contains the current state of the field & all the data - * @params {String} id: the id of the updated object. - * @params {Array} fields: The fields we have in the view. - * @params {Object} ev: The event object. - * @returns {Deferred} The deferred object thats gonna be resolved when the change is made. - */ - confirmUpdate: function (state, id, fields, ev) { - var self = this; - this.state = state; - return this.confirmChange(state, id, fields, ev).then(function () { - self._refresh(id); - }); - }, - /** - * Refresh our grid. - * - * @private - */ - _refresh: function (id) { - this._updateRow(id); - this._refreshColTotals(); - this._refreshRowTotals(); - }, - /** - *Update row data in our internal rows. - * - * @params {String} id: The id of the row that needs to be updated. - */ - _updateRow: function (id) { - var self = this, - record = _.findWhere(self.state.data, {id: id}); - _.each(self.rows, function(row) { - _.each(row.data, function(col, i) { - if (col.id == id) { - row.data[i] = record; - } - }); - }); - }, - /** - * Update the row total. - */ - _refreshColTotals: function () { - this._computeColumnAggregates(); - this.$('tfoot').replaceWith(this._renderFooter()); - }, - /** - * Update the column total. - */ - _refreshRowTotals: function () { - var self = this; - this._computeRowAggregates(); - var $rows = self.$el.find('tr.o_data_row'); - _.each(self.rows, function(row, i) { - if (row.aggregate) { - $($rows[i]).find('.row-total') - .replaceWith(self._renderAggregateRowCell(row)); - } - }); - }, - /* - x2m fields expect this - */ - getEditableRecordID: function (){ return false;} - - }); - - return X2Many2dMatrixRenderer; + * Render a single body Cell. + * Gets the field and renders the widget. We force the edit mode, since + * we always want the widget to be editable. + * + * @private + * @param {Object} record Contains the data for this cell + * @param {jQueryElement} node The HTML of the field. + * @param {int} colIndex The index of the current column. + * @param {Object} options The obtions used for the widget + * @returns {jQueryElement} the rendered cell. + */ + _renderBodyCell: function (record, node, colIndex, options) { + var tdClassName = 'o_data_cell'; + if (node.tag === 'button') { + tdClassName += ' o_list_button'; + } else if (node.tag === 'field') { + var typeClass = FIELD_CLASSES[ + this.state.fields[node.attrs.name].type + ]; + if (typeClass) { + tdClassName += ' ' + typeClass; + } + if (node.attrs.widget) { + tdClassName += ' o_' + node.attrs.widget + '_cell'; + } + } + // TODO roadmap: here we should collect possible extra params + // the user might want to attach to each single cell. + var $td = $('', { + 'class': tdClassName, + }); + if (_.isUndefined(record)) { + // Without record, nothing elese to do + return $td; + } + $td.attr({ + 'data-form-id': record.id, + 'data-id': record.data.id, + }); + // We register modifiers on the element so that it gets + // the correct modifiers classes (for styling) + var modifiers = this._registerModifiers( + node, + record, + $td, + _.pick(options, 'mode') + ); + // If the invisible modifiers is true, the element is + // left empty. Indeed, if the modifiers was to change the + // whole cell would be rerendered anyway. + if (modifiers.invisible && !(options && options.renderInvisible)) { + return $td; + } + // Enforce mode of the parent + options.mode = this.getParent().mode; + var widget = this._renderFieldWidget( + node, record, _.pick(options, 'mode') + ); + this._handleAttributes(widget.$el, node); + return $td.append(widget.$el); + }, + + /** + * Wraps the column aggregate with a tfoot element + * + * @private + * @returns {jQueryElement} The footer element with the cells in it. + */ + _renderFooter: function () { + var $cells = this._renderAggregateColCells(); + if ($cells) { + return $('
').append($cells) + ); + } + }, + + /** + * Render the Aggregate cells for the column. + * + * @private + * @returns {List} the rendered cells + */ + _renderAggregateColCells: function () { + var self = this; + return _.map(this.columns, function (column) { + var $cell = $('', {class: 'col-total text-right'}); + if (column.aggregate) { + self._apply_aggregate_value($cell, column.aggregate); + } + return $cell; + }); + }, + + /** + * Compute the column aggregates. + * This function is called everytime the value is changed. + * + * @private + */ + _computeColumnAggregates: function () { + if (!this.matrix_data.show_column_totals) { + return; + } + var fname = this.matrix_data.field_value, + field = this.state.fields[fname]; + if (!field) { + return; + } + var type = field.type; + if (!~['integer', 'float', 'monetary'].indexOf(type)) { + return; + } + _.each(this.columns, function (column, index) { + column.aggregate = { + fname: fname, + ftype: type, + help: _t('Sum'), + value: 0, + }; + _.each(this.rows, function (row) { + // TODO Use only one _.propertyOf in underscore 1.9.0+ + try { + column.aggregate.value += row.data[index].data[fname]; + } catch (error) { + // Nothing to do + } + }); + }.bind(this)); + }, + + /** + * @override + */ + updateState: function (state, params) { + if (params.matrix_data) { + this._saveMatrixData(params.matrix_data); + } + return this._super.apply(this, arguments); + }, + + /** + * Traverse the fields matrix with the keyboard + * + * @override + * @private + * @param {OdooEvent} event "navigation_move" event + */ + _onNavigationMove: function (event) { + var widgets = this.__parentedChildren, + index = widgets.indexOf(event.target), + first = index === 0, + last = index === widgets.length - 1, + move = 0; + // Guess if we have to move the focus + if (event.data.direction === "next" && !last) { + move = 1; + } else if (event.data.direction === "previous" && !first) { + move = -1; + } + // Move focus + if (move) { + var target = widgets[index + move]; + index = this.allFieldWidgets[target.record.id].indexOf(target); + this._activateFieldWidget(target.record, index, {inc: 0}); + event.stopPropagation(); + } + }, + + /** + * Compute the row aggregates. + * + * This function is called everytime the value is changed. + * + * @private + */ + _computeRowAggregates: function () { + if (!this.matrix_data.show_row_totals) { + return; + } + var fname = this.matrix_data.field_value, + field = this.state.fields[fname]; + if (!field) { + return; + } + var type = field.type; + if (!~['integer', 'float', 'monetary'].indexOf(type)) { + return; + } + _.each(this.rows, function (row) { + row.aggregate = { + fname: fname, + ftype: type, + help: _t('Sum'), + value: 0, + }; + _.each(row.data, function (col) { + // TODO Use _.property in underscore 1.9+ + try { + row.aggregate.value += col.data[fname]; + } catch (error) { + // Nothing to do + } + }); + }); + }, + + /** + * Takes the given Value, formats it and adds it to the given cell. + * + * @private + * + * @param {jQueryElement} $cell + * The Cell where the aggregate should be added. + * + * @param {Object} aggregate + * The object which contains the information about the aggregate value + */ + _apply_aggregate_value: function ($cell, aggregate) { + var field = this.state.fields[aggregate.fname], + formatter = field_utils.format[field.type]; + var formattedValue = formatter( + aggregate.value, field, {escape: true} + ); + $cell.addClass('total').attr('title', aggregate.help) + .html(formattedValue); + }, + + /** + * Check if the change was successful and then update the grid. + * This function is required on relational fields. + * + * @param {Object} state + * Contains the current state of the field & all the data + * + * @param {String} id + * the id of the updated object. + * + * @param {Array} fields + * The fields we have in the view. + * + * @param {Object} ev + * The event object. + * + * @returns {Deferred} + * The deferred object thats gonna be resolved when the change is made. + */ + confirmUpdate: function (state, id, fields, ev) { + var self = this; + this.state = state; + return this.confirmChange(state, id, fields, ev).then(function () { + self._refresh(id); + }); + }, + + /** + * Refresh our grid. + * + * @private + * @param {String} id Datapoint ID + */ + _refresh: function (id) { + this._updateRow(id); + this._refreshColTotals(); + this._refreshRowTotals(); + }, + + /** + *Update row data in our internal rows. + * + * @param {String} id: The id of the row that needs to be updated. + */ + _updateRow: function (id) { + var record = _.findWhere(this.state.data, {id: id}), + _id = _.property("id"); + _.each(this.rows, function (row) { + _.each(row.data, function (col, i) { + if (_id(col) === id) { + row.data[i] = record; + } + }); + }); + }, + + /** + * Update the row total. + */ + _refreshColTotals: function () { + this._computeColumnAggregates(); + this.$('tfoot').replaceWith(this._renderFooter()); + }, + + /** + * Update the column total. + */ + _refreshRowTotals: function () { + var self = this; + this._computeRowAggregates(); + var $rows = self.$el.find('tr.o_data_row'); + _.each(self.rows, function (row, i) { + if (row.aggregate) { + $($rows[i]).find('.row-total') + .replaceWith(self._renderAggregateRowCell(row)); + } + }); + }, + + /** + * X2many fields expect this + * + * @returns {null} + */ + getEditableRecordID: function () { + return null; + }, + + }); + + return X2Many2dMatrixRenderer; }); diff --git a/web_widget_x2many_2d_matrix/static/src/js/widget_x2many_2d_matrix.js b/web_widget_x2many_2d_matrix/static/src/js/widget_x2many_2d_matrix.js index 56d2598f..addf7cd4 100644 --- a/web_widget_x2many_2d_matrix/static/src/js/widget_x2many_2d_matrix.js +++ b/web_widget_x2many_2d_matrix/static/src/js/widget_x2many_2d_matrix.js @@ -4,177 +4,221 @@ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */ odoo.define('web_widget_x2many_2d_matrix.widget', function (require) { - "use strict"; + "use strict"; - var core = require('web.core'); - // var FieldManagerMixin = require('web.FieldManagerMixin'); - var field_registry = require('web.field_registry'); - var relational_fields = require('web.relational_fields'); - var weContext = require('web_editor.context'); - // var Helpers = require('web_widget_x2many_2d_matrix.helpers'); - var AbstractField = require('web.AbstractField'); - var X2Many2dMatrixRenderer = require('web_widget_x2many_2d_matrix.X2Many2dMatrixRenderer'); + var field_registry = require('web.field_registry'); + var relational_fields = require('web.relational_fields'); + var X2Many2dMatrixRenderer = require( + 'web_widget_x2many_2d_matrix.X2Many2dMatrixRenderer' + ); - var WidgetX2Many2dMatrix = relational_fields.FieldOne2Many.extend({ - widget_class: 'o_form_field_x2many_2d_matrix', - /** - * Initialize the widget & parameters. - * - * @param {Object} parent: contains the form view. - * @param {String} name: the name of the field. - * @param {Object} record: Contains the information about the database records. - * @param {Object} options: Contains the view options. - */ - init: function (parent, name, record, options) { - this._super(parent, name, record, options); - this.init_params(); - }, + var WidgetX2Many2dMatrix = relational_fields.FieldOne2Many.extend({ + widget_class: 'o_form_field_x2many_2d_matrix', - /** - * Initialize the widget specific parameters. - * Sets the axis and the values. - */ - init_params: function () { - var node = this.attrs; - this.by_x_axis = {}; - this.by_y_axis = {}; - this.field_x_axis = node.field_x_axis || this.field_x_axis; - this.field_y_axis = node.field_y_axis || this.field_y_axis; - this.field_label_x_axis = node.field_label_x_axis || this.field_x_axis; - this.field_label_y_axis = node.field_label_y_axis || this.field_y_axis; - this.x_axis_clickable = this.parse_boolean(node.x_axis_clickable || '1'); - this.y_axis_clickable = this.parse_boolean(node.y_axis_clickable || '1'); - this.field_value = node.field_value || this.field_value; - // TODO: is this really needed? Holger? - for (var property in node) { - if (property.startsWith("field_att_")) { - this.fields_att[property.substring(10)] = node[property]; - } - } - // and this? - this.field_editability = node.field_editability || this.field_editability; - this.show_row_totals = this.parse_boolean(node.show_row_totals || '1'); - this.show_column_totals = this.parse_boolean(node.show_column_totals || '1'); + /** + * Initialize the widget & parameters. + * + * @param {Object} parent contains the form view. + * @param {String} name the name of the field. + * @param {Object} record information about the database records. + * @param {Object} options view options. + */ + init: function (parent, name, record, options) { + this._super(parent, name, record, options); + this.init_params(); + }, - }, - /** - * Initializes the Value matrix. - * Puts the values in the grid. If we have related items we use the display name. - */ - init_matrix: function(){ - var self = this, - records = self.recordData[this.name].data; - // Wipe the content if something still exists - this.by_x_axis = {}; - this.by_y_axis = {}; - _.each(records, function(record) { - var x = record.data[self.field_x_axis], - y = record.data[self.field_y_axis]; - if (x.type == 'record') { - // we have a related record - x = x.data.display_name; - } - if (y.type == 'record') { - // we have a related record - y = y.data.display_name; - } - self.by_x_axis[x] = self.by_x_axis[x] || {}; - self.by_y_axis[y] = self.by_y_axis[y] || {}; - self.by_x_axis[x][y] = record; - self.by_y_axis[y][x] = record; - }); - // init columns - self.columns = []; - $.each(self.by_x_axis, function(x){ - self.columns.push(self._make_column(x)); - }); - self.rows = []; - $.each(self.by_y_axis, function(y){ - self.rows.push(self._make_row(y)); - }); - self.matrix_data = { - 'field_value': self.field_value, - 'field_x_axis': self.field_x_axis, - 'field_y_axis': self.field_y_axis, - 'columns': self.columns, - 'rows': self.rows, - 'show_row_totals': self.show_row_totals, - 'show_column_totals': self.show_column_totals - }; + /** + * Initialize the widget specific parameters. + * Sets the axis and the values. + */ + init_params: function () { + var node = this.attrs; + this.by_x_axis = {}; + this.by_y_axis = {}; + this.field_x_axis = node.field_x_axis || this.field_x_axis; + this.field_y_axis = node.field_y_axis || this.field_y_axis; + this.field_label_x_axis = + node.field_label_x_axis || this.field_x_axis; + this.field_label_y_axis = + node.field_label_y_axis || this.field_y_axis; + this.x_axis_clickable = this.parse_boolean( + node.x_axis_clickable || '1' + ); + this.y_axis_clickable = this.parse_boolean( + node.y_axis_clickable || '1' + ); + this.field_value = node.field_value || this.field_value; + // TODO: is this really needed? Holger? + for (var property in node) { + if (property.startsWith("field_att_")) { + this.fields_att[property.substring(10)] = + node[property]; + } + } + // And this? + this.field_editability = + node.field_editability || this.field_editability; + this.show_row_totals = + this.parse_boolean(node.show_row_totals || '1'); + this.show_column_totals = + this.parse_boolean(node.show_column_totals || '1'); + }, - }, - /** - * Create scaffold for a column. - * - * @params {String} x: The string used as a column title - */ - _make_column: function(x){ - return { - // simulate node parsed on xml arch - 'tag': 'field', - 'attrs': { - 'name': this.field_x_axis, - 'string': x - } - }; - }, - /** - * Create scaffold for a row. - * - * @params {String} x: The string used as a row title - */ - _make_row: function(y){ - var self = this; - // use object so that we can attach more data if needed - var row = {'data': []}; - $.each(self.by_x_axis, function(x) { - row.data.push(self.by_y_axis[y][x]); - }); - return row; - }, - /** - *Parse a String containing a Python bool or 1 and convert it to a proper bool. - * - * @params {String} val: the string to be parsed. - * @returns {Boolean} The parsed boolean. - */ - parse_boolean: function(val) { - if (val.toLowerCase() === 'true' || val === '1') { - return true; - } - return false; - }, - /** - *Create the matrix renderer and add its output to our element - * - * @returns {Deferred} A deferred object to be completed when it finished rendering. - */ - _render: function () { - if (!this.view) { - return this._super(); - } - // Ensure widget is re initiated when rendering - this.init_matrix(); - var arch = this.view.arch, - viewType = 'list'; - this.renderer = new X2Many2dMatrixRenderer(this, this.value, { - arch: arch, - editable: true, - viewType: viewType, - matrix_data: this.matrix_data - }); - this.$el.addClass('o_field_x2many o_field_x2many_2d_matrix'); - // Remove previous rendered and add the newly created one - this.$el.find('div:not(.o_x2m_control_panel)').remove(); - return this.renderer.appendTo(this.$el); + /** + * Initializes the Value matrix. + * + * Puts the values in the grid. + * If we have related items we use the display name. + */ + init_matrix: function () { + var records = this.recordData[this.name].data; + // Wipe the content if something still exists + this.by_x_axis = {}; + this.by_y_axis = {}; + _.each(records, function (record) { + var x = record.data[this.field_x_axis], + y = record.data[this.field_y_axis]; + if (x.type === 'record') { + // We have a related record + x = x.data.display_name; + } + if (y.type === 'record') { + // We have a related record + y = y.data.display_name; + } + this.by_x_axis[x] = this.by_x_axis[x] || {}; + this.by_y_axis[y] = this.by_y_axis[y] || {}; + this.by_x_axis[x][y] = record; + this.by_y_axis[y][x] = record; + }.bind(this)); + // Init columns + this.columns = []; + $.each(this.by_x_axis, function (x) { + this.columns.push(this._make_column(x)); + }.bind(this)); + this.rows = []; + $.each(this.by_y_axis, function (y) { + this.rows.push(this._make_row(y)); + }.bind(this)); + this.matrix_data = { + 'field_value': this.field_value, + 'field_x_axis': this.field_x_axis, + 'field_y_axis': this.field_y_axis, + 'columns': this.columns, + 'rows': this.rows, + 'show_row_totals': this.show_row_totals, + 'show_column_totals': this.show_column_totals, + }; + }, - } + /** + * Create scaffold for a column. + * + * @param {String} x The string used as a column title + * @returns {Object} + */ + _make_column: function (x) { + return { + // Simulate node parsed on xml arch + 'tag': 'field', + 'attrs': { + 'name': this.field_x_axis, + 'string': x, + }, + }; + }, - }); + /** + * Create scaffold for a row. + * + * @param {String} y The string used as a row title + * @returns {Object} + */ + _make_row: function (y) { + var self = this; + // Use object so that we can attach more data if needed + var row = {'data': []}; + $.each(self.by_x_axis, function (x) { + row.data.push(self.by_y_axis[y][x]); + }); + return row; + }, - field_registry.add('x2many_2d_matrix', WidgetX2Many2dMatrix); + /** + * Parse a String containing a bool and convert it to a JS bool. + * + * @param {String} val: the string to be parsed. + * @returns {Boolean} The parsed boolean. + */ + parse_boolean: function (val) { + if (val.toLowerCase() === 'true' || val === '1') { + return true; + } + return false; + }, - return { - WidgetX2Many2dMatrix: WidgetX2Many2dMatrix - }; + /** + * Create the matrix renderer and add its output to our element + * + * @returns {Deferred} + * A deferred object to be completed when it finished rendering. + */ + _render: function () { + if (!this.view) { + return this._super(); + } + // Ensure widget is re initiated when rendering + this.init_matrix(); + var arch = this.view.arch; + // Update existing renderer + if (!_.isUndefined(this.renderer)) { + return this.renderer.updateState(this.value, { + matrix_data: this.matrix_data, + }); + } + // Create a new matrix renderer + this.renderer = new X2Many2dMatrixRenderer(this, this.value, { + arch: arch, + editable: this.mode === 'edit' && arch.attrs.editable, + viewType: "list", + matrix_data: this.matrix_data, + }); + this.$el.addClass('o_field_x2many o_field_x2many_2d_matrix'); + return this.renderer.appendTo(this.$el); + }, + + /** + * Activate the widget. + * + * @override + */ + activate: function (options) { + // Won't work fine without https://github.com/odoo/odoo/pull/26490 + // TODO Use _.propertyOf in underscore 1.9+ + try { + this._backwards = options.event.data.direction === "previous"; + } catch (error) { + this._backwards = false; + } + var result = this._super.apply(this, arguments); + delete this._backwards; + return result; + }, + + /** + * Get first element to focus. + * + * @override + */ + getFocusableElement: function () { + return this.$(".o_input:" + (this._backwards ? "last" : "first")); + }, + }); + + field_registry.add('x2many_2d_matrix', WidgetX2Many2dMatrix); + + return { + WidgetX2Many2dMatrix: WidgetX2Many2dMatrix, + }; });