合并企业版代码(未测试,先提交到测试分支)

This commit is contained in:
qihao.gong@jikimo.com
2023-04-14 17:42:23 +08:00
parent 7a7b3d7126
commit d28525526a
1300 changed files with 513579 additions and 5426 deletions

View File

@@ -0,0 +1,19 @@
odoo.define('web_grid.component_registry', function (require) {
"use strict";
const Registry = require('web.Registry');
return new Registry();
});
odoo.define('web_grid._component_registry', function (require) {
"use strict";
const components = require('web_grid.components');
const registry = require('web_grid.component_registry');
registry
.add('float_factor', components.FloatFactorComponent)
.add('float_time', components.FloatTimeComponent)
.add('float_toggle', components.FloatToggleComponent);
});

View File

@@ -0,0 +1,265 @@
odoo.define('web_grid.components', function (require) {
"use strict";
const fieldUtils = require('web.field_utils');
const utils = require('web.utils');
const { debounce } = require("@web/core/utils/timing");
const { Component, onPatched, onWillUpdateProps, useRef, useState } = owl;
class BaseGridComponent extends Component {
setup() {
this.currentInput = useRef("currentInput");
this.state = useState({
error: false,
});
onWillUpdateProps(this.onWillUpdateProps);
onPatched(this.onPatched);
}
onWillUpdateProps(nextProps) {
if (nextProps.date !== this.props.date) {
// if we change the range of dates we are looking at, the
// component must remove it's error state
this.state.error = false;
}
}
onPatched() {
if (this.currentInput.el) {
this.currentInput.el.select();
}
}
//----------------------------------------------------------------------
// Getters
//----------------------------------------------------------------------
/**
* Returns the additional options needed for format/parse.
* Override this getter to add options.
*
* @returns {Object}
*/
get fieldOptions() {
return this.props.nodeOptions;
}
/**
* Returns the formatType needed for the format/parse function.
* Override this getter to add options.
*
* @returns {Object}
*/
get formatType() {
return this.constructor.formatType || this.props.fieldInfo.type;
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* @private
* @param {any} value
* @returns {string}
*/
_format(value) {
return fieldUtils.format[this.formatType](value, {}, this.fieldOptions);
}
/**
* @private
* @param {any} value
* @returns {string}
*/
_parse(value) {
return fieldUtils.parse[this.formatType](value, {}, this.fieldOptions);
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* This handler verifies that the value has a good format, if it is
* the case it will trigger an event to update the value in DB.
*
* @private
* @param {MouseEvent} ev
*/
_onBlurCell(ev) {
let value;
try {
value = this._parse(ev.target.value);
this.state.error = false;
} catch (_) {
this.state.error = ev.target.value;
} finally {
this.props.onCellUpdated({
path: this.props.path,
value
});
}
}
/**
* This handler notifies the grid that a cell has been focused
*
* @private
*/
_onFocusCell() {
this.props.onCellFocused(this.props.path);
}
}
BaseGridComponent.defaultProps = {
cellHeight: 0,
cellValue: 0,
hasBarChartTotal: false,
readonly: false,
isTotal: false,
nodeOptions: {},
onCellFocused: () => {},
onCellUpdated: () => {},
};
BaseGridComponent.props = {
cellHeight: {
type: Number,
optional: true
},
cellValue: {
type: Number,
optional: true
},
fieldInfo: Object,
hasBarChartTotal: {
type: Boolean,
optional: true,
},
isInput: Boolean,
nodeOptions: {
type: Object,
optional: true,
},
onCellFocused: {
type: Function,
optional: true,
},
onCellUpdated: {
type: Function,
optional: true,
},
path: {
type: String,
optional: true
},
readonly: {
type: Boolean,
optional: true,
},
isTotal: {
type: Boolean,
optional: true
},
date: {
type: String,
optional: true
},
};
BaseGridComponent.template = 'web_grid.BaseGridComponent';
BaseGridComponent.formatType = 'float_factor';
class FloatFactorComponent extends BaseGridComponent {}
class FloatTimeComponent extends BaseGridComponent {
get fieldOptions() {
return Object.assign({}, super.fieldOptions, {
noLeadingZeroHour: true,
});
}
}
FloatTimeComponent.formatType = 'float_time';
class FloatToggleComponent extends BaseGridComponent {
setup() {
super.setup();
this.state = useState({
disabled: false,
value: this.initialValue,
});
this._onClickButton = debounce(this._onClickButton, 200, true);
}
onWillUpdateProps(nextProps) {
if (nextProps.cellValue !== this.initialValue) {
this.state.value = nextProps.cellValue;
}
}
//----------------------------------------------------------------------
// Getters
//----------------------------------------------------------------------
/**
* Returns the additional options to the format function.
*
* @returns {Object}
*/
get fieldOptions() {
const fieldOptions = Object.assign({}, this.props.nodeOptions);
if (!fieldOptions.factor) {
fieldOptions.factor = 1;
}
const range = [0.0, 0.5, 1.0];
if (!fieldOptions.range) {
fieldOptions.range = range;
}
return fieldOptions;
}
/**
* Returns the initial value.
*
* @returns {Number}
*/
get initialValue() {
return this.props.cellValue;
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* This handler is called when a user clicks on a button
* it will change the value in the state
*
* @private
* @param {MouseEvent} ev
*/
_onClickButton() {
const range = this.fieldOptions.range;
const currentFloat = fieldUtils.parse.float(this._format(this.state.value));
const closest = utils.closestNumber(currentFloat, range);
const closestIndex = range.indexOf(closest);
const nextIndex = closestIndex + 1 < range.length ? closestIndex + 1 : 0;
this.state.value = this._parse(fieldUtils.format.float(range[nextIndex]));
this.state.disabled = true;
this.props.onCellUpdated({
path: this.props.path,
value: this.state.value,
doneCallback: () => {
this.state.disabled = false;
}
});
}
}
FloatToggleComponent.template = 'web_grid.FloatToggleComponent';
return {
BaseGridComponent,
FloatFactorComponent,
FloatTimeComponent,
FloatToggleComponent,
};
});

View File

@@ -0,0 +1,369 @@
odoo.define('web_grid.GridController', function (require) {
"use strict";
var AbstractController = require('web.AbstractController');
var config = require('web.config');
var core = require('web.core');
var utils = require('web.utils');
var concurrency = require('web.concurrency');
const { escape } = require("@web/core/utils/strings");
const { FormViewDialog } = require("@web/views/view_dialogs/form_view_dialog");
var qweb = core.qweb;
var _t = core._t;
const { Component, markup } = owl;
var GridController = AbstractController.extend({
custom_events: Object.assign({}, AbstractController.prototype.custom_events, {
'create_inline': '_addLine',
'cell_edited': '_onCellEdited',
'open_cell_information': '_onOpenCellInformation',
}),
/**
* @override
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.context = params.context;
this.navigationButtons = params.navigationButtons;
this.ranges = params.ranges;
this.currentRange = params.currentRange;
this.formViewID = params.formViewID;
this.listViewID = params.listViewID;
this.adjustment = params.adjustment;
this.adjustName = params.adjustName;
this.canCreate = params.activeActions.create;
this.createInline = params.createInline;
this.displayEmpty = params.displayEmpty;
this.mutex = new concurrency.Mutex();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
* @param {jQuery} [$node]
*/
renderButtons: function ($node) {
this.$buttons = $(qweb.render('grid.GridArrows', {
widget: {
_ranges: this.ranges,
_buttons: this.navigationButtons,
allowCreate: this.canCreate,
},
isMobile: config.device.isMobile
}));
this.$buttons.on('click', '.o_grid_button_add', this._onAddLine.bind(this));
this.$buttons.on('click', '.grid_arrow_previous', this._onPaginationChange.bind(this, 'prev'));
this.$buttons.on('click', '.grid_button_initial', this._onPaginationChange.bind(this, 'initial'));
this.$buttons.on('click', '.grid_arrow_next', this._onPaginationChange.bind(this, 'next'));
this.$buttons.on('click', '.grid_arrow_range', this._onRangeChange.bind(this));
this.$buttons.on('click', '.grid_arrow_button', this._onButtonClicked.bind(this));
this.updateButtons();
if ($node) {
this.$buttons.appendTo($node);
}
},
/**
* @override
*/
updateButtons: function () {
if (!this.$buttons) {
return;
}
const state = this.model.get();
this.$buttons.find('.o_grid_button_add').toggleClass('d-none', this.createInline && (!!state.data[0].rows.length || this.displayEmpty));
this.$buttons.find('.grid_arrow_previous').toggleClass('d-none', !state.data[0].prev);
this.$buttons.find('.grid_arrow_next').toggleClass('d-none', !state.data[0].next);
this.$buttons.find('.grid_button_initial').toggleClass('d-none', !state.data[0].initial);
this.$buttons.find('.grid_arrow_range').removeClass('active');
this.$buttons.find('.grid_arrow_range[data-name=' + this.currentRange + ']').addClass('active');
},
/**
* Get the action to execute.
*/
_getEventAction(label, cell, ctx) {
const noActivitiesFound = _t('No activities found');
return {
type: 'ir.actions.act_window',
name: label,
res_model: this.modelName,
views: [
[this.listViewID, 'list'],
[this.formViewID, 'form']
],
domain: cell.domain,
context: ctx,
help: markup(`<p class='o_view_nocontent_smiling_face'>${escape(noActivitiesFound)}</p>`),
};
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Get the context for the form view.
* @private
*/
_getFormContext() {
return Object.assign({}, this.model.getContext(), { view_grid_add_line: true });
},
/**
* @private
* @returns {object}
*/
_getFormDialogOptions() {
const formContext = this._getFormContext();
// TODO: document quick_create_view (?) context key
var formViewID = formContext.quick_create_view || this.formViewID || false;
return {
resModel: this.modelName,
resId: false,
context: formContext,
viewId: formViewID,
title: _t("Add a Line"),
onRecordSaved: this.reload.bind(this, {}),
};
},
/**
* Open a form View to create a new entry in the grid
* @private
*/
_addLine() {
const options = this._getFormDialogOptions()
Component.env.services.dialog.add(FormViewDialog, options);
},
/**
* @private
* @param {Object} cell
* @param {number} newValue
* @returns {Promise}
*/
_adjust: function (cell, newValue) {
var difference = newValue - cell.value;
// 1e-6 is probably an overkill, but that way milli-values are usable
if (Math.abs(difference) < 1e-6) {
// cell value was set to itself, don't hit the server
return Promise.resolve();
}
// convert row values to a domain, concat to action domain
var state = this.model.get();
var domain = this.model.domain.concat(cell.row.domain);
// early rendering of the new value.
// FIXME: only the model should modify the state, so in master
// move the _adjust method in the model so that it can properly
// handle "pending" data
utils.into(state.data, cell.cell_path).value = newValue;
var self = this;
return this.mutex.exec(function () {
if (self.adjustment === 'action') {
const actionData = {
type: self.adjustment,
name: self.adjustName,
context: self.model.getContext({
grid_adjust: { // context for type=action
row_domain: domain,
column_field: state.colField,
column_value: cell.col.values[state.colField][0],
cell_field: state.cellField,
change: difference,
},
}),
};
return self.trigger_up('execute_action', {
action_data: actionData,
env: {
context: self.model.getContext(),
model: self.modelName
},
on_success: async function () {
let state = self.model.get();
await self.model.reloadCell(cell, state.cellField, state.colField);
state = self.model.get();
await self.renderer.update(state);
self.updateButtons(state);
},
});
}
return self._rpc({
model: self.modelName,
method: self.adjustName,
args: [ // args for type=object
[],
domain,
state.colField,
cell.col.values[state.colField][0],
state.cellField,
difference
],
context: self.model.getContext()
}).then(function () {
return self.model.reloadCell(cell, state.cellField, state.colField);
}).then(function () {
var state = self.model.get();
return self.renderer.update(state);
}).then(function () {
self.updateButtons(state);
});
});
},
/**
* @override
* @private
* @returns {Promise}
*/
_update: function () {
return this._super.apply(this, arguments)
.then(this.updateButtons.bind(this));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} event
*/
_onAddLine: function (event) {
event.preventDefault();
this._addLine();
},
/**
* If something needs to be done when a new value has been set, it can be done here
* @param ev the event that triggered the update
*/
_cellHasBeenUpdated(ev) {
// Currently overriden in timesheet_grid.timesheet_grid_controller
},
/**
* @private
* @param {OdooEvent} e
*/
_onCellEdited: function (event) {
var state = this.model.get();
this._adjust({
row: utils.into(state.data, event.data.row_path),
col: utils.into(state.data, event.data.col_path),
value: utils.into(state.data, event.data.cell_path).value,
cell_path: event.data.cell_path,
}, event.data.value)
.then(() => {
if (event.data.doneCallback !== undefined) {
event.data.doneCallback();
}
this._cellHasBeenUpdated(event);
})
.guardedCatch(function () {
if (event.data.doneCallback !== undefined) {
event.data.doneCallback();
}
});
},
/**
* @private
* @param {MouseEvent} e
*/
_onButtonClicked: function (e) {
var self = this;
e.stopPropagation();
// TODO: maybe allow opting out of getting ids?
var button = this.navigationButtons[$(e.target).attr('data-index')];
var actionData = _.extend({}, button, {
context: this.model.getContext(button.context),
});
this.model.getIds().then(function (ids) {
self.trigger_up('execute_action', {
action_data: actionData,
env: {
context: self.model.getContext(),
model: self.modelName,
resIDs: ids,
},
on_closed: self.reload.bind(self, {}),
});
});
},
/**
* @private
* @param {OwlEvent} ev
*/
_onOpenCellInformation: function (ev) {
var cell_path = ev.data.path.split('.');
var row_path = cell_path.slice(0, -3).concat(['rows'], cell_path.slice(-2, -1));
var state = this.model.get();
var cell = utils.into(state.data, cell_path);
var row = utils.into(state.data, row_path);
var groupFields = state.groupBy.slice(state.isGrouped ? 1 : 0);
var label = _.filter(_.map(groupFields, function (g) {
return row.values[g][1];
}), function (g) {
return g;
}).join(' - ');
// pass group by, section and col fields as default in context
var cols_path = cell_path.slice(0, -3).concat(['cols'], cell_path.slice(-1));
var col = utils.into(state.data, cols_path);
var column_value = col.values[state.colField][0];
if (!column_value) {
column_value = false;
} else if (!_.isNumber(column_value)) {
column_value = column_value.split("/")[0];
}
var ctx = _.extend({}, this.context);
if (this.model.sectionField && state.groupBy && state.groupBy[0] === this.model.sectionField) {
var value = state.data[parseInt(cols_path[0])].__label;
ctx['default_' + this.model.sectionField] = _.isArray(value) ? value[0] : value;
}
_.each(groupFields, function (field) {
ctx['default_' + field] = row.values[field][0] || false;
});
ctx['default_' + state.colField] = column_value;
ctx['create'] = this.canCreate && !cell.readonly;
ctx['edit'] = this.activeActions.edit && !cell.readonly;
this.do_action(this._getEventAction(label, cell, ctx));
},
/**
* @private
* @param {string} dir either 'prev', 'initial' or 'next
*/
_onPaginationChange: function (dir) {
var state = this.model.get();
this.update({pagination: state.data[0][dir]});
},
/**
* @private
* @param {MouseEvent} e
*/
_onRangeChange: function (e) {
e.stopPropagation();
var $target = $(e.target);
if (config.device.isMobile) {
$target.closest(".dropdown-menu").prev().dropdown("toggle");
}
if ($target.hasClass('active')) {
return;
}
this.currentRange = $target.attr('data-name');
this.context.grid_range = this.currentRange;
this.update({range: this.currentRange});
},
});
return GridController;
});

View File

@@ -0,0 +1,393 @@
odoo.define('web_grid.GridModel', function (require) {
"use strict";
var AbstractModel = require('web.AbstractModel');
var concurrency = require('web.concurrency');
var utils = require('web.utils');
const fieldUtils = require('web.field_utils');
const { _t } = require('web.core');
const GridModel = AbstractModel.extend({
/**
* GridModel
*
* All data will be loaded in the _gridData object and can be retrieved with
* the `get` method.
*/
/**
* @override
*/
init: function () {
this._super.apply(this, arguments);
this._gridData = null;
this.dp = new concurrency.DropPrevious();
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @public
*/
computeAllTotals: function (state) {
state.data.forEach((group, groupIndex) => {
state.data[groupIndex].totals = this._computeTotals(group.grid);
});
state.totals = this._computeTotals(_.flatten(_.pluck(state.data, 'grid'), true));
return state;
},
/**
* @override
* @returns {Object}
*/
__get: function () {
return this._gridData;
},
/**
* @override
* @param {Object} context
* @returns {Object}
*/
getContext: function (context) {
var c = _.extend({}, this.context, context);
return c;
},
/**
* The data from the grid view basically come from a read_group so we don't
* have any res_ids. A big domain is thus computed with the domain of all
* cells and this big domain is used to search for res_ids.
*
* @returns {Promise<integer[]>} the list of ids used in the grid
*/
getIds: function () {
var data = this._gridData.data;
if (!_.isArray(data)) {
data = [data];
}
var domain = [];
// count number of non-empty cells and only add those to the search
// domain, on sparse grids this makes domains way smaller
var cells = 0;
for (var i = 0; i < data.length; i++) {
var grid = data[i].grid;
for (var j = 0; j < grid.length; j++) {
var row = grid[j];
for (var k = 0; k < row.length; k++) {
var cell = row[k];
if (cell.size !== 0) {
cells++;
domain.push.apply(domain, cell.domain);
}
}
}
}
// if there are no elements in the grid we'll get an empty domain
// which will select all records of the model... that is *not* what
// we want
if (cells === 0) {
return Promise.resolve([]);
}
while (--cells > 0) {
domain.unshift('|');
}
return this._rpc({
model: this.modelName,
method: 'search',
args: [domain],
context: this.getContext(),
});
},
/**
* @override
* @param {Object} params
* @returns {Promise}
*/
__load: function (params) {
this.fields = params.fields;
this.modelName = params.modelName;
this.rowFields = params.rowFields;
this.sectionField = params.sectionField;
this.colField = params.colField;
this.cellField = params.cellField;
this.ranges = params.ranges;
this.currentRange = params.currentRange;
this.domain = params.domain;
this.context = params.context;
var groupedBy = (params.groupedBy && params.groupedBy.length) ?
params.groupedBy : this.rowFields;
this.groupedBy = Array.isArray(groupedBy) ? groupedBy : [groupedBy];
this.readonlyField = params.readonlyField;
return this._fetch(this.groupedBy);
},
/**
* @override
* @param {any} handle this parameter is ignored
* @param {Object} params
* @returns {Promise}
*/
__reload: function (handle, params) {
if (params === 'special') {
return Promise.resolve();
}
params = params || {};
if ('context' in params) {
// keep the grid anchor, when reloading view (e.i.: removing a filter in search view)
var old_context = this.context;
this.context = params.context;
if (old_context.grid_anchor !== undefined || params.context.grid_anchor !== undefined) {
this.context.grid_anchor = old_context.grid_anchor || params.context.grid_anchor;
}
}
if ('domain' in params) {
this.domain = params.domain;
}
if ('pagination' in params) {
_.extend(this.context, params.pagination);
}
if ('range' in params) {
this.context.grid_range = params.range || this.context.grid_range;
this.currentRange = _.findWhere(this.ranges, {name: params.range});
}
if ('groupBy' in params) {
if (params.groupBy.length) {
this.groupedBy = Array.isArray(params.groupBy) ?
params.groupBy : [params.groupBy];
} else {
this.groupedBy = this.rowFields;
}
}
return this._fetch(this.groupedBy);
},
reloadCell: function (cell, cellField, colField) {
var self = this;
var domain = cell.col.domain.concat(this.domain);
var domainRow = cell.row.values;
for (var value in domainRow) {
domain = domain.concat([[value.toString(), '=', domainRow[value][0]]]);
}
/**
* We're doing this because the record can be attribute to someone else
* when it's attribute to no one at the beginning.
* Once we've done this we have to reload all the grid and not only the cell
* (to also change de name of the person it's attribute to)
*/
if (this._gridData.isGrouped) {
var groupLabel = this._gridData.data[cell.cell_path[0]].__label;
if (groupLabel !== undefined)
domain = domain.concat([[this._gridData.groupBy[0], '=', groupLabel[0]]]);
else {
return self._fetch(self._gridData.groupBy);
}
}
return this._rpc({
model: this.modelName,
method: 'read_group',
kwargs: {
domain: domain,
fields: [cellField],
groupby: [colField],
},
context: this.getContext()
}).then(function (result) {
if (result.length === 0 || !(cellField in result[0])) {
return Promise.resolve();
}
var currentCell = utils.into(self._gridData.data, cell.cell_path);
currentCell.value = result[0][cellField];
currentCell.size = result[0][colField + '_count'];
currentCell.domain = domain;
self._gridData = self.computeAllTotals(self._gridData);
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {Object[]} grid
* @returns {{super: number, rows: {}, columns: {}}}
*/
_computeTotals: function (grid) {
const totals = {
super: 0,
rows: {},
columns: {}
};
for (let i = 0; i < grid.length; i++) {
const row = grid[i];
for (let j = 0; j < row.length; j++) {
const cell = row[j];
totals.super += cell.value;
totals.rows[i] = (totals.rows[i] || 0) + cell.value;
totals.columns[j] = (totals.columns[j] || 0) + cell.value;
}
}
return totals;
},
/**
* @private
* @param {Object} rows
* @param {boolean} grouped
* @returns {Object with keys id (string) and label (string[])}
*/
_getRowInfo(row, grouped) {
const fieldNames = Object.keys(row.values);
const rowValues = [];
const rowIds = [];
for (let i = 0; i < fieldNames.length; i++) {
const rowField = fieldNames[i];
let value = row.values[rowField];
const fieldName = rowField.split(':')[0]; // remove groupby function (:day, :month...)
const fieldType = this.fields[fieldName].type;
if (fieldType === 'selection') {
value = this.fields[fieldName].selection.find(function (choice) {
return choice[0] === value;
});
}
const id = value && ["many2one", "selection"].includes(fieldType) ? value[0] : value;
value = value && ["many2one", "selection"].includes(fieldType) ? value[1] : value;
rowValues.push(value);
rowIds.push(id);
}
return { id: rowIds.join(','), label: rowValues };
},
/**
* @private
* @param {string[]} groupBy
* @returns {Promise}
*/
_fetch: function (groupBy) {
if (!this.currentRange) {
return Promise.resolve();
}
if (this.sectionField && this.sectionField === groupBy[0]) {
return this._fetchGroupedData(groupBy);
} else {
return this._fetchUngroupedData(groupBy);
}
},
/**
* @private
* @param {string[]} groupBy
* @returns {Promise}
*/
_fetchGroupedData: async function (groupBy) {
const results = await this.dp.add(this._rpc({
model: this.modelName,
method: 'read_grid_grouped',
kwargs: {
row_fields: groupBy.slice(1),
col_field: this.colField,
cell_field: this.cellField,
section_field: this.sectionField,
domain: (this.domain || []),
current_range: this.currentRange,
readonly_field: this.readonlyField,
},
context: this.getContext(),
}));
if (!(_.isEmpty(results) || _.reduce(results, function (m, it) {
return _.isEqual(m.cols, it.cols) && m;
}))) {
throw new Error(_t("The sectioned grid view can't handle groups with different columns sets"));
}
results.forEach((group, groupIndex) => {
results[groupIndex].totals = this._computeTotals(group.grid);
group.rows.forEach((row, rowIndex) => {
const { id, label } = this._getRowInfo(row, true);
results[groupIndex].rows[rowIndex].id = id;
results[groupIndex].rows[rowIndex].label = label;
});
});
this._updateContext(results);
this._gridData = {
isGrouped: true,
data: results,
totals: this._computeTotals(_.flatten(_.pluck(results, 'grid'), true)),
groupBy,
colField: this.colField,
cellField: this.cellField,
range: this.currentRange.name,
context: this.context,
};
},
/**
* @private
* @param {string[]} groupBy
* @returns {Promise}
*/
_fetchUngroupedData: async function (groupBy) {
const result = await this.dp.add(this._rpc({
model: this.modelName,
method: 'read_grid',
kwargs: {
row_fields: groupBy,
col_field: this.colField,
cell_field: this.cellField,
domain: this.domain,
range: this.currentRange,
readonly_field: this.readonlyField,
},
context: this.getContext(),
}));
const rows = result.rows;
rows.forEach((row, rowIndex) => {
const { id, label } = this._getRowInfo(row, false);
result.rows[rowIndex].label = label;
result.rows[rowIndex].id = id;
});
const data = [result];
this._updateContext(data);
this._gridData = {
isGrouped: false,
data,
totals: this._computeTotals(result.grid),
groupBy,
colField: this.colField,
cellField: this.cellField,
range: this.currentRange.name,
context: this.context,
};
},
/**
*
* @param {Array<Record<string, any>>} records
* @returns
*/
_updateContext(records) {
if (this.currentRange.span && this.currentRange.step && records && records.length) {
// then the colField should be a date/datetime and we need to check if the grid anchor is in the right place.
const record = records[0];
const previousAnchor = fieldUtils.parse.date(record.prev.grid_anchor);
const initialAnchor = fieldUtils.parse.date(record.initial.grid_anchor);
const nextAnchor = fieldUtils.parse.date(record.next.grid_anchor);
if (previousAnchor < initialAnchor && initialAnchor < nextAnchor) {
// then the context will be the initial one.
this.context = Object.assign({}, this.context, record.initial);
}
}
return this.context;
},
});
return GridModel;
});

View File

@@ -0,0 +1,440 @@
odoo.define('web_grid.GridRenderer', function (require) {
"use strict";
const AbstractRenderer = require('web.AbstractRendererOwl');
const fieldUtils = require('web.field_utils');
const utils = require('web.utils');
const gridComponentRegistry = require('web_grid.component_registry');
const { useListener } = require("@web/core/utils/hooks");
const { onPatched, onWillUpdateProps, useRef, useState } = owl;
class GridRenderer extends AbstractRenderer {
setup() {
super.setup();
this.root = useRef("root");
this.state = useState({
editMode: false,
currentPath: "",
errors: {},
});
this.currentInput = useRef("currentInput");
useListener('mouseover', 'td:not(:first-child), th:not(:first-child)', this._onMouseEnter);
useListener('mouseout', 'td:not(:first-child), th:not(:first-child)', this._onMouseLeave);
onWillUpdateProps(this.onWillUpdateProps);
onPatched(this.onPatched);
}
onWillUpdateProps(nextProps) {
if (nextProps.data[0].next.grid_anchor !== this.props.data[0].next.grid_anchor) {
//if we change the range of dates we are looking at,
//the cells should not be in error state anymore
this.state.errors = {};
}
}
onPatched() {
if (this.currentInput.el) {
this.currentInput.el.select();
}
}
//----------------------------------------------------------------------
// Getters
//----------------------------------------------------------------------
/**
* returns the columns of the first gridgroup
*
* @returns {Object}
*/
get columns() {
return this.props.data.length ? this.props.data[0].cols : [];
}
/**
* returns a boolean expressing if the grid uses cellComponents
*
* @returns {Object}
*/
get component() {
return gridComponentRegistry.get(this.props.cellComponent);
}
get gridAnchorNext() {
return this.props.data[0].next.grid_anchor;
}
/**
* As there have to be a minimum of 5 rows in an ungrouped grid,
* this will return the number of empty rows to add
* if there are not enough.
*
* @returns {Array}
*/
get emptyRows() {
const rowLength = this.props.isGrouped ? this.props.data.reduce((count, d) => count + d.rows.length + 1, 0) : this.props.data[0].rows.length;
return Array.from({
length: Math.max(5 - rowLength, 0)
}, (_, i) => i);
}
/**
* get the formatType needed for format and parse
*
* @returns {string}
*/
get formatType() {
if (this.hasComponent) {
return this.component.formatType;
}
return this.props.fields[this.props.cellField].type;
}
/**
* returns a boolean expressing if the grid uses cellComponents
*
* @returns {Boolean}
*/
get hasComponent() {
return gridComponentRegistry.contains(this.props.cellComponent);
}
/**
* Get the information needed to display the total of a grid correctly
* will contain the classMap and the value
*
* @returns {classmap: Object, value: number}
*/
get gridTotal() {
if (this.props.totals.super) {
const classMap = {
'o_grid_super': true,
'text-danger': this.props.totals.super < 0,
};
const value = this.props.totals.super;
return {
classMap,
value
};
} else {
return {
classMap: {
o_grid_super: true
},
value: 0.0
};
}
}
/**
* returns the getMeasureLabels
*
* @returns {string}
*/
get measureLabel() {
if (this.props.measureLabel) {
return _.str.sprintf("%s", this.props.measureLabel);
} else {
return this.env._t("Total");
}
}
/**
* returns the xml of the noContentHelper
*
* @returns {string}
*/
get noContentHelper() {
return utils.json_node_to_xml(this.props.noContentHelper);
}
/**
* returns a boolean expressing if yes or no the noContentHelp should be shown
*
* @returns {Boolean}
*/
get showNoContentHelp() {
const stateRow = Array.isArray(this.props.data) ? this.props.data.find(data => data.rows[0]) : this.props.data.rows[0];
return stateRow === undefined && !!this.props.noContentHelp;
}
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* Notifies the model that a cell has been edited.
*
* @private
* @param {string} path path to the cell
* @param {Number} value new value that we want to store in DB
* @param {Function} doneCallback function to call after update
*/
_cellEdited(path, value, doneCallback) {
const cell_path = path.split('.');
const grid_path = cell_path.slice(0, -3);
const row_path = grid_path.concat(['rows'], cell_path.slice(-2, -1));
const col_path = grid_path.concat(['cols'], cell_path.slice(-1));
this.trigger('cell-edited', {
cell_path,
row_path,
col_path,
value,
doneCallback,
});
}
/**
* @private
* @param {any} value
* @returns {string}
*/
_format(value) {
if (value === undefined) {
return '';
}
const cellField = this.props.fields[this.props.cellField];
return fieldUtils.format[this.formatType](value, cellField, this.props.cellComponentOptions);
}
/**
* @private
* @param {integer} index
* @returns {value: number, smallerThanZero: boolean, muted: boolean}
*/
_formatCellContentTotals(index) {
if (this.props.totals) {
return {
value: this.props.totals.columns[index],
smallerThanZero: this.props.totals.columns[index] < 0,
muted: !this.props.totals.columns || !this.props.totals.columns[index]
};
} else {
return {};
}
}
/**
* @private
* @param {Object} cell
* @returns {Object}
*/
_getCellClassMap(cell) {
// these are "hard-set" for correct grid behaviour
const classmap = {
o_grid_cell_container: true,
o_grid_cell_empty: !cell.size,
o_grid_cell_readonly: !this.props.editableCells || cell.readonly,
};
// merge in class info from the cell
for (const cls of cell.classes || []) {
// don't allow overwriting initial values
if (!(cls in classmap)) {
classmap[cls] = true;
}
}
return classmap;
}
/**
* @private
* @param {string} value
* @returns {*}
*/
_parse(value) {
const cellField = this.props.fields[this.props.cellField];
return fieldUtils.parse[this.formatType](value, cellField, this.props.cellComponentOptions);
}
/**
* measure the height value of footer cell if hasBarChartTotal="true"
* max height value is 90%
*
* @private
* @param {number} index
* @returns {number} height: to be used as css percentage
*/
_totalHeight(index) {
const maxCount = Math.max(...Object.values(this.props.totals.columns));
const factor = maxCount ? (90 / maxCount) : 0;
return factor * this.props.totals.columns[index];
}
//----------------------------------------------------------------------
// Handlers
//----------------------------------------------------------------------
/**
* @private
*/
_onClickCreateInline() {
this.trigger('create-inline');
}
/**
* @private
* @param path path to the cell
*/
_onClickCellInformation(path) {
this.state.editMode = false;
this.trigger('open-cell-information', {
path
});
}
/**
* @private
* @param {string} path
*/
_onFocusComponent(path) {
this.state.editMode = true;
this.state.currentPath = path;
}
/**
* @private
* @param {string} path path to the cell
* @param {CustomEvent} ev
*/
_onFocusGridCell(path) {
this.state.editMode = true;
this.state.currentPath = path;
}
/**
* @private
* @param {Object} cell
* @param {CustomEvent} ev
*/
_onGridInputBlur(ev) {
this.state.editMode = false;
let hasError = false;
let value = ev.target.value;
try {
value = this._parse(value);
} catch (_) {
hasError = true;
}
const path = this.state.currentPath;
if (hasError) {
this.state.errors[path] = value;
} else {
delete this.state.errors[path];
this._cellEdited(path, value);
}
}
/**
* @private
* @param {Object}
*/
_onUpdateValue({ path, value, doneCallback }) {
this.state.editMode = false;
if (value !== undefined) {
this._cellEdited(path, value, doneCallback);
}
}
/**
* Hover the column in which the mouse is.
*
* @private
* @param {MouseEvent} ev
*/
_onMouseEnter(ev) {
const cellParent = ev.target.closest('td,th');
const rowParent = ev.target.closest('tr');
const index = [...rowParent.children].indexOf(cellParent) + 1;
this.root.el.querySelectorAll(`td:nth-child(${index}), th:nth-child(${index})`)
.forEach(el => {
if (cellParent.querySelector('.o_grid_total_title')) {
el.classList.add('o_cell_highlight');
}
el.classList.add('o_cell_hover');
});
}
/**
* Remove the hover on the columns.
*
* @private
*/
_onMouseLeave() {
this.root.el.querySelectorAll('.o_cell_hover')
.forEach(el => el.classList.remove('o_cell_hover', 'o_cell_highlight'));
}
}
GridRenderer.defaultProps = {
cellComponentOptions: {},
hasBarChartTotal: false,
hideColumnTotal: false,
hideLineTotal: false,
};
GridRenderer.props = {
editableCells: {
type: Boolean,
optional: true
},
canCreate: Boolean,
cellComponent: {
type: String,
optional: true
},
cellComponentOptions: {
type: Object,
optional: true,
},
cellField: String,
colField: String,
createInline: Boolean,
displayEmpty: Boolean,
fields: Object,
groupBy: Array,
hasBarChartTotal: {
type: Boolean,
optional: true,
},
hideColumnTotal: {
type: Boolean,
optional: true,
},
hideLineTotal: {
type: Boolean,
optional: true,
},
measureLabel: {
type: String,
optional: true
},
noContentHelp: {
type: String,
optional: true
},
range: String,
context: Object,
arch: Object,
isEmbedded: Boolean,
isGrouped: Boolean,
data: [{
cols: [{
values: Object,
domain: Array,
is_current: Boolean,
is_unavailable: Boolean,
}],
grid: [{
size: Number,
domain: Array,
value: Number,
readonly: {
type: Boolean,
optional: true
},
is_current: Boolean,
is_unavailable: Boolean,
}],
initial: Object,
next: Object,
prev: Object,
rows: [{
values: Object,
domain: Array,
project: Object,
label: Array
}],
totals: {
columns: Object,
rows: Object,
super: Number
},
__label: Array
}],
totals: Object,
};
GridRenderer.template = 'web_grid.GridRenderer';
return GridRenderer;
});

View File

@@ -0,0 +1,165 @@
odoo.define('web_grid.GridView', function (require) {
"use strict";
var AbstractView = require('web.AbstractView');
var config = require('web.config');
var core = require('web.core');
var GridModel = require('web_grid.GridModel');
var GridController = require('web_grid.GridController');
var GridRenderer = require('web_grid.GridRenderer');
var viewRegistry = require('web.view_registry');
var pyUtils = require('web.py_utils');
const RendererWrapper = require('web.RendererWrapper');
var _t = core._t;
var _lt = core._lt;
var GridView = AbstractView.extend({
display_name: _lt('Grid'),
mobile_friendly: true,
icon: 'fa fa-th',
config: _.extend({}, AbstractView.prototype.config, {
Model: GridModel,
Controller: GridController,
Renderer: GridRenderer,
}),
viewType: 'grid',
init: function (viewInfo, params) {
var self = this;
this._super.apply(this, arguments);
var arch = this.arch;
var fields = this.fields;
var rowFields = [];
var sectionField, colField, cellField, ranges, cellComponent, cellComponentOptions, measureLabel, readonlyField;
_.each(arch.children, function (child) {
if (child.tag === 'field') {
if (child.attrs.type === 'row') {
if (child.attrs.section === '1' && !sectionField) {
sectionField = child.attrs.name;
}
rowFields.push(child.attrs.name);
}
if (child.attrs.type === 'col') {
colField = child.attrs.name;
ranges = self._extract_ranges(child, params.context);
}
if (child.attrs.type === 'measure') {
cellField = child.attrs.name;
cellComponent = child.attrs.widget;
if (child.attrs.options) {
cellComponentOptions = JSON.parse(child.attrs.options.replace(/'/g, '"'));
}
measureLabel = child.attrs.string;
}
if (child.attrs.type === 'readonly') {
readonlyField = child.attrs.name;
}
}
});
// model
this.loadParams.ranges = ranges;
let default_range_name = config.device.isMobile ? 'day' : '';
ranges.forEach(range => {
if (range['name'] === 'week' && !config.device.isMobile) {
default_range_name = range['name'];
}
})
let contextRangeName = params.context.grid_range || default_range_name;
var contextRange = contextRangeName && _.findWhere(ranges, {name: contextRangeName});
this.loadParams.fields = this.fields;
this.loadParams.currentRange = contextRange || ranges[0];
this.loadParams.rowFields = rowFields;
this.loadParams.sectionField = sectionField;
this.loadParams.colField = colField;
this.loadParams.cellField = cellField;
this.loadParams.groupedBy = params.groupBy;
this.loadParams.readonlyField = readonlyField;
// renderer
this.rendererParams.canCreate = this.controllerParams.activeActions.create;
this.rendererParams.fields = fields;
this.rendererParams.measureLabel = measureLabel;
this.rendererParams.editableCells = !!(this.controllerParams.activeActions.edit && arch.attrs.adjustment);
this.rendererParams.cellComponent = cellComponent;
this.rendererParams.cellComponentOptions = cellComponentOptions;
this.rendererParams.hideLineTotal = !!JSON.parse(arch.attrs.hide_line_total || '0');
this.rendererParams.hideColumnTotal = !!JSON.parse(arch.attrs.hide_column_total || '0');
this.rendererParams.hasBarChartTotal = !!JSON.parse(arch.attrs.barchart_total || '0');
this.rendererParams.createInline = !!JSON.parse(arch.attrs.create_inline || 'false');
this.rendererParams.displayEmpty = !!JSON.parse(arch.attrs.display_empty || 'false');
this.rendererParams.noContentHelp = (!this.rendererParams.displayEmpty && this.rendererParams.noContentHelp) || "";
// controller
this.controllerParams.formViewID = false;
this.controllerParams.listViewID = false;
_.each(params.actionViews, function (view) {
if (view.type === 'form') {
self.controllerParams.formViewID = view.viewID;
}
if (view.type === 'list') {
self.controllerParams.listViewID = view.viewID;
}
});
this.controllerParams.context = params.context;
this.controllerParams.ranges = ranges;
this.controllerParams.currentRange = this.loadParams.currentRange.name;
this.controllerParams.navigationButtons = arch.children
.filter(function (c) { return c.tag === 'button'; })
.map(function (c) { return c.attrs; });
this.controllerParams.adjustment = arch.attrs.adjustment;
this.controllerParams.adjustName = arch.attrs.adjust_name;
this.controllerParams.createInline = this.rendererParams.createInline;
this.controllerParams.displayEmpty = this.rendererParams.displayEmpty;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
getRenderer(parent, state) {
state = Object.assign({}, state, this.rendererParams);
return new RendererWrapper(null, this.config.Renderer, state);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Extract the range to display on the view, and filter
* them according they should be visible or not (attribute 'invisible')
*
* @private
* @param {node} col_node - the node of 'col' in grid view arch definition
* @param {Object} context - the context used to instanciate the view
* @returns {Array<{name: string, string: string, span: string, step: string}>}
*/
_extract_ranges: function(col_node, context) {
let ranges = [];
const pyevalContext = py.dict.fromJSON(context || {});
for (const range of col_node.children.map(node => node.attrs)) {
if (range.invisible && pyUtils.py_eval(range.invisible, { 'context': pyevalContext })) {
continue;
}
ranges.push(range);
}
if (config.device.isMobile && !ranges.find(r => r.name === 'day')) {
ranges.unshift({
name: 'day',
string: _t('Day'),
span: 'day',
step: 'day',
});
}
return ranges;
},
});
viewRegistry.add('grid', GridView);
return GridView;
});

View File

@@ -0,0 +1,258 @@
$grid-pill-height: 3rem;
.o_web_client .o_view_grid {
table {
margin-bottom: 0;
table-layout: fixed;
min-width: 100%;
@include media-breakpoint-up(sm) {
width: auto;
}
max-width: none;
}
th, td {
border: 0 !important;
vertical-align: middle;
width: 5em;
}
.o_grid_title_header {
width: 10em;
}
thead {
color: $o-main-text-color;
background-color: $o-brand-lightsecondary;
border-bottom: 1px solid $gray-300;
th {
text-align: center;
white-space: pre-line;
&.o_grid_total {
text-align: center;
}
}
}
tfoot, .o_grid_total {
.o_total_barchart {
height: 50px;
line-height: 50px;
.o_grid_super > div {
bottom: 2px;
line-height: 1.5;
position: absolute;
right: 3px;
}
}
.o_grid_unavailable, .o_grid_current:not(.o_grid_total) {
background-color: transparent;
}
color: $o-main-text-color;
border-top: 1px solid $gray-300;
border-bottom: 1px solid $gray-300;
font-weight: bold;
text-align: right;
td {
position: relative;
> .o_grid_total_bar {
@include o-position-absolute(auto, 1px, 0, 0);
text-align: center;
background-color: rgba($o-brand-odoo, .5);
> .o_grid_total_title {
bottom: 2px;
transform: translate(-50%);
line-height: 1.5;
}
&:hover {
background-color: rgba($o-brand-odoo, .8);
}
}
}
}
.o_cell_hover {
background-color: $table-hover-bg;
position: relative;
}
.o_cell_highlight:not(.o_grid_total, .o_grid_super) {
box-shadow: inset -4px 0 4px -4px $o-brand-odoo, inset 4px 0 4px -4px $o-brand-odoo;
}
tbody {
border-top: none;
background-color: $o-view-background-color;
text-align: right;
th, td {
padding: 0;
& > div {
padding: 0.3rem;
}
}
th > div {
display: flex;
> div {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: left;
&.o_grid_row_header_part:before {
content: "/\00a0";
padding: 0 2px;
}
&:first-child:before {
content: " ";
}
&:last-child {
flex: 100 1 auto;
padding-right: 0;
}
}
}
tr:nth-of-type(odd) th > div:after, tr:hover td.o_grid_total {
background-color: $table-accent-bg;
}
td.o_grid_total {
padding-right: 4px;
border-left: 1px solid $gray-300 !important;
&.o_grid_cell_empty > div {
opacity: 0.2;
}
}
.o_grid_cell_information {
cursor: pointer;
opacity: 0;
transition: opacity 100ms ease 0s;
}
.o_grid_cell_container {
display: flex;
outline: none;
align-items: center;
&:hover {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 5px $o-brand-primary;
&.o_grid_cell_readonly {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.075), 0 0 5px $o-brand-lightsecondary;
cursor: not-allowed;
}
.o_grid_cell_information {
opacity: 0.4;
}
}
> i {
padding: 2px;
flex: 0 0 auto;
}
> .o_grid_input, > .o_grid_show {
width: 100%;
flex: 1 1 auto;
}
&.o_grid_cell_empty {
& > .o_grid_input, & > .o_grid_show, & > .o_grid_float_toggle {
opacity: 0.2;
}
}
}
.o_grid_input {
cursor: text;
padding: 0;
text-align: right;
outline: none;
border: none;
&:focus {
opacity: 1 !important;
background-color: $o-view-background-color;
outline: none;
}
}
// "grouped" tbodies get special styling for their title rows (first
// row of the tbody) and the row labels of both title and regular rows
&.o_grid_section {
tr th {
padding-left: 6 * $table-cell-padding-x-sm;
text-align: left;
}
tr:first-child {
font-weight: bold;
background-color: rgba($o-brand-secondary, 0.15);
&:hover {
background-color: rgba($o-brand-secondary, 0.30);
}
th {
padding-left: $table-cell-padding-x-sm;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
td.o_grid_cell_null > div {
opacity: 0.2;
}
.o_grid_current:not(.o_grid_total) {
background-color: rgba($o-brand-primary, 0.25);
&.o_cell_hover {
background-color: rgba($o-brand-primary, 0.35);
}
}
.o_grid_weekend:not(.o_grid_total) {
background-color: rgba(220,220,220, 0.25);
}
.o_grid_unavailable {
background-color: rgba($o-brand-secondary, 0.25);
&.o_cell_hover {
background-color: rgba($o-brand-secondary, 0.35);
}
}
.o_grid_text_muted{
color: lighten($o-main-text-color, 15%);
font-style: italic;
}
.o_grid_add_line {
text-align: left;
}
.o_grid_float_toggle {
outline: 0;
border: 0;
box-shadow: none;
line-height: 1;
padding: 0px;
}
@media print {
.o_grid_add_line {
display: none;
}
}
// ensure white background completely surrounds nocontent bubble
.o_grid_nocontent_container {
overflow: auto;
.oe_view_nocontent_img_link {
padding:10px;
}
}
}
@include media-breakpoint-down(md) {
.o_grid_range.show > .dropdown-menu {
display: inline-flex;
min-width: 0px;
}
}

View File

@@ -0,0 +1,434 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="web_grid.GridRenderer" owl="1">
<div class="o_grid_view" t-ref="root">
<div t-if="showNoContentHelp" class="o_view_nocontent">
<div class="o_nocontent_help">
<t t-out="props.noContentHelp"/>
</div>
</div>
<div t-else="">
<t t-call="web_grid.Table"/>
</div>
</div>
</t>
<t t-name="web_grid.AddLineRow" owl="1">
<tr class="o_grid_add_line_row">
<th t-if="showTimerButton" class="o_grid_timer_header">
\u00A0
</th>
<th>
<div class="ms-3">
<div>
<a href="javascript:void(0)" role="button" t-on-click="_onClickCreateInline" data-hotkey="i">Add a line</a>
</div>
</div>
</th>
<t t-foreach="columns" t-as="column" t-key="column_index">
<td t-att-class="{o_grid_current: column.is_current, o_grid_unavailable: column.is_unavailable}"></td>
</t>
<t t-if="!props.hideLineTotal and props.range !== 'day'" >
<td class="o_grid_total"/>
</t>
</tr>
</t>
<t t-name="web_grid.Table" owl="1">
<div class="o_view_grid table-responsive cursor-default">
<table class="table table-hover table-sm table-striped">
<thead>
<tr>
<th class="o_grid_title_header"></th>
<t t-foreach="columns" t-as="column" t-key="column_index">
<th t-att-class="{o_grid_current: column.is_current, o_grid_unavailable: column.is_unavailable}">
<t t-esc="column.values[props.colField][1]"/>
</th>
</t>
<t t-if="!props.hideLineTotal and props.range !== 'day'" >
<th class="o_grid_total">
<t t-esc="measureLabel"/>
</th>
</t>
</tr>
</thead>
<t t-if="props.isGrouped" t-call="web_grid.GroupedGridBody"/>
<t t-else="" t-call="web_grid.UnGroupedGridBody"/>
<tfoot t-if="!props.hideColumnTotal">
<tr t-att-class="{o_total_barchart: props.hasBarChartTotal}">
<td/>
<t t-foreach="columns" t-as="column" t-key="column_index">
<t t-set="total_cells" t-value="_formatCellContentTotals(column_index)"/>
<t t-if="props.hasBarChartTotal" t-set="totalHeight" t-value="_totalHeight(column_index)"/>
<td t-att-class="{
'o_grid_current': column.is_current,
'o_grid_unavailable': column.is_unavailable,
'o_grid_cell_null' : total_cells.muted,
'text-danger': total_cells.smallerThanZero,
}">
<t t-if="hasComponent">
<t t-component="component"
onCellFocused.bind="_onFocusComponent"
onCellUpdated.bind="_onUpdateValue"
hasBarChartTotal="props.hasBarChartTotal"
cellHeight="props.hasBarChartTotal ? totalHeight : 0"
cellValue="total_cells.value"
readonly="cellReadOnly"
isInput="state.editMode and state.currentPath===cellPath"
fieldInfo="props.fields[props.cellField]"
nodeOptions="props.cellComponentOptions"
path="cellPath"
isTotal="true"/>
</t>
<t t-else="">
<div t-if="props.hasBarChartTotal"
class="o_grid_total_bar"
t-att-style="'height:' + totalHeight + '%;'">
<span t-if="totalHeight"
class="o_grid_total_title position-absolute bg-white px-1"
t-esc="_format(total_cells.value)"/>
</div>
<div t-else="" t-esc="_format(total_cells.value)"/>
</t>
</td>
</t>
<t t-if="props.range !== 'day' and !props.hideLineTotal">
<td t-att-class="{
...gridTotal.classMap,
'o_grid_cell_null': gridTotal.value === 0,
}">
<t t-if="hasComponent">
<t t-component="component"
onCellFocused.bind="_onFocusComponent"
onCellUpdated.bind="_onUpdateValue"
cellValue="gridTotal.value"
readonly="cellReadOnly"
isInput="state.editMode and state.currentPath===cellPath"
fieldInfo="props.fields[props.cellField]"
nodeOptions="props.cellComponentOptions"
path="cellPath"
isTotal="true"/>
</t>
<div t-else="">
<t t-esc="_format(gridTotal.value)"/>
</div>
</td>
</t>
</tr>
</tfoot>
</table>
</div>
</t>
<t t-name="web_grid.GroupedGridBody" owl="1">
<t t-foreach="props.data" t-as="grid" t-key="grid_index">
<tbody class="o_grid_section">
<tr t-if="grid.__label">
<th>
<t t-out="grid.__label[1]"/>
</th>
<t t-foreach="columns" t-as="column" t-key="column_index">
<td t-att-class="{
o_grid_current: column.is_current,
o_grid_unavailable: column.is_unavailable,
'text-danger': grid.totals.columns[column_index] lt 0,
'o_grid_cell_null': grid.totals.columns[column_index] == 0,
}">
<t t-if="hasComponent">
<t t-component="component"
onCellFocused.bind="_onFocusComponent"
onCellUpdated.bind="_onUpdateValue"
cellValue="grid.totals.columns[column_index]"
readonly="true"
isInput="false"
fieldInfo="props.fields[props.cellField]"
nodeOptions="props.cellComponentOptions"
isTotal="true"/>
</t>
<div t-else="" t-esc="_format(grid.totals.columns[column_index])"/>
</td>
</t>
<t t-if="props.range !== 'day' and !props.hideLineTotal" >
<td class="o_grid_total"
t-att-class="{
'text-danger': grid.totals.super lt 0,
'o_grid_cell_null': grid.totals.super == 0,
}">
<t t-if="hasComponent">
<t t-component="component"
onCellFocused.bind="_onFocusComponent"
onCellUpdated.bind="_onUpdateValue"
cellValue="grid.totals.super"
readonly="true"
isInput="false"
fieldInfo="props.fields[props.cellField]"
nodeOptions="props.cellComponentOptions"
isTotal="true"/>
</t>
<div t-else="" t-esc="_format(grid.totals.super)"/>
</td>
</t>
</tr>
<t t-call="web_grid.GridRows">
<t t-set="groupTotals" t-value="grid.totals" />
<t t-set="rows" t-value="grid.rows"/>
<t t-set="path" t-value="[grid_index, 'grid']"/>
</t>
</tbody>
</t>
<tbody>
<t t-call="web_grid.GridEmptyRows"/>
</tbody>
<tbody t-if="props.canCreate and props.createInline" class="o_grid_section">
<t t-call="web_grid.AddLineRow"></t>
</tbody>
</t>
<t t-name="web_grid.UnGroupedGridBody" owl="1">
<tbody>
<t t-call="web_grid.GridRows">
<t t-set="groupTotals" t-value="props.totals" />
<t t-set="rows" t-value="props.data[0].rows"/>
<t t-set="grid" t-value="props.data[0]"/>
<t t-set="path" t-value="[0,'grid']"/>
</t>
<t t-call="web_grid.GridEmptyRows"/>
<t t-if="props.canCreate and props.createInline" t-call="web_grid.AddLineRow"/>
</tbody>
</t>
<t t-name="web_grid.GridEmptyRows" owl="1">
<t t-foreach="emptyRows" t-as="row" t-key="row_index">
<tr class="o_grid_padding">
<th><div>\u00A0</div></th>
<t t-foreach="props.data[0].cols" t-as="col" t-key="col_index">
<td t-att-class="{o_grid_current: col.is_current, o_grid_unavailable: col.is_unavailable}"></td>
</t>
<t t-if="props.range !== 'day' and !props.hideLineTotal">
<td class="o_grid_total"/>
</t>
</tr>
</t>
</t>
<t t-name="web_grid.GridRows" owl="1">
<t t-foreach="grid.rows" t-as="rowlabel" t-key="grid.rows[rowlabel_index].id">
<t t-set="row" t-value="grid.grid[rowlabel_index]"/>
<tr>
<th>
<div class="ms-3">
<t t-foreach="grid.rows[rowlabel_index].label" t-as="label" t-key="label_index">
<div t-if="label or (grid.rows[rowlabel_index].label.every(element => !element) and !label_index)" t-att-title="label"
class="o_grid_row_header_part"
t-att-class="{o_grid_text_muted: !label }">
<t t-if="label" t-esc="label"/>
<t t-else="">None</t>
</div>
</t>
</div>
</th>
<t t-foreach="row" t-as="cell" t-key="cell_index">
<t t-set="rowIndex" t-value="rowlabel_index"/>
<t t-call="web_grid.Cell" />
</t>
<t t-if="props.range !== 'day' and !props.hideLineTotal" >
<td class="o_grid_total"
t-att-class="{
'text-danger': groupTotals.rows[rowlabel_index] lt 0,
'o_grid_cell_empty': groupTotals.rows[rowlabel_index] == 0,
}">
<t t-if="hasComponent">
<t t-component="component"
onCellFocused.bind="_onFocusComponent"
onCellUpdated.bind="_onUpdateValue"
cellValue="groupTotals.rows[rowlabel_index]"
readonly="true"
isInput="false"
fieldInfo="props.fields[props.cellField]"
nodeOptions="props.cellComponentOptions"
isTotal="true"/>
</t>
<div t-else="" t-esc="_format(groupTotals.rows[rowlabel_index])"/>
</td>
</t>
</tr>
</t>
</t>
<t t-name="web_grid.Cell" owl="1">
<td t-att-class="{o_grid_current: cell.is_current, o_grid_unavailable: cell.is_unavailable}">
<t t-set="cellPath" t-value="path.concat([rowIndex, cell_index]).join('.')"/>
<t t-set="cellReadOnly" t-value="!props.editableCells || cell.readonly"/>
<div t-att-class="_getCellClassMap(cell)">
<t t-if="cell.size or !cellReadOnly">
<i class="fa fa-search-plus o_grid_cell_information" t-on-click="() => this._onClickCellInformation(cellPath)"
title="See all the records aggregated in this cell">
</i >
</t>
<t t-call="web_grid.CellValue"/>
</div>
</td>
</t>
<t t-name="web_grid.CellValue" owl="1">
<t t-if="hasComponent">
<t t-component="component"
onCellFocused.bind="_onFocusComponent"
onCellUpdated.bind="_onUpdateValue"
cellValue="cell.value"
readonly="cellReadOnly"
isInput="state.editMode and state.currentPath===cellPath"
fieldInfo="props.fields[props.cellField]"
nodeOptions="props.cellComponentOptions"
path="cellPath"
date="gridAnchorNext"/>
</t>
<t t-elif="cellReadOnly">
<div class="o_grid_show" t-att-class="{'text-danger': cell.value lt 0}">
<t t-esc="_format(cell.value)"/>
</div>
</t>
<t t-else="">
<t t-if="state.editMode and state.currentPath===cellPath">
<input class="o_grid_input"
t-ref="currentInput"
t-att-class="{'text-danger': cell.value lt 0}"
tabindex="1" t-att-value="_format(cell.value)"
t-on-blur="_onGridInputBlur"/>
</t>
<t t-else="">
<t t-if="state.errors[cellPath]">
<div class="o_grid_input text-danger o_has_error" tabindex="1"
t-on-focus="() => this._onFocusGridCell(cellPath)">
<t t-esc="state.errors[cellPath]"/>
</div>
</t>
<t t-else="">
<div class="o_grid_input" tabindex="1"
t-on-focus="() => this._onFocusGridCell(cellPath)"
t-att-class="{'text-danger': cell.value lt 0}">
<t t-esc="_format(cell.value)"/>
</div>
</t>
</t>
</t>
</t>
<t t-name="web_grid.BaseGridComponent" owl="1">
<t t-if="props.isTotal">
<div t-if="props.hasBarChartTotal"
class="o_grid_total_bar"
t-att-style="'height:' + props.cellHeight + '%;'">
<span t-if="props.cellHeight"
class="o_grid_total_title position-absolute bg-white px-1"
t-esc="_format(props.cellValue)"/>
</div>
<div t-else="" t-esc="_format(props.cellValue)"/>
</t>
<t t-elif="!props.readonly">
<t t-if="props.isInput">
<input class="o_grid_input" tabindex="1"
t-ref="currentInput"
t-att-value="_format(props.cellValue)"
t-on-blur="_onBlurCell"/>
</t>
<t t-else="">
<t t-if="state.error">
<div class="o_grid_input o_has_error" tabindex="1"
t-on-focus="_onFocusCell">
<t t-esc="state.error"/>
</div>
</t>
<t t-else="">
<div class="o_grid_input" tabindex="1"
t-att-class="{'text-danger': props.cellValue lt 0}"
t-on-focus="_onFocusCell">
<t t-esc="_format(props.cellValue)"/>
</div>
</t>
</t>
</t>
<t t-else="">
<div class="o_grid_show"
t-att-class="{'text-danger': props.cellValue lt 0}"
t-att-contentEditable="!props.readonly">
<t t-esc="_format(props.cellValue)"/>
</div>
</t>
</t>
<t t-name="web_grid.FloatToggleComponent" class="btn-block" owl="1">
<t t-if="props.isTotal">
<div t-if="props.hasBarChartTotal"
class="o_grid_total_bar"
t-att-style="'height:' + props.cellHeight + '%;'">
<span t-if="props.cellHeight"
class="o_grid_total_title position-absolute bg-white px-1"
t-esc="_format(props.cellValue)"/>
</div>
<div t-else="" t-esc="_format(props.cellValue)"/>
</t>
<t t-else="">
<button
class="o_grid_float_toggle btn btn-default d-inline-block w-100 text-end pe-0"
t-att-class="{'text-danger': state.value lt 0}"
t-att-disabled="props.readonly or state.disabled"
t-on-click="_onClickButton">
<t t-esc="_format(state.value)"/>
</button>
</t>
</t>
<t t-name="grid.GridArrows">
<div>
<button t-if="widget.allowCreate" class="btn btn-primary o_grid_button_add" type="button">
Add a Line
</button>
<div t-if="widget._buttons.length" class="btn-group">
<button t-foreach="widget._buttons" t-as="btn" t-key="btn_index"
t-attf-class="grid_arrow_button btn {{ btn['class'] || 'btn-secondary' }}"
type="button" t-att-data-index="btn_index" t-att-data-hotkey="btn['data-hotkey']">
<t t-esc="btn.string"/>
</button>
</div>
<button class="grid_arrow_previous o_grid_button_change_range btn btn-primary d-none"
type="button">
<span class="fa fa-arrow-left" role="img" aria-label="Previous" title="Previous"/>
</button>
<button class="btn btn-primary grid_button_initial o_grid_button_change_range d-none" type="button" data-hotkey="t">
Today
</button>
<button class="grid_arrow_next o_grid_button_change_range btn btn-primary d-none"
type="button">
<span class="fa fa-arrow-right" role="img" aria-label="Next" title="Next"/>
</button>
<div t-if="widget._ranges.length > 1" class="btn-group o_grid_range">
<t t-if="isMobile">
<t t-call="grid.RangeButtons.Mobile"/>
</t>
<t t-else="">
<t t-foreach="widget._ranges" t-as="range" t-key="range_index">
<t t-call="grid.RangeButton"/>
</t>
</t>
</div>
</div>
</t>
<t t-name="grid.RangeButtons.Mobile">
<button class="btn btn-link btn-sm fa fa-calendar" data-bs-toggle="dropdown" aria-expanded="false">
</button>
<ul class="dropdown-menu dropdown-menu-end list-inline">
<li t-foreach="widget._ranges" t-as="range" t-key="range_index">
<t t-call="grid.RangeButton"/>
</li>
</ul>
</t>
<t t-name="grid.RangeButton">
<button class="grid_arrow_range btn btn-secondary" type="button" t-att-data-name="range.name" t-att-data-hotkey="range.hotkey">
<t t-esc="range.string"/>
</button>
</t>
</templates>