").append($tr);
+ }
+ },
+
+ /**
+ * Renders the total cell (of all rows / columns)
+ *
+ * @private
+ * @returns {jQueryElement} The td element with the total in it.
+ */
+ _renderTotalCell: function () {
+ if (
+ !this.matrix_data.show_column_totals ||
+ !this.matrix_data.show_row_totals
+ ) {
+ return;
+ }
+
+ var $cell = $("| ", {class: "col-total"});
+ this.applyAggregateValue($cell, this.total);
+ return $cell;
+ },
+
+ /**
+ * 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 = $(" | ");
+ if (config.isDebug()) {
+ $cell.addClass(column.attrs.name);
+ }
+ if (column.aggregate) {
+ self.applyAggregateValue($cell, column);
+ }
+ 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;
+ }
+ this.total = {
+ attrs: {
+ name: fname,
+ },
+ aggregate: {
+ help: _t("Sum Total"),
+ value: 0,
+ },
+ };
+ _.each(
+ this.columns,
+ function (column, index) {
+ column.aggregate = {
+ 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
+ }
+ });
+ this.total.aggregate.value += column.aggregate.value;
+ }.bind(this)
+ );
+ },
+
+ _getRecord: function (recordId) {
+ var record = null;
+ utils.traverse_records(this.state, function (r) {
+ if (r.id === recordId) {
+ record = r;
+ }
+ });
+ return record;
+ },
+
+ /**
+ * @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 = {
+ 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} axis
+ * The object which contains the information about the aggregate value axis
+ */
+ applyAggregateValue: function ($cell, axis) {
+ var field = this.state.fields[axis.attrs.name];
+ var value = axis.aggregate.value;
+ var help = axis.aggregate.help;
+ var fieldInfo = this.state.fieldsInfo.list[axis.attrs.name];
+ var formatFunc =
+ field_utils.format[fieldInfo.widget ? fieldInfo.widget : field.type];
+ var formattedValue = formatFunc(value, field, {escape: true});
+ $cell.addClass("o_list_number").attr("title", 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/2d_matrix_view.js b/web_widget_x2many_2d_matrix/static/src/js/2d_matrix_view.js
new file mode 100644
index 000000000..a223cf804
--- /dev/null
+++ b/web_widget_x2many_2d_matrix/static/src/js/2d_matrix_view.js
@@ -0,0 +1,22 @@
+/* Copyright 2019 Alexandre Díaz
+ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
+
+odoo.define("web_widget_x2many_2d_matrix.X2Many2dMatrixView", function (require) {
+ "use strict";
+
+ var BasicView = require("web.BasicView");
+
+ BasicView.include({
+ _processField: function (viewType, field, attrs) {
+ // Workaround for kanban mode rendering.
+ // Source of the issue: https://github.com/OCA/OCB/blob/12.0/addons/web/static/src/js/views/basic/basic_view.js#L303 .
+ // See https://github.com/OCA/web/pull/1404#pullrequestreview-305813206 .
+ // In the long term we should a way to handle kanban mode
+ // better (eg: a specific renderer).
+ if (attrs.widget === "x2many_2d_matrix") {
+ attrs.mode = "tree";
+ }
+ return this._super(viewType, field, attrs);
+ },
+ });
+});
diff --git a/web_widget_x2many_2d_matrix/static/src/js/abstract_view_matrix_limit_extend.js b/web_widget_x2many_2d_matrix/static/src/js/abstract_view_matrix_limit_extend.js
new file mode 100644
index 000000000..3303f86cb
--- /dev/null
+++ b/web_widget_x2many_2d_matrix/static/src/js/abstract_view_matrix_limit_extend.js
@@ -0,0 +1,16 @@
+odoo.define("web_widget_x2many_2d_matrix.matrix_limit_extend", function (require) {
+ "use strict";
+
+ var FormView = require("web.FormView");
+
+ FormView.include({
+ // We extend this method so that the view is not limited to
+ // just 40 cells when the 'x2many_2d_matrix' widget is used.
+ _setSubViewLimit: function (attrs) {
+ this._super(attrs);
+ if (attrs.widget === "x2many_2d_matrix") {
+ attrs.limit = Infinity;
+ }
+ },
+ });
+});
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
new file mode 100644
index 000000000..118206925
--- /dev/null
+++ b/web_widget_x2many_2d_matrix/static/src/js/widget_x2many_2d_matrix.js
@@ -0,0 +1,260 @@
+/* Copyright 2015 Holger Brunn
+ * Copyright 2016 Pedro M. Baeza
+ * Copyright 2018 Simone Orsi
+ * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). */
+
+odoo.define("web_widget_x2many_2d_matrix.widget", function (require) {
+ "use strict";
+
+ 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 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();
+ },
+
+ /**
+ * Initialize the widget specific parameters.
+ * Sets the axis and the values.
+ */
+ init_params: function () {
+ var node = this.attrs;
+ this.by_y_axis = {};
+ this.x_axis = [];
+ this.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];
+ }
+ }
+ var field_defs = this.recordData[this.name].fields;
+ // TODO: raise when any of the fields above don't exist with a
+ // helpful error message
+ if (!field_defs[this.field_value]) {
+ throw new Error(
+ _.str.sprintf(
+ "You need to include %s in your view definition",
+ this.field_value
+ )
+ );
+ }
+ this.show_row_totals = this.parse_boolean(
+ node.show_row_totals ||
+ this.is_aggregatable(field_defs[this.field_value])
+ ? "1"
+ : ""
+ );
+ this.show_column_totals = this.parse_boolean(
+ node.show_column_totals ||
+ this.is_aggregatable(field_defs[this.field_value])
+ ? "1"
+ : ""
+ );
+ },
+
+ /**
+ * 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_y_axis = {};
+ this.x_axis = [];
+ this.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_y_axis[y] = this.by_y_axis[y] || {};
+ this.by_y_axis[y][x] = record;
+ if (this.y_axis.indexOf(y) === -1) {
+ this.y_axis.push(y);
+ }
+ if (this.x_axis.indexOf(x) === -1) {
+ this.x_axis.push(x);
+ }
+ }.bind(this)
+ );
+ // Init columns
+ this.columns = [];
+ _.each(
+ this.x_axis,
+ function (x) {
+ this.columns.push(this._make_column(x));
+ }.bind(this)
+ );
+ this.rows = [];
+ _.each(
+ this.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 = {
+ tag: "field",
+ attrs: {
+ name: this.field_y_axis,
+ string: y,
+ },
+ data: [],
+ };
+ _.each(self.x_axis, function (x) {
+ row.data.push(self.by_y_axis[y][x]);
+ });
+ return row;
+ },
+
+ /**
+ * Determine if a field represented by field_def can be aggregated
+ */
+ is_aggregatable: function (field_def) {
+ return field_def.type in {float: 1, monetary: 1, integer: 1};
+ },
+
+ /**
+ * 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;
+ },
+
+ /**
+ * 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,
+ };
+});
diff --git a/web_widget_x2many_2d_matrix/static/src/scss/web_widget_x2many_2d_matrix.scss b/web_widget_x2many_2d_matrix/static/src/scss/web_widget_x2many_2d_matrix.scss
new file mode 100644
index 000000000..8596f01ef
--- /dev/null
+++ b/web_widget_x2many_2d_matrix/static/src/scss/web_widget_x2many_2d_matrix.scss
@@ -0,0 +1,72 @@
+$x2many_2d_matrix_max_height: 450px;
+
+.o_form_view .o_field_x2many_2d_matrix {
+ .table-responsive {
+ max-height: $x2many_2d_matrix_max_height;
+ overflow-y: auto;
+ }
+
+ .o_x2many_2d_matrix.o_list_view {
+ > thead > tr > th {
+ white-space: pre-line;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background-color: $o-list-footer-bg-color;
+
+ &.total {
+ right: 0;
+ }
+ }
+
+ > tbody {
+ > tr {
+ &:nth-of-type(2n + 1) td.row-total,
+ &:nth-of-type(2n + 1) td:first-child {
+ background-color: mix(#000, #fff, 1%);
+ }
+ &:nth-of-type(2n) td.row-total,
+ &:nth-of-type(2n) td:first-child {
+ background-color: white;
+ }
+
+ > td {
+ text-align: left;
+
+ &:first-child {
+ position: sticky;
+ left: 0;
+ border-right-width: 1px;
+ border-right-color: $gray-300;
+ border-right-style: solid;
+ box-shadow: -1px 5px 10px $gray-300;
+ }
+ &.row-total {
+ font-weight: bold;
+ position: sticky;
+ right: 0;
+ border-left-width: 1px;
+ border-left-color: $gray-300;
+ border-left-style: solid;
+ box-shadow: -1px 5px 10px $gray-300;
+ }
+ }
+ }
+ }
+
+ > tfoot > tr > td {
+ padding: 0.75rem;
+ text-align: left;
+ background-color: $o-list-footer-bg-color;
+ position: sticky;
+ bottom: 0;
+
+ &.col-total {
+ right: 0;
+ border-left-width: 1px;
+ border-left-color: $gray-300;
+ border-left-style: solid;
+ }
+ }
+ }
+}
|