Browse Source

Merge pull request #1022 from Tecnativa/11.0-matrix-tabnavigation

[FIX] web_widget_x2many_2d_matrix: Fix linters  and enable keyboard navigation
pull/1038/head
Pedro M. Baeza 6 years ago
committed by GitHub
parent
commit
6d5751c592
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 119
      web_widget_x2many_2d_matrix/README.rst
  2. 966
      web_widget_x2many_2d_matrix/static/src/js/2d_matrix_renderer.js
  3. 372
      web_widget_x2many_2d_matrix/static/src/js/widget_x2many_2d_matrix.js

119
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:
<field name="my_field" widget="x2many_2d_matrix" field_x_axis="my_field1" field_y_axis="my_field2" field_value="my_field3">
<tree>
<field name="my_field"/>
<field name="my_field1"/>
<field name="my_field2"/>
<field name="my_field3"/>
</tree>
</field>
.. code-block:: xml
<field name="my_field" widget="x2many_2d_matrix" field_x_axis="my_field1" field_y_axis="my_field2" field_value="my_field3">
<tree>
<field name="my_field"/>
<field name="my_field1"/>
<field name="my_field2"/>
<field name="my_field3"/>
</tree>
</field>
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::
<field name="task_ids" widget="x2many_2d_matrix" field_x_axis="project_id" field_y_axis="user_id" field_value="planned_hours">
<tree>
<field name="task_ids"/>
<field name="project_id"/>
<field name="user_id"/>
<field name="planned_hours"/>
</tree>
</field>
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
<field name="task_ids" widget="x2many_2d_matrix" field_x_axis="project_id" field_y_axis="user_id" field_value="planned_hours">
<tree>
<field name="task_ids"/>
<field name="project_id"/>
<field name="user_id"/>
<field name="planned_hours"/>
</tree>
</field>
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 <a.kostyuk@mobilunity.com>
* Simone Orsi <simone.orsi@camptocamp.com>
* Timon Tschanz <timon.tschanz@camptocamp.com>
* Jairo Llopis <jairo.llopis@tecnativa.com>
Maintainer
----------

966
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 = $('<div>', {'class': 'alert alert-info'});
$alert.text(_t('Sorry no matrix data to display.'));
this.$el.append($alert);
return this._super();
}
var $table = $('<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 = $('<tbody>').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 = $('<tr>').append('<th/>');
$tr= $tr.append(_.map(this.columns, this._renderHeaderCell.bind(this)));
if (this.matrix_data.show_row_totals) {
$tr.append($('<th/>', {class: 'total'}));
}
return $('<thead>').append($tr);
},
/**
* Render a single header cell. Creates a th and adds the description as text.
*
* @private
* @param {jQueryElement} node
* @returns {jQueryElement} the created <th> node.
*/
_renderHeaderCell: function (node) {
var name = node.attrs.name;
var field = this.state.fields[name];
var $th = $('<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 <tr>.
* 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 <tr> element that has been rendered.
*/
_renderRow: function (row) {
var self = this;
var $tr = $('<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 = $('<td>');
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 = $('<td/>', {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 = $('<td>', {
'class': tdClassName,
'data-form-id': record.id,
'data-id': record.data.id,
});
// We register modifiers on the <td> 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 <td> 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 $('<tfoot>').append($('<tr>').append('<td/>').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 = $('<td>', {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 = $('<div>', {'class': 'alert alert-info'});
$alert.text(_t('Sorry no matrix data to display.'));
this.$el.append($alert);
return this._super();
}
var $table = $('<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 = $('<tbody>').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 = $('<tr>').append('<th/>');
$tr = $tr.append(_.map(
this.columns,
this._renderHeaderCell.bind(this)
));
if (this.matrix_data.show_row_totals) {
$tr.append($('<th/>', {class: 'total'}));
}
return $('<thead>').append($tr);
},
/**
* Render a single header cell.
*
* Creates a th and adds the description as text.
*
* @private
* @param {jQueryElement} node
* @returns {jQueryElement} the created <th> node.
*/
_renderHeaderCell: function (node) {
var name = node.attrs.name;
var field = this.state.fields[name];
var $th = $('<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 <tr>.
* 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 <tr> element that has been rendered.
*/
_renderRow: function (row) {
var $tr = $('<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 = $('<td>');
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 = $('<td/>', {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 = $('<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 <td> 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 <td> 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 $('<tfoot>').append(
$('<tr>').append('<td/>').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 = $('<td>', {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;
});

372
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,
};
});
Loading…
Cancel
Save