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 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 `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: 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 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 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 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 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 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 Bug Tracker
=========== ===========
@ -154,7 +167,7 @@ Contributors
* Artem Kostyuk <a.kostyuk@mobilunity.com> * Artem Kostyuk <a.kostyuk@mobilunity.com>
* Simone Orsi <simone.orsi@camptocamp.com> * Simone Orsi <simone.orsi@camptocamp.com>
* Timon Tschanz <timon.tschanz@camptocamp.com> * Timon Tschanz <timon.tschanz@camptocamp.com>
* Jairo Llopis <jairo.llopis@tecnativa.com>
Maintainer 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). */ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
odoo.define('web_widget_x2many_2d_matrix.X2Many2dMatrixRenderer', function (require) { 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). */ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
odoo.define('web_widget_x2many_2d_matrix.widget', function (require) { 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