合并企业版代码(未测试,先提交到测试分支)
This commit is contained in:
19
web_grid/static/src/js/grid_component_registry.js
Normal file
19
web_grid/static/src/js/grid_component_registry.js
Normal 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);
|
||||
});
|
||||
265
web_grid/static/src/js/grid_components.js
Normal file
265
web_grid/static/src/js/grid_components.js
Normal 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,
|
||||
};
|
||||
});
|
||||
369
web_grid/static/src/js/grid_controller.js
Normal file
369
web_grid/static/src/js/grid_controller.js
Normal 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;
|
||||
|
||||
});
|
||||
393
web_grid/static/src/js/grid_model.js
Normal file
393
web_grid/static/src/js/grid_model.js
Normal 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;
|
||||
|
||||
});
|
||||
440
web_grid/static/src/js/grid_renderer.js
Normal file
440
web_grid/static/src/js/grid_renderer.js
Normal 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;
|
||||
});
|
||||
165
web_grid/static/src/js/grid_view.js
Normal file
165
web_grid/static/src/js/grid_view.js
Normal 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;
|
||||
});
|
||||
258
web_grid/static/src/scss/grid_view.scss
Normal file
258
web_grid/static/src/scss/grid_view.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
434
web_grid/static/src/xml/grid_view.xml
Normal file
434
web_grid/static/src/xml/grid_view.xml
Normal 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>
|
||||
Reference in New Issue
Block a user