fix bug and make web grid modul
This commit is contained in:
parent
d4ea266ffd
commit
768747ca18
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
from . import models
|
||||
from . import tools
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "Grid odex30",
|
||||
|
||||
'summary': "Basic 2D Grid view for odoo",
|
||||
'category': 'Hidden',
|
||||
'version': '0.1',
|
||||
'depends': ['web'],
|
||||
'author': 'Ecpert .',
|
||||
'license': '',
|
||||
'assets': {
|
||||
'web.assets_backend_lazy': [
|
||||
'odex30_web_grid/static/src/**/*',
|
||||
|
||||
('remove', 'odex30_web_grid/static/src/**/*.dark.scss'),
|
||||
],
|
||||
'web.assets_backend_lazy_dark': [
|
||||
'odex30_web_grid/static/src/**/*.dark.scss',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'odex30_web_grid/static/tests/**/*.test.js',
|
||||
'odex30_web_grid/static/tests/grid_mock_server.js',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'odex30_web_grid/static/tests/legacy/helpers.js',
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * odex30_web_grid
|
||||
#
|
||||
# Translators:
|
||||
# Martin Trigaux, 2022
|
||||
# Malaz Abuidris <msea@odoo.com>, 2022
|
||||
# "Tiffany Chang (tic)" <tic@odoo.com>, 2025.
|
||||
# "Dylan Kiss (dyki)" <dyki@odoo.com>, 2025.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 16.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-10-08 18:50+0000\n"
|
||||
"PO-Revision-Date: 2025-10-13 15:01+0000\n"
|
||||
"Last-Translator: \"D"
|
||||
"Language-Team: Arabic "
|
||||
"ar/>\n"
|
||||
"Language: ar\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
|
||||
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
|
||||
"X-Generator: Weblate 5.12.2\n"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model,name:odex30_web_grid.model_ir_actions_act_window_view
|
||||
msgid "Action Window View"
|
||||
msgstr "عرض نافذة الإجراء"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_controller.xml:0
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.js:0
|
||||
msgid "Add a Line"
|
||||
msgstr "إضافة بند"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.xml:0
|
||||
msgid "Add a line"
|
||||
msgstr "إضافة بند"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model,name:odex30_web_grid.model_base
|
||||
msgid "Base"
|
||||
msgstr "قاعدة"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_arch_parser.js:0
|
||||
msgid "Count"
|
||||
msgstr "التعداد"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model.fields,field_description:odex30_web_grid.field_ir_actions_act_window_view__display_name
|
||||
#: model:ir.model.fields,field_description:odex30_web_grid.field_ir_ui_view__display_name
|
||||
msgid "Display Name"
|
||||
msgstr "اسم العرض"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model.fields.selection,name:odex30_web_grid.selection__ir_actions_act_window_view__view_mode__grid
|
||||
#: model:ir.model.fields.selection,name:odex30_web_grid.selection__ir_ui_view__type__grid
|
||||
msgid "Grid"
|
||||
msgstr "الشبكة"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_model.js:0
|
||||
msgid ""
|
||||
"Grouping by the field used in the column of the grid view is not possible."
|
||||
msgstr "لا يُسمح بالتجميع حسب الحقل المستخدم في العمود لنافذة عرض الشبكة."
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model.fields,field_description:odex30_web_grid.field_ir_actions_act_window_view__id
|
||||
#: model:ir.model.fields,field_description:odex30_web_grid.field_ir_ui_view__id
|
||||
msgid "ID"
|
||||
msgstr "المُعرف"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_controller.js:0
|
||||
msgid "New Record"
|
||||
msgstr "سجل جديد"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.xml:0
|
||||
msgid "Next"
|
||||
msgstr "التالي"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.js:0
|
||||
msgid "No activities found"
|
||||
msgstr "لم يتم العثور على أي أنشطة"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/components/grid_row/grid_row.xml:0
|
||||
#: code:addons/odex30_web_grid/static/src/components/many2one_grid_row/many2one_grid_row.xml:0
|
||||
msgid "None"
|
||||
msgstr "لا شيء"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.xml:0
|
||||
msgid "Previous"
|
||||
msgstr "السابق"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.xml:0
|
||||
msgid "Today"
|
||||
msgstr "اليوم"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#. odoo-javascript
|
||||
#: code:addons/odex30_web_grid/static/src/views/grid_renderer.js:0
|
||||
msgid "Total"
|
||||
msgstr "الإجمالي"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model,name:odex30_web_grid.model_ir_ui_view
|
||||
msgid "View"
|
||||
msgstr "أداة العرض"
|
||||
|
||||
#. module: odex30_web_grid
|
||||
#: model:ir.model.fields,field_description:odex30_web_grid.field_ir_actions_act_window_view__view_mode
|
||||
#: model:ir.model.fields,field_description:odex30_web_grid.field_ir_ui_view__type
|
||||
msgid "View Type"
|
||||
msgstr "نوع واجهة العرض"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_ui_view
|
||||
from . import ir_actions
|
||||
from . import models
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class IrActionsAct_WindowView(models.Model):
|
||||
_inherit = 'ir.actions.act_window.view'
|
||||
|
||||
view_mode = fields.Selection(selection_add=[('grid', "Grid")], ondelete={'grid': 'cascade'})
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class IrUiView(models.Model):
|
||||
_inherit = 'ir.ui.view'
|
||||
|
||||
type = fields.Selection(selection_add=[('grid', "Grid")])
|
||||
|
||||
def _get_view_info(self):
|
||||
return {'grid': {'icon': 'fa fa-th'}} | super()._get_view_info()
|
||||
|
||||
def unlink(self):
|
||||
if not any(v.type == "grid" for v in self):
|
||||
return super().unlink()
|
||||
self.env["ir.actions.act_window.view"].search(
|
||||
[("view_mode", "=", "grid"), ("view_id", "in", self.ids)]
|
||||
).unlink()
|
||||
res = super().unlink()
|
||||
grid_models = list(set(self.search([("type", "=", "grid")]).mapped("model")))
|
||||
for action in self.env["ir.actions.act_window"].search(
|
||||
[("view_mode", "like", "grid"), ("res_model", "not in", grid_models)]
|
||||
):
|
||||
action.view_mode = ",".join(mode for mode in action.view_mode.split(",") if mode != "grid")
|
||||
return res
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class Base(models.AbstractModel):
|
||||
_inherit = 'base'
|
||||
|
||||
@api.model
|
||||
def grid_update_cell(self, domain, measure_field_name, value):
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
@api.model
|
||||
def grid_unavailability(self, start_date, end_date, groupby='', res_ids=()):
|
||||
|
||||
return {}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { formatFloatFactor } from "@web/views/fields/formatters";
|
||||
import { GridCell } from "./grid_cell";
|
||||
|
||||
function formatter(value, options = {}) {
|
||||
return formatFloatFactor(value, options);
|
||||
}
|
||||
|
||||
export class FloatFactorGridCell extends GridCell {
|
||||
static props = {
|
||||
...GridCell.props,
|
||||
factor: { type: Number, optional: true },
|
||||
};
|
||||
|
||||
parse(value) {
|
||||
const factorValue = value / this.factor;
|
||||
return super.parse(factorValue.toString());
|
||||
}
|
||||
|
||||
get factor() {
|
||||
return this.props.factor || this.props.fieldInfo.options?.factor || 1;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return super.value * this.factor;
|
||||
}
|
||||
|
||||
get formattedValue() {
|
||||
return formatter(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
export const floatFactorGridCell = {
|
||||
component: FloatFactorGridCell,
|
||||
formatter,
|
||||
};
|
||||
|
||||
registry.category("grid_components").add("float_factor", floatFactorGridCell);
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { parseFloatTime } from "@web/views/fields/parsers";
|
||||
import { formatFloatTime } from "@web/views/fields/formatters";
|
||||
import { GridCell } from "./grid_cell";
|
||||
|
||||
function formatter(value, options = {}) {
|
||||
return formatFloatTime(value, { ...options, noLeadingZeroHour: true });
|
||||
}
|
||||
|
||||
export class FloatTimeGridCell extends GridCell {
|
||||
get formattedValue() {
|
||||
return formatter(this.value);
|
||||
}
|
||||
|
||||
get inputMode() {
|
||||
return "text";
|
||||
}
|
||||
|
||||
parse(value) {
|
||||
return parseFloatTime(value);
|
||||
}
|
||||
}
|
||||
|
||||
export const floatTimeGridCell = {
|
||||
component: FloatTimeGridCell,
|
||||
formatter,
|
||||
};
|
||||
|
||||
registry.category("grid_components").add("float_time", floatTimeGridCell);
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { formatFloatFactor } from "@web/views/fields/formatters";
|
||||
import { useGridCell, useMagnifierGlass } from "@odex30_web_grid/hooks/grid_cell_hook";
|
||||
import { standardGridCellProps } from "./grid_cell";
|
||||
|
||||
import { Component, useRef, useState, useEffect } from "@odoo/owl";
|
||||
|
||||
function formatter(value, options = {}) {
|
||||
return formatFloatFactor(value, options);
|
||||
}
|
||||
|
||||
export class FloatToggleGridCell extends Component {
|
||||
static props = {
|
||||
...standardGridCellProps,
|
||||
factor: { type: Number, optional: true },
|
||||
};
|
||||
static template = "odex30_web_grid.FloatToggleGridCell";
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.buttonRef = useRef("toggleButton");
|
||||
this.magnifierGlassHook = useMagnifierGlass();
|
||||
this.state = useState({
|
||||
edit: this.props.editMode,
|
||||
invalid: false,
|
||||
cell: null,
|
||||
});
|
||||
useGridCell();
|
||||
|
||||
useEffect(
|
||||
(buttonEl) => {
|
||||
if (buttonEl) {
|
||||
buttonEl.focus();
|
||||
}
|
||||
},
|
||||
() => [this.buttonRef.el]
|
||||
);
|
||||
}
|
||||
|
||||
get factor() {
|
||||
return this.props.factor || this.props.fieldInfo.options?.factor || 1;
|
||||
}
|
||||
|
||||
get range() {
|
||||
return this.props.fieldInfo.options?.range || [0.0, 0.5, 1.0];
|
||||
}
|
||||
|
||||
get value() {
|
||||
return (this.state.cell.value || 0) * this.factor;
|
||||
}
|
||||
|
||||
get formattedValue() {
|
||||
return formatter(this.state.cell.value || 0, {
|
||||
digits: this.props.fieldInfo.attrs?.digits || 2,
|
||||
factor: this.factor,
|
||||
});
|
||||
}
|
||||
|
||||
isEditable(props = this.props) {
|
||||
return (
|
||||
!props.readonly && this.state.cell?.readonly === false && !this.state.cell.row.isSection
|
||||
);
|
||||
}
|
||||
|
||||
onChange() {
|
||||
let currentIndex = this.range.indexOf(this.value);
|
||||
currentIndex++;
|
||||
if (currentIndex > this.range.length - 1) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
this.update(this.range[currentIndex] / this.factor);
|
||||
}
|
||||
|
||||
update(value) {
|
||||
this.state.cell.update(value);
|
||||
}
|
||||
|
||||
onCellClick(ev) {
|
||||
if (this.isEditable() && !this.state.edit && !ev.target.closest(".o_grid_search_btn")) {
|
||||
this.onChange();
|
||||
this.props.onEdit(true);
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
this.props.onKeyDown(ev, this.state.cell);
|
||||
}
|
||||
}
|
||||
|
||||
export const floatToggleGridCell = {
|
||||
component: FloatToggleGridCell,
|
||||
formatter,
|
||||
};
|
||||
|
||||
registry.category("grid_components").add("float_toggle", floatToggleGridCell);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
<t t-name="odex30_web_grid.FloatToggleGridCell" t-inherit="odex30_web_grid.Cell">
|
||||
<xpath expr="//input" position="replace">
|
||||
<button t-if="state.edit"
|
||||
class="o_field_float_toggle"
|
||||
t-on-click="onChange"
|
||||
t-on-keydown="onKeyDown"
|
||||
t-ref="toggleButton"
|
||||
>
|
||||
<t t-out="formattedValue" />
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
|
||||
import { formatInteger } from "@web/views/fields/formatters";
|
||||
import { formatFloat } from "@web/core/utils/numbers";
|
||||
import { parseInteger, parseFloat } from "@web/views/fields/parsers";
|
||||
import { useInputHook } from "@odex30_web_grid/hooks/input_hook";
|
||||
|
||||
import { Component, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { useGridCell, useMagnifierGlass } from "@odex30_web_grid/hooks/grid_cell_hook";
|
||||
|
||||
export const standardGridCellProps = {
|
||||
name: String,
|
||||
classNames: String,
|
||||
fieldInfo: Object,
|
||||
readonly: { type: Boolean, optional: true },
|
||||
editMode: { type: Boolean, optional: true },
|
||||
reactive: {
|
||||
type: Object,
|
||||
shape: {
|
||||
cell: [HTMLElement, { value: null }],
|
||||
},
|
||||
},
|
||||
openRecords: Function,
|
||||
onEdit: Function,
|
||||
getCell: Function,
|
||||
onKeyDown: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
export class GridCell extends Component {
|
||||
static template = "odex30_web_grid.Cell";
|
||||
static props = standardGridCellProps;
|
||||
static defaultProps = {
|
||||
readonly: true,
|
||||
editMode: false,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.state = useState({
|
||||
edit: this.props.editMode,
|
||||
invalid: false,
|
||||
cell: null,
|
||||
});
|
||||
this.discardChanges = false;
|
||||
this.magnifierGlassHook = useMagnifierGlass();
|
||||
this.inputRef = useInputHook({
|
||||
getValue: () => this.formattedValue,
|
||||
refName: "numpadDecimal",
|
||||
parse: this.parse.bind(this),
|
||||
notifyChange: this.onChange.bind(this),
|
||||
commitChanges: this.saveEdition.bind(this),
|
||||
onKeyDown: (ev) => this.props.onKeyDown(ev, this.state.cell),
|
||||
discard: this.discard.bind(this),
|
||||
setInvalid: () => {
|
||||
this.state.invalid = true;
|
||||
},
|
||||
setDirty: () => {
|
||||
this.state.invalid = false;
|
||||
},
|
||||
isInvalid: () => this.state.invalid,
|
||||
});
|
||||
useNumpadDecimal();
|
||||
|
||||
useGridCell();
|
||||
useEffect(
|
||||
(edit, inputEl, cellEl) => {
|
||||
if (inputEl) {
|
||||
inputEl.value = this.formattedValue;
|
||||
}
|
||||
if (edit && inputEl) {
|
||||
inputEl.focus();
|
||||
if (inputEl.type === "text") {
|
||||
if (inputEl.selectionStart === null) {
|
||||
return;
|
||||
}
|
||||
if (inputEl.selectionStart === inputEl.selectionEnd) {
|
||||
inputEl.selectionStart = 0;
|
||||
inputEl.selectionEnd = inputEl.value.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.discardChanges = false;
|
||||
},
|
||||
() => [this.state.edit, this.inputRef.el, this.props.reactive.cell]
|
||||
);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.state.cell?.value || 0;
|
||||
}
|
||||
|
||||
get section() {
|
||||
return this.row.getSection();
|
||||
}
|
||||
|
||||
get row() {
|
||||
return this.state.cell?.row;
|
||||
}
|
||||
|
||||
get formattedValue() {
|
||||
const { type, digits } = this.props.fieldInfo;
|
||||
if (type === "integer") {
|
||||
return formatInteger(this.value);
|
||||
}
|
||||
return formatFloat(this.value, { digits: digits || 2 });
|
||||
}
|
||||
|
||||
get inputMode() {
|
||||
return "numeric";
|
||||
}
|
||||
|
||||
isEditable(props = this.props) {
|
||||
return (
|
||||
!props.readonly && this.state.cell?.readonly === false && !this.state.cell.row.isSection
|
||||
);
|
||||
}
|
||||
|
||||
parse(value) {
|
||||
if (this.props.fieldInfo.type === "integer") {
|
||||
return parseInteger(value);
|
||||
}
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
onChange(value) {
|
||||
if (!this.discardChanges) {
|
||||
this.update(value);
|
||||
}
|
||||
}
|
||||
|
||||
update(value) {
|
||||
this.state.cell.update(value);
|
||||
}
|
||||
|
||||
saveEdition(value) {
|
||||
const changesCommitted = (value || false) !== (this.state.cell.value || false);
|
||||
if ((value || false) !== (this.state.cell?.value || false)) {
|
||||
this.update(value);
|
||||
}
|
||||
this.props.onEdit(false);
|
||||
return changesCommitted;
|
||||
}
|
||||
|
||||
discard() {
|
||||
this.discardChanges = true;
|
||||
this.props.onEdit(false);
|
||||
}
|
||||
|
||||
onCellClick(ev) {
|
||||
if (this.isEditable() && !this.state.edit) {
|
||||
this.discardChanges = false;
|
||||
this.props.onEdit(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const integerGridCell = {
|
||||
component: GridCell,
|
||||
formatter: formatInteger,
|
||||
};
|
||||
|
||||
registry.category("grid_components").add("integer", integerGridCell);
|
||||
|
||||
export const floatGridCell = {
|
||||
component: GridCell,
|
||||
formatter: formatFloat,
|
||||
};
|
||||
|
||||
registry.category("grid_components").add("float", floatGridCell);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
<t t-name="odex30_web_grid.Cell">
|
||||
<div t-if="props.reactive.cell"
|
||||
t-attf-class="{{ props.classNames }} o_grid_cell o_grid_highlightable position-relative d-flex justify-content-center align-items-center w-100 h-100 text-800"
|
||||
t-on-click.synthetic="onCellClick"
|
||||
t-ref="root"
|
||||
>
|
||||
<button class="o_grid_search_btn btn btn-sm position-absolute start-0 px-1 opacity-50 opacity-100-hover"
|
||||
t-on-click.synthetic="() => this.magnifierGlassHook.onMagnifierGlassClick()"
|
||||
>
|
||||
<i class="fa fa-search"/>
|
||||
</button>
|
||||
<div t-if="state.cell" class="d-flex w-100 h-100 justify-content-center align-items-center">
|
||||
<input t-if="state.edit"
|
||||
t-att-inputmode="inputMode"
|
||||
type="text"
|
||||
class="o_input h-100"
|
||||
t-ref="numpadDecimal"
|
||||
/>
|
||||
<span t-else="" class="z-1" t-out="formattedValue"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { GridCell } from "../grid_cell";
|
||||
import { GridRow } from "./grid_row";
|
||||
|
||||
const gridComponentRegistry = registry.category("grid_components");
|
||||
|
||||
export class GridComponent extends Component {
|
||||
static props = ["name", "type", "isMeasure?", "component?", "*"];
|
||||
static template = "odex30_web_grid.GridComponent"
|
||||
|
||||
get gridComponent() {
|
||||
if (this.props.component) {
|
||||
return this.props.component;
|
||||
}
|
||||
if (gridComponentRegistry.contains(this.props.type)) {
|
||||
return gridComponentRegistry.get(this.props.type).component;
|
||||
}
|
||||
if (this.props.isMeasure) {
|
||||
console.warn(`Missing widget: ${this.props.type} for grid component`);
|
||||
return GridCell;
|
||||
}
|
||||
return GridRow;
|
||||
}
|
||||
|
||||
get gridComponentProps() {
|
||||
const gridComponentProps = Object.fromEntries(
|
||||
Object.entries(this.props).filter(
|
||||
([key,]) => key in this.gridComponent.props
|
||||
)
|
||||
);
|
||||
gridComponentProps.classNames = `o_grid_component o_grid_component_${this.props.type} ${gridComponentProps.classNames || ""}`;
|
||||
return gridComponentProps;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odex30_web_grid.GridComponent">
|
||||
<t t-component="gridComponent" t-props="gridComponentProps" />
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { Component } from "@odoo/owl";
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class GridRow extends Component {
|
||||
static template = "odex30_web_grid.GridRow";
|
||||
static props = {
|
||||
name: String,
|
||||
model: Object,
|
||||
row: Object,
|
||||
classNames: { type: String, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
style: { type: String, optional: true },
|
||||
value: { optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
classNames: "",
|
||||
context: {},
|
||||
style: "",
|
||||
};
|
||||
|
||||
get value() {
|
||||
let value = 'value' in this.props ? this.props.value : this.props.row.initialRecordValues[this.props.name];
|
||||
const fieldInfo = this.props.model.fieldsInfo[this.props.name];
|
||||
if (fieldInfo.type === "selection") {
|
||||
value = fieldInfo.selection.find(([key,]) => key === value)?.[1];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const gridRow = {
|
||||
component: GridRow,
|
||||
};
|
||||
|
||||
registry
|
||||
.category("grid_components")
|
||||
.add("selection", gridRow)
|
||||
.add("char", gridRow);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odex30_web_grid.GridRow">
|
||||
<div t-att-name="props.name" t-att-class="props.classNames" t-att-style="props.style">
|
||||
<span t-if="value" t-esc="value" />
|
||||
<span t-else="" class="o_grid_no_data text-300">None</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { GridRow, gridRow } from "./grid_row";
|
||||
|
||||
export class Many2OneGridRow extends GridRow {
|
||||
static template = "odex30_web_grid.Many2OneGridRow";
|
||||
static props = {
|
||||
...GridRow.props,
|
||||
relation: { type: String, optional: true },
|
||||
canOpen: { type: Boolean, optional: true },
|
||||
}
|
||||
static defaultProps = {
|
||||
...GridRow.defaultProps,
|
||||
canOpen: true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.actionService = useService("action");
|
||||
}
|
||||
|
||||
get relation() {
|
||||
return this.props.relation || this.props.model.fieldsInfo[this.props.name].relation;
|
||||
}
|
||||
|
||||
get urlRelation() {
|
||||
if (!this.relation.includes(".")) {
|
||||
return "m-" + this.relation;
|
||||
}
|
||||
return this.relation;
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this.value && this.value[1].split("\n", 1)[0];
|
||||
}
|
||||
|
||||
get extraLines() {
|
||||
return this.value
|
||||
? this.value[1]
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.slice(1)
|
||||
: [];
|
||||
}
|
||||
|
||||
get resId() {
|
||||
return this.value && this.value[0];
|
||||
}
|
||||
|
||||
async openAction() {
|
||||
const action = await this.orm.call(this.relation, "get_formview_action", [[this.resId]], {
|
||||
context: this.props.context,
|
||||
});
|
||||
await this.actionService.doAction(action);
|
||||
}
|
||||
|
||||
onClick(ev) {
|
||||
if (this.props.canOpen) {
|
||||
ev.stopPropagation();
|
||||
this.openAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const many2OneGridRow = {
|
||||
...gridRow,
|
||||
component: Many2OneGridRow,
|
||||
};
|
||||
|
||||
registry.category("grid_components").add("many2one", many2OneGridRow);
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odex30_web_grid.Many2OneGridRow">
|
||||
<div t-att-name="props.name" t-att-class="props.classNames" t-att-style="props.style">
|
||||
<t t-if="!props.canOpen and resId">
|
||||
<span>
|
||||
<span t-esc="displayName" t-att-class="{'me-2': props.row.isAdditionalRow}"/>
|
||||
<t t-foreach="extraLines" t-as="extraLine" t-key="extraLine_index">
|
||||
<br />
|
||||
<span t-esc="extraLine" />
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
<t t-elif="resId">
|
||||
<a
|
||||
t-attf-class="o_form_uri"
|
||||
t-att-href="`/odoo/${urlRelation}/${resId}`"
|
||||
t-on-click.prevent="onClick"
|
||||
>
|
||||
<span t-esc="displayName" t-att-class="{'me-2': props.row.isAdditionalRow}"/>
|
||||
<t t-foreach="extraLines" t-as="extraLine" t-key="extraLine_index">
|
||||
<br />
|
||||
<span t-esc="extraLine" />
|
||||
</t>
|
||||
</a>
|
||||
</t>
|
||||
<span t-else="" class="o_grid_no_data text-300">None</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { useComponent, useEffect } from "@odoo/owl";
|
||||
|
||||
export function useMagnifierGlass() {
|
||||
const component = useComponent();
|
||||
return {
|
||||
onMagnifierGlassClick() {
|
||||
const { context, domain, title } = component.state.cell;
|
||||
component.props.openRecords(title, domain.toList(), context);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useGridCell() {
|
||||
const component = useComponent();
|
||||
useEffect(
|
||||
/** @param {HTMLElement | null} cellEl */
|
||||
(cellEl) => {
|
||||
if (!cellEl) {
|
||||
component.state.cell = null;
|
||||
return;
|
||||
}
|
||||
component.state.cell = component.props.getCell(
|
||||
cellEl.dataset.row,
|
||||
cellEl.dataset.column
|
||||
);
|
||||
Object.assign(component.rootRef.el.style, {
|
||||
"grid-row": cellEl.style["grid-row"],
|
||||
"grid-column": cellEl.style["grid-column"],
|
||||
"z-index": 1,
|
||||
});
|
||||
component.rootRef.el.dataset.gridRow = cellEl.dataset.gridRow;
|
||||
component.rootRef.el.dataset.gridColumn = cellEl.dataset.gridColumn;
|
||||
cellEl.querySelector(".o_grid_cell_readonly").classList.add("d-none");
|
||||
component.rootRef.el.classList.toggle(
|
||||
"o_field_cursor_disabled",
|
||||
!component.state.cell.row.isSection && !component.isEditable()
|
||||
);
|
||||
component.rootRef.el.classList.toggle("fw-bold", Boolean(component.state.cell.row.isSection));
|
||||
},
|
||||
() => [component.props.reactive.cell]
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
|
||||
import { useEffect, useRef } from "@odoo/owl";
|
||||
|
||||
export function useInputHook(params) {
|
||||
const inputRef = params.ref || useRef(params.refName || "input");
|
||||
|
||||
/*
|
||||
* A field is dirty if it is no longer sync with the model
|
||||
* More specifically, a field is no longer dirty after it has *tried* to update the value in the model.
|
||||
* An invalid value will therefore not be dirty even if the model will not actually store the invalid value.
|
||||
*/
|
||||
let isDirty = false;
|
||||
|
||||
/**
|
||||
* The last value that has been committed to the model.
|
||||
* Not changed in case of invalid field value.
|
||||
*/
|
||||
let lastSetValue = null;
|
||||
|
||||
/**
|
||||
* When a user types, we need to set the field as dirty.
|
||||
*/
|
||||
function onInput(ev) {
|
||||
isDirty = ev.target.value !== lastSetValue;
|
||||
if (params.setDirty) {
|
||||
params.setDirty(isDirty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On blur, we consider the field no longer dirty, even if it were to be invalid.
|
||||
* However, if the field is invalid, the new value will not be committed to the model.
|
||||
*/
|
||||
function onChange(ev) {
|
||||
if (isDirty) {
|
||||
isDirty = false;
|
||||
let isInvalid = false;
|
||||
let val = ev.target.value;
|
||||
if (params.parse) {
|
||||
try {
|
||||
val = params.parse(val);
|
||||
} catch {
|
||||
if (params.setInvalid) {
|
||||
params.setInvalid();
|
||||
}
|
||||
isInvalid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInvalid) {
|
||||
params.notifyChange(val);
|
||||
lastSetValue = ev.target.value;
|
||||
}
|
||||
|
||||
if (params.setDirty) {
|
||||
params.setDirty(isDirty);
|
||||
}
|
||||
}
|
||||
}
|
||||
function onKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (params.discard && hotkey === "escape") {
|
||||
params.discard();
|
||||
} else if (params.commitChanges && ["enter", "tab", "shift+tab"].includes(hotkey)) {
|
||||
commitChanges();
|
||||
}
|
||||
if (params.onKeyDown) {
|
||||
params.onKeyDown(ev);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
(inputEl) => {
|
||||
if (inputEl) {
|
||||
inputEl.addEventListener("input", onInput);
|
||||
inputEl.addEventListener("change", onChange);
|
||||
inputEl.addEventListener("keydown", onKeydown);
|
||||
return () => {
|
||||
inputEl.removeEventListener("input", onInput);
|
||||
inputEl.removeEventListener("change", onChange);
|
||||
inputEl.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
}
|
||||
},
|
||||
() => [inputRef.el]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sometimes, a patch can happen with possible a new value for the field
|
||||
* If the user was typing a new value (isDirty) or the field is still invalid,
|
||||
* we need to do nothing.
|
||||
* If it is not such a case, we update the field with the new value.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const isInvalid = params.isInvalid ? params.isInvalid() : false;
|
||||
if (inputRef.el && !isDirty && !isInvalid) {
|
||||
inputRef.el.value = params.getValue();
|
||||
lastSetValue = inputRef.el.value;
|
||||
}
|
||||
});
|
||||
|
||||
function isUrgentSaved(urgent) {
|
||||
if (params.isUrgentSaved) {
|
||||
return params.isUrgentSaved(urgent);
|
||||
}
|
||||
return urgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roughly the same as onChange, but called at more specific / critical times. (See bus events)
|
||||
*/
|
||||
async function commitChanges(urgent) {
|
||||
if (!inputRef.el) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDirty = inputRef.el.value !== lastSetValue;
|
||||
if (isDirty || isUrgentSaved(urgent)) {
|
||||
let isInvalid = false;
|
||||
isDirty = false;
|
||||
let val = inputRef.el.value;
|
||||
if (params.parse) {
|
||||
try {
|
||||
val = params.parse(val);
|
||||
} catch {
|
||||
isInvalid = true;
|
||||
if (urgent) {
|
||||
return;
|
||||
} else {
|
||||
params.setInvalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = params.commitChanges(val); // means change has been committed
|
||||
if (result) {
|
||||
lastSetValue = inputRef.el.value;
|
||||
if (params.setDirty) {
|
||||
params.setDirty(isDirty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inputRef;
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { exprToBoolean } from "@web/core/utils/strings";
|
||||
import { visitXML } from "@web/core/utils/xml";
|
||||
import { getActiveActions, processButton } from "@web/views/utils";
|
||||
|
||||
export class GridArchParser {
|
||||
parse(xmlDoc, models, modelName) {
|
||||
const archInfo = {
|
||||
activeActions: getActiveActions(xmlDoc),
|
||||
hideLineTotal: false,
|
||||
hideColumnTotal: false,
|
||||
hasBarChartTotal: false,
|
||||
createInline: false,
|
||||
displayEmpty: false,
|
||||
buttons: [],
|
||||
activeRangeName: "",
|
||||
ranges: {},
|
||||
sectionField: null,
|
||||
rowFields: [],
|
||||
columnFieldName: "",
|
||||
measureField: {
|
||||
name: "__count",
|
||||
aggregator: "sum",
|
||||
readonly: true,
|
||||
string: _t("Count"),
|
||||
},
|
||||
readonlyField: null,
|
||||
widgetPerFieldName: {},
|
||||
editable: false,
|
||||
formViewId: false,
|
||||
};
|
||||
let buttonId = 0;
|
||||
|
||||
visitXML(xmlDoc, (node) => {
|
||||
if (node.tagName === "grid") {
|
||||
if (node.hasAttribute("hide_line_total")) {
|
||||
archInfo.hideLineTotal = exprToBoolean(node.getAttribute("hide_line_total"));
|
||||
}
|
||||
if (node.hasAttribute("hide_column_total")) {
|
||||
archInfo.hideColumnTotal = exprToBoolean(
|
||||
node.getAttribute("hide_column_total")
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("barchart_total")) {
|
||||
archInfo.hasBarChartTotal = exprToBoolean(
|
||||
node.getAttribute("barchart_total")
|
||||
);
|
||||
}
|
||||
if (node.hasAttribute("create_inline")) {
|
||||
archInfo.createInline = exprToBoolean(node.getAttribute("create_inline"));
|
||||
}
|
||||
if (node.hasAttribute("display_empty")) {
|
||||
archInfo.displayEmpty = exprToBoolean(node.getAttribute("display_empty"));
|
||||
}
|
||||
if (node.hasAttribute("action") && node.hasAttribute("type")) {
|
||||
archInfo.openAction = {
|
||||
name: node.getAttribute("action"),
|
||||
type: node.getAttribute("type"),
|
||||
};
|
||||
}
|
||||
if (node.hasAttribute("editable")) {
|
||||
archInfo.editable = exprToBoolean(node.getAttribute("editable"));
|
||||
}
|
||||
if (node.hasAttribute("form_view_id")) {
|
||||
archInfo.formViewId = parseInt(node.getAttribute("form_view_id"), 10);
|
||||
}
|
||||
} else if (node.tagName === "field") {
|
||||
const fieldName = node.getAttribute("name"); // exists (rng validation)
|
||||
const fieldInfo = models[modelName].fields[fieldName];
|
||||
const type = node.getAttribute("type") || "row";
|
||||
const string = node.getAttribute("string") || fieldInfo.string;
|
||||
let invisible = node.getAttribute("invisible") || 'False';
|
||||
switch (type) {
|
||||
case "row":
|
||||
if (node.hasAttribute("widget")) {
|
||||
archInfo.widgetPerFieldName[fieldName] = node.getAttribute("widget");
|
||||
}
|
||||
if (
|
||||
node.hasAttribute("section") &&
|
||||
exprToBoolean(node.getAttribute("section")) &&
|
||||
!archInfo.sectionField
|
||||
) {
|
||||
archInfo.sectionField = {
|
||||
name: fieldName,
|
||||
invisible,
|
||||
};
|
||||
} else {
|
||||
archInfo.rowFields.push({
|
||||
name: fieldName,
|
||||
invisible,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "col":
|
||||
archInfo.columnFieldName = fieldName;
|
||||
const { ranges, activeRangeName } = this._extractRanges(node);
|
||||
archInfo.ranges = ranges;
|
||||
archInfo.activeRangeName = activeRangeName;
|
||||
break;
|
||||
case "measure":
|
||||
if (node.hasAttribute("widget")) {
|
||||
archInfo.widgetPerFieldName[fieldName] = node.getAttribute("widget");
|
||||
}
|
||||
archInfo.measureField = {
|
||||
name: fieldName,
|
||||
aggregator: node.getAttribute("operator") || fieldInfo.aggregator,
|
||||
string,
|
||||
readonly: exprToBoolean(node.getAttribute("readonly")) || fieldInfo.readonly,
|
||||
};
|
||||
break;
|
||||
case "readonly":
|
||||
let groupOperator = fieldInfo.aggregator;
|
||||
if (node.hasAttribute("operator")) {
|
||||
groupOperator = node.getAttribute("operator");
|
||||
}
|
||||
archInfo.readonlyField = {
|
||||
name: fieldName,
|
||||
aggregator: groupOperator,
|
||||
string,
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else if (node.tagName === "button") {
|
||||
archInfo.buttons.push({
|
||||
...processButton(node),
|
||||
type: "button",
|
||||
id: buttonId++,
|
||||
});
|
||||
}
|
||||
});
|
||||
archInfo.editable =
|
||||
archInfo.editable &&
|
||||
archInfo.measureField &&
|
||||
!archInfo.measureField.readonly &&
|
||||
archInfo.measureField.aggregator === "sum";
|
||||
return archInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the range to display on the view, and filter
|
||||
* them according they should be visible or not (attribute 'invisible')
|
||||
*
|
||||
* @private
|
||||
* @param {Element} colNode - the node of 'col' in grid view arch definition
|
||||
* @returns {
|
||||
* Object<{
|
||||
* ranges: {
|
||||
* name: {name: string, label: string, span: string, step: string, hotkey?: string}
|
||||
* },
|
||||
* activeRangeName: string,
|
||||
* }>
|
||||
* } list of ranges to apply in the grid view.
|
||||
*/
|
||||
_extractRanges(colNode) {
|
||||
const ranges = {};
|
||||
let activeRangeName;
|
||||
let firstRangeName = "";
|
||||
for (const rangeNode of colNode.children) {
|
||||
const rangeName = rangeNode.getAttribute("name");
|
||||
if (!firstRangeName.length) {
|
||||
firstRangeName = rangeName;
|
||||
}
|
||||
ranges[rangeName] = {
|
||||
name: rangeName,
|
||||
description: rangeNode.getAttribute("string"),
|
||||
span: rangeNode.getAttribute("span"),
|
||||
step: rangeNode.getAttribute("step"),
|
||||
hotkey: rangeNode.getAttribute("hotkey"),
|
||||
default: exprToBoolean(rangeNode.getAttribute("default")),
|
||||
};
|
||||
if (ranges[rangeName].default) {
|
||||
activeRangeName = rangeName;
|
||||
}
|
||||
}
|
||||
return { ranges: ranges, activeRangeName: activeRangeName || firstRangeName };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { serializeDate, deserializeDate } from "@web/core/l10n/dates";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
import { useModelWithSampleData } from "@web/model/model";
|
||||
import { standardViewProps } from "@web/views/standard_view_props";
|
||||
import { useViewButtons } from "@web/views/view_button/view_button_hook";
|
||||
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
|
||||
import { ViewButton } from "@web/views/view_button/view_button";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
import { CogMenu } from "@web/search/cog_menu/cog_menu";
|
||||
import { SearchBar } from "@web/search/search_bar/search_bar";
|
||||
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { ActionHelper } from "@web/views/action_helper";
|
||||
|
||||
import { Component, useState, onWillUnmount, useRef } from "@odoo/owl";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export class GridController extends Component {
|
||||
static components = {
|
||||
Layout,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
ViewButton,
|
||||
CogMenu,
|
||||
SearchBar,
|
||||
ActionHelper,
|
||||
};
|
||||
|
||||
static props = {
|
||||
...standardViewProps,
|
||||
archInfo: Object,
|
||||
buttonTemplate: String,
|
||||
Model: Function,
|
||||
Renderer: Function,
|
||||
};
|
||||
|
||||
static template = "odex30_web_grid.GridView";
|
||||
|
||||
setup() {
|
||||
const state = this.props.state || {};
|
||||
let activeRangeName = this.props.archInfo.activeRangeName;
|
||||
let defaultAnchor;
|
||||
if (state.activeRangeName) {
|
||||
activeRangeName = state.activeRangeName;
|
||||
} else if (this.isMobile && "day" in this.props.archInfo.ranges) {
|
||||
activeRangeName = "day";
|
||||
}
|
||||
if (state.anchor) {
|
||||
defaultAnchor = state.anchor;
|
||||
} else if (this.props.context.grid_anchor) {
|
||||
defaultAnchor = deserializeDate(this.props.context.grid_anchor);
|
||||
}
|
||||
this.dialogService = useService("dialog");
|
||||
this.model = useModelWithSampleData(this.props.Model, {
|
||||
resModel: this.props.resModel,
|
||||
sectionField: this.props.archInfo.sectionField,
|
||||
rowFields: this.props.archInfo.rowFields,
|
||||
columnFieldName: this.props.archInfo.columnFieldName,
|
||||
measureField: this.props.archInfo.measureField,
|
||||
readonlyField: this.props.archInfo.readonlyField,
|
||||
fieldsInfo: this.props.relatedModels[this.props.resModel].fields,
|
||||
activeRangeName,
|
||||
ranges: this.props.archInfo.ranges,
|
||||
defaultAnchor,
|
||||
});
|
||||
const rootRef = useRef("root");
|
||||
useSetupAction({
|
||||
rootRef: rootRef,
|
||||
getLocalState: () => {
|
||||
const { anchor, range } = this.model.navigationInfo;
|
||||
return {
|
||||
anchor,
|
||||
activeRangeName: range?.name,
|
||||
};
|
||||
}
|
||||
})
|
||||
const isWeekendVisible = browser.localStorage.getItem("grid.isWeekendVisible");
|
||||
this.state = useState({
|
||||
activeRangeName: this.model.navigationInfo.range?.name,
|
||||
isWeekendVisible: isWeekendVisible !== null && isWeekendVisible !== undefined
|
||||
? JSON.parse(isWeekendVisible)
|
||||
: true,
|
||||
});
|
||||
useViewButtons(rootRef, {
|
||||
beforeExecuteAction: this.beforeExecuteActionButton.bind(this),
|
||||
afterExecuteAction: this.afterExecuteActionButton.bind(this),
|
||||
reload: this.reload.bind(this),
|
||||
});
|
||||
onWillUnmount(() => this.closeDialog?.());
|
||||
this.searchBarToggler = useSearchBarToggler();
|
||||
}
|
||||
|
||||
get isMobile() {
|
||||
return this.env.isSmall;
|
||||
}
|
||||
|
||||
get isEditable() {
|
||||
return (
|
||||
this.props.archInfo.activeActions.edit &&
|
||||
this.props.archInfo.editable
|
||||
);
|
||||
}
|
||||
|
||||
get displayNoContent() {
|
||||
return (
|
||||
!(this.props.archInfo.displayEmpty || this.model.hasData()) || this.model.useSampleModel
|
||||
);
|
||||
}
|
||||
|
||||
get displayAddALine() {
|
||||
return this.props.archInfo.activeActions.create;
|
||||
}
|
||||
|
||||
get hasDisplayableData() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get options() {
|
||||
const { hideLineTotal, hideColumnTotal, hasBarChartTotal, createInline } =
|
||||
this.props.archInfo;
|
||||
return {
|
||||
hideLineTotal,
|
||||
hideColumnTotal,
|
||||
hasBarChartTotal,
|
||||
createInline,
|
||||
};
|
||||
}
|
||||
|
||||
createRecord(params) {
|
||||
const columnContext = this.model.columnFieldIsDate
|
||||
? {
|
||||
[`default_${this.model.columnFieldName}`]: serializeDate(
|
||||
this.model.navigationInfo.anchor
|
||||
),
|
||||
}
|
||||
: {};
|
||||
const context = {
|
||||
...this.props.context,
|
||||
...columnContext,
|
||||
...(params?.context || {}),
|
||||
};
|
||||
this.closeDialog = this.dialogService.add(
|
||||
FormViewDialog,
|
||||
{
|
||||
title: _t("New Record"),
|
||||
resModel: this.model.resModel,
|
||||
viewId: this.props.archInfo.formViewId,
|
||||
onRecordSaved: this.onRecordSaved.bind(this),
|
||||
...(params || {}),
|
||||
context,
|
||||
},
|
||||
{
|
||||
onClose: () => {
|
||||
this.closeDialog = null;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async beforeExecuteActionButton() {}
|
||||
|
||||
async afterExecuteActionButton() {}
|
||||
|
||||
async reload() {
|
||||
await this.model.reload();
|
||||
}
|
||||
|
||||
async onRecordSaved(record) {
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this.state.isWeekendVisible || ["day", "year"].includes(this.state.activeRangeName)
|
||||
? this.model.columnsArray
|
||||
: this.model.columnsArray.filter(
|
||||
(column) => DateTime.fromISO(column.value).weekday < 6
|
||||
);
|
||||
}
|
||||
|
||||
toggleWeekendVisibility() {
|
||||
this.state.isWeekendVisible = !this.state.isWeekendVisible;
|
||||
browser.localStorage.setItem("grid.isWeekendVisible", this.state.isWeekendVisible);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
@include media-breakpoint-down(md) {
|
||||
.o_grid_range_buttons.show > .dropdown-menu {
|
||||
display: inline-flex;
|
||||
min-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_view {
|
||||
.o_view_sample_data .o_grid_highlightable {
|
||||
overflow: hidden;
|
||||
@include o-sample-data-disabled;
|
||||
}
|
||||
|
||||
.o_view_nocontent {
|
||||
position: fixed;
|
||||
top: 225px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
<t t-name="odex30_web_grid.Buttons">
|
||||
<div class="d-flex o_grid_buttons">
|
||||
<div class="me-2" t-if="displayAddALine">
|
||||
<button class="btn btn-primary o_grid_button_add"
|
||||
type="button"
|
||||
t-on-click="() => this.createRecord()">
|
||||
Add a Line
|
||||
</button>
|
||||
</div>
|
||||
<t t-call="odex30_web_grid.CustomButtons" t-if="props.archInfo.buttons.length"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.CustomButtons">
|
||||
<div class="o_grid_custom_buttons me-2">
|
||||
<t t-foreach="props.archInfo.buttons" t-as="button" t-key="button.id">
|
||||
<ViewButton
|
||||
t-if="!button.invisible"
|
||||
className="button.className"
|
||||
clickParams="button.clickParams"
|
||||
defaultRank="button.defaultRank"
|
||||
icon="button.icon"
|
||||
string="button.string"
|
||||
title="button.title"
|
||||
record="model.record"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.GridView">
|
||||
<div t-attf-class="o_grid_view {{ isMobile ? 'o_action_delegate_scroll' : '' }} {{props.className}}" t-ref="root">
|
||||
<Layout className="(model.useSampleModel ? 'o_view_sample_data' : '')" display="props.display">
|
||||
<t t-set-slot="control-panel-additional-actions">
|
||||
<CogMenu/>
|
||||
</t>
|
||||
<t t-set-slot="layout-buttons">
|
||||
<t t-call="{{ props.buttonTemplate }}"/>
|
||||
</t>
|
||||
<t t-set-slot="layout-actions">
|
||||
<SearchBar toggler="searchBarToggler"/>
|
||||
</t>
|
||||
<t t-set-slot="control-panel-navigation-additional">
|
||||
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
|
||||
</t>
|
||||
<t t-set-slot="default" t-slot-scope="layout">
|
||||
<t t-if="displayNoContent">
|
||||
<ActionHelper noContentHelp="props.info.noContentHelp"/>
|
||||
</t>
|
||||
<t t-if="hasDisplayableData">
|
||||
<t t-component="props.Renderer"
|
||||
options="options"
|
||||
model="model"
|
||||
sections="model.sectionsArray"
|
||||
columns="columns"
|
||||
rows="model.itemsArray"
|
||||
sectionField="model.sectionField"
|
||||
rowFields="model.rowFields"
|
||||
measureField="props.archInfo.measureField"
|
||||
isEditable="isEditable"
|
||||
createInline="props.archInfo.activeActions.create and props.archInfo.createInline and !displayNoContent"
|
||||
createRecord.bind="createRecord"
|
||||
widgetPerFieldName="props.archInfo.widgetPerFieldName"
|
||||
openAction="props.archInfo.openAction"
|
||||
contentRef="layout.contentRef"
|
||||
ranges="model.ranges"
|
||||
state="state"
|
||||
toggleWeekendVisibility.bind="toggleWeekendVisibility"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</Layout>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
|||
// = Grid Renderer
|
||||
// ============================================================================
|
||||
// No CSS hacks, variables overrides only
|
||||
|
||||
.o_grid_renderer {
|
||||
.o_grid_row_total, .o_grid_add_line:not(.o_grid_row){
|
||||
--background-color: #{$o-gray-300};
|
||||
--color: #{$o-black};
|
||||
}
|
||||
|
||||
.o_grid_cell_overlay_total {
|
||||
--background-color: #{rgba($o-gray-400, var(--bg-opacity, 1))};
|
||||
}
|
||||
|
||||
.o_grid_section {
|
||||
--background-color: #{darken($o-gray-200, 4%)};
|
||||
|
||||
.o_grid_cell_overlay_today {
|
||||
--bg-opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_column_total_computed {
|
||||
--background-color: #{$o-gray-400};
|
||||
--color: #{$o-black};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,664 @@
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { useVirtualGrid } from "@web/core/virtual_grid_hook";
|
||||
import { Field } from "@web/views/fields/field";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { ViewScaleSelector } from "@web/views/view_components/view_scale_selector";
|
||||
|
||||
import { GridComponent } from "@odex30_web_grid/components/grid_component/grid_component";
|
||||
|
||||
import {
|
||||
Component,
|
||||
markup,
|
||||
useState,
|
||||
onWillUpdateProps,
|
||||
onMounted,
|
||||
onPatched,
|
||||
reactive,
|
||||
useRef,
|
||||
useExternalListener,
|
||||
} from "@odoo/owl";
|
||||
|
||||
export class GridRenderer extends Component {
|
||||
static components = {
|
||||
Field,
|
||||
GridComponent,
|
||||
ViewScaleSelector,
|
||||
};
|
||||
|
||||
static template = "odex30_web_grid.Renderer";
|
||||
|
||||
static props = {
|
||||
sections: { type: Array, optional: true },
|
||||
columns: { type: Array, optional: true },
|
||||
rows: { type: Array, optional: true },
|
||||
model: { type: Object, optional: true },
|
||||
options: Object,
|
||||
sectionField: { type: Object, optional: true },
|
||||
rowFields: Array,
|
||||
measureField: Object,
|
||||
isEditable: Boolean,
|
||||
widgetPerFieldName: Object,
|
||||
openAction: { type: Object, optional: true },
|
||||
contentRef: Object,
|
||||
createInline: Boolean,
|
||||
createRecord: Function,
|
||||
ranges: { type: Object, optional: true },
|
||||
state: Object,
|
||||
toggleWeekendVisibility: Function,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
sections: [],
|
||||
columns: [],
|
||||
rows: [],
|
||||
model: {},
|
||||
ranges: {},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rendererRef = useRef("renderer");
|
||||
this.actionService = useService("action");
|
||||
this.editionState = useState({
|
||||
hoveredCellInfo: false,
|
||||
editedCellInfo: false,
|
||||
});
|
||||
this.hoveredElement = null;
|
||||
const measureFieldName = this.props.model.measureFieldName;
|
||||
const fieldInfo = this.props.model.fieldsInfo[measureFieldName];
|
||||
const measureFieldWidget = this.props.widgetPerFieldName[measureFieldName];
|
||||
const widgetName = measureFieldWidget || fieldInfo.type;
|
||||
this.gridCell = registry.category("grid_components").get(widgetName);
|
||||
this.hoveredCellProps = {
|
||||
// props cell hovered
|
||||
name: measureFieldName,
|
||||
type: widgetName,
|
||||
component: this.gridCell.component,
|
||||
reactive: reactive({ cell: null }),
|
||||
fieldInfo,
|
||||
readonly: !this.props.isEditable,
|
||||
openRecords: this.openRecords.bind(this),
|
||||
editMode: false,
|
||||
onEdit: this.onEditCell.bind(this),
|
||||
getCell: this.getCell.bind(this),
|
||||
isMeasure: true,
|
||||
};
|
||||
this.editCellProps = {
|
||||
// props for cell in edit mode
|
||||
name: measureFieldName,
|
||||
type: widgetName,
|
||||
component: this.gridCell.component,
|
||||
reactive: reactive({ cell: null }),
|
||||
fieldInfo,
|
||||
readonly: !this.props.isEditable,
|
||||
openRecords: this.openRecords.bind(this),
|
||||
editMode: true,
|
||||
onEdit: this.onEditCell.bind(this),
|
||||
getCell: this.getCell.bind(this),
|
||||
onKeyDown: this.onCellKeydown.bind(this),
|
||||
isMeasure: true,
|
||||
};
|
||||
this.isEditing = false;
|
||||
onWillUpdateProps(this.onWillUpdateProps);
|
||||
onMounted(this._focusOnToday);
|
||||
onPatched(this._focusOnToday);
|
||||
// This property is used to avoid refocus on today whenever a cell value is updated.
|
||||
this.shouldFocusOnToday = true;
|
||||
this.onMouseOver = useDebounced(this._onMouseOver, 10);
|
||||
this.onMouseOut = useDebounced(this._onMouseOut, 10);
|
||||
this.virtualGrid = useVirtualGrid({
|
||||
scrollableRef: this.props.contentRef,
|
||||
initialScroll: { top: 60 },
|
||||
});
|
||||
useExternalListener(window, "click", this.onClick);
|
||||
useExternalListener(window, "keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
getCell(rowId, columnId) {
|
||||
return this.props.model.data.rows[rowId]?.cells[columnId];
|
||||
}
|
||||
|
||||
getItemHeight(item) {
|
||||
let height = this.rowHeight;
|
||||
if (item.isSection && item.isFake) {
|
||||
return 0;
|
||||
}
|
||||
if (this.props.createInline && !item.isSection && item.section.lastRow.id === item.id) {
|
||||
height *= 2; // to include the Add a line row
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
get isMobile() {
|
||||
return this.env.isSmall;
|
||||
}
|
||||
|
||||
get rowHeight() {
|
||||
const baseHeight = this.isMobile ? 48 : 32;
|
||||
/*
|
||||
* On mobile devices, grouped by fields are stacked vertically.
|
||||
* By default, the base height accommodates up to 2 fields.
|
||||
* For each additional field beyond the first 2, we add 20px to maintain proper spacing.
|
||||
*/
|
||||
const extraHeight = this.isMobile
|
||||
? Math.max(0, this.props.model.rowFields.length - 2) * 20
|
||||
: 0;
|
||||
return baseHeight + extraHeight;
|
||||
}
|
||||
|
||||
get virtualRows() {
|
||||
this.virtualGrid.setRowsHeights(this.props.rows.map((row) => this.getItemHeight(row)));
|
||||
const [start, end] = this.virtualGrid.rowsIndexes;
|
||||
return this.props.rows.slice(start, end + 1);
|
||||
}
|
||||
|
||||
getRowPosition(row, isCreateInlineRow = false) {
|
||||
const rowIndex = row ? this.props.rows.findIndex((r) => r.id === row.id) : 0;
|
||||
const section = row && row.getSection();
|
||||
const sectionDisplayed = Boolean(
|
||||
section && (section.value || this.props.sections.length > 1)
|
||||
);
|
||||
let rowPosition = this.rowsGap + rowIndex + 1 + (sectionDisplayed ? section.sectionId : 0);
|
||||
if (isCreateInlineRow) {
|
||||
rowPosition += 1;
|
||||
}
|
||||
if (!sectionDisplayed) {
|
||||
rowPosition -= 1;
|
||||
}
|
||||
return rowPosition;
|
||||
}
|
||||
|
||||
getTotalRowPosition() {
|
||||
let sectionIndex = 0;
|
||||
if (this.props.model.sectionField && this.props.sections.length) {
|
||||
if (this.props.sections.length > 1 || this.props.sections[0].value) {
|
||||
sectionIndex = this.props.sections.length;
|
||||
}
|
||||
}
|
||||
return (
|
||||
(this.props.rows.length || 1) +
|
||||
sectionIndex +
|
||||
(this.props.createInline ? 1 : 0) +
|
||||
this.rowsGap
|
||||
);
|
||||
}
|
||||
|
||||
onWillUpdateProps(nextProps) {}
|
||||
|
||||
formatValue(value) {
|
||||
return this.gridCell.formatter(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* TODO: [XBO] remove me in master
|
||||
* @param {*} data
|
||||
*/
|
||||
getDefaultState(data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
get rowsCount() {
|
||||
const addLineRows = this.props.createInline ? this.props.sections.length || 1 : 0;
|
||||
return this.props.rows.length - (this.props.model.sectionField ? 0 : 1) + addLineRows;
|
||||
}
|
||||
|
||||
get gridTemplateRows() {
|
||||
let totalRows = 0;
|
||||
if (!this.props.options.hideColumnTotal) {
|
||||
totalRows += 1;
|
||||
if (this.props.options.hasBarChartTotal) {
|
||||
totalRows += 1;
|
||||
}
|
||||
}
|
||||
// Row height must be hard-coded for the virtual hook to work properly.
|
||||
return `auto repeat(${this.rowsCount + totalRows}, ${this.rowHeight}px)`;
|
||||
}
|
||||
|
||||
get gridTemplateColumns() {
|
||||
return `auto repeat(${this.props.columns.length}, ${
|
||||
this.props.columns.length > 7 ? "minmax(8ch, auto)" : "minmax(10ch, 1fr)"
|
||||
}) minmax(10ch, 10em)`;
|
||||
}
|
||||
|
||||
get measureLabel() {
|
||||
const measureFieldName = this.props.model.measureFieldName;
|
||||
if (measureFieldName === "__count") {
|
||||
return _t("Total");
|
||||
}
|
||||
return (
|
||||
this.props.measureField.string || this.props.model.fieldsInfo[measureFieldName].string
|
||||
);
|
||||
}
|
||||
|
||||
get rowsGap() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
get columnsGap() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
get displayAddLine() {
|
||||
return this.props.createInline && this.row.id === this.row.section.lastRow.id;
|
||||
}
|
||||
|
||||
getCellColorClass(column, section) {
|
||||
return "text-900";
|
||||
}
|
||||
|
||||
getSectionColumnsClasses(column, row) {
|
||||
const isToday = column.isToday;
|
||||
return {
|
||||
"bg-info bg-opacity-50": isToday,
|
||||
"bg-200 border-top": !isToday,
|
||||
"bg-opacity-75":
|
||||
this.getUnavailableClass(column) === "o_grid_unavailable" &&
|
||||
row.cells[column.id].value === 0,
|
||||
};
|
||||
}
|
||||
|
||||
getSectionCellsClasses(column, row) {
|
||||
return {
|
||||
"text-opacity-25":
|
||||
row.cells[column.id].value === 0 ||
|
||||
this.getUnavailableClass(column) === "o_grid_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
isTextDanger() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getTextColorClasses(column, row, isEven) {
|
||||
const value = row.cells[column.id].value;
|
||||
const isTextDanger = this.isTextDanger(row, column);
|
||||
return {
|
||||
"text-bg-view": isEven && value >= 0 && !isTextDanger,
|
||||
"text-900": !isEven && value >= 0 && !isTextDanger,
|
||||
"text-danger": value < 0 || isTextDanger,
|
||||
};
|
||||
}
|
||||
|
||||
getCellsClasses(column, row, section, isEven) {
|
||||
return {
|
||||
...this.getTextColorClasses(column, row, isEven),
|
||||
o_grid_cell_today: column.isToday,
|
||||
"fst-italic": row.isAdditionalRow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GridSection | GridRow} section
|
||||
*/
|
||||
_getTotalCellBgColor(section) {
|
||||
return "text-bg-800";
|
||||
}
|
||||
|
||||
getSectionTotalRowClass(section, grandTotal) {
|
||||
return {
|
||||
[this._getTotalCellBgColor(section)]: true,
|
||||
"text-opacity-25": grandTotal === 0,
|
||||
};
|
||||
}
|
||||
|
||||
getColumnBarChartHeightStyle(column) {
|
||||
let heightPercentage = 0;
|
||||
if (this.props.model.maxColumnsTotal !== 0) {
|
||||
heightPercentage = (column.grandTotal / this.props.model.maxColumnsTotal) * 100;
|
||||
}
|
||||
return `height: ${heightPercentage}%; bottom: 0;`;
|
||||
}
|
||||
|
||||
getFooterTotalCellClasses(grandTotal) {
|
||||
if (grandTotal < 0) {
|
||||
return "bg-danger text-bg-danger";
|
||||
}
|
||||
|
||||
return "bg-400";
|
||||
}
|
||||
|
||||
getUnavailableClass(column, section = undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
getFieldAdditionalProps(fieldName) {
|
||||
return {
|
||||
name: fieldName,
|
||||
type:
|
||||
this.props.widgetPerFieldName[fieldName] ||
|
||||
this.props.model.fieldsInfo[fieldName].type,
|
||||
};
|
||||
}
|
||||
|
||||
getCellsTextClasses(column, row) {
|
||||
return {
|
||||
"text-900 text-opacity-25": row.cells[column.id].value === 0,
|
||||
};
|
||||
}
|
||||
|
||||
getTotalCellsTextClasses(row, grandTotal) {
|
||||
return {
|
||||
"fst-italic": row.isAdditionalRow,
|
||||
"text-bg-200": grandTotal >= 0,
|
||||
"bg-danger text-bg-danger": grandTotal < 0,
|
||||
"text-opacity-50": grandTotal === 0,
|
||||
};
|
||||
}
|
||||
|
||||
onCreateInlineClick(section) {
|
||||
const context = {
|
||||
...(section?.context || {}),
|
||||
};
|
||||
const title = _t("Add a Line");
|
||||
this.props.createRecord({ context, title });
|
||||
}
|
||||
|
||||
_focusOnToday() {
|
||||
if (!this.shouldFocusOnToday) {
|
||||
return;
|
||||
}
|
||||
this.shouldFocusOnToday = false;
|
||||
const { navigationInfo, columnFieldIsDate } = this.props.model;
|
||||
if (this.isMobile || !columnFieldIsDate || navigationInfo.range.name != "month") {
|
||||
return;
|
||||
}
|
||||
const rendererEl = this.rendererRef.el;
|
||||
const todayEl = rendererEl.querySelector("div.o_grid_column_title.fw-bolder");
|
||||
if (todayEl) {
|
||||
rendererEl.parentElement.scrollLeft =
|
||||
todayEl.offsetLeft - rendererEl.offsetWidth / 2 + todayEl.offsetWidth / 2;
|
||||
}
|
||||
}
|
||||
|
||||
_onMouseOver(ev) {
|
||||
if (this.hoveredElement || ev.fromElement?.classList.contains("dropdown-item")) {
|
||||
// As mouseout is call prior to mouseover, if hoveredElement is set this means
|
||||
// that we haven't left it. So it's a mouseover inside it.
|
||||
return;
|
||||
}
|
||||
const highlightableElement = ev.target.closest(".o_grid_highlightable");
|
||||
if (!highlightableElement) {
|
||||
// We are not in an element that should trigger a highlight.
|
||||
return;
|
||||
}
|
||||
const { column, gridRow, gridColumn, row } = highlightableElement.dataset;
|
||||
const isCellInColumnTotalHighlighted =
|
||||
highlightableElement.classList.contains("o_grid_row_total");
|
||||
const elementsToHighlight = this.rendererRef.el.querySelectorAll(
|
||||
`.o_grid_highlightable[data-grid-row="${gridRow}"]:not(.o_grid_add_line):not(.o_grid_column_title), .o_grid_highlightable[data-grid-column="${gridColumn}"]:not(.o_grid_row_timer):not(.o_grid_section_title):not(.o_grid_row_title${
|
||||
isCellInColumnTotalHighlighted ? ",.o_grid_row_total" : ""
|
||||
})`
|
||||
);
|
||||
for (const node of elementsToHighlight) {
|
||||
if (node.classList.contains("o_grid_bar_chart_container")) {
|
||||
node.classList.add("o_grid_highlighted");
|
||||
}
|
||||
if (node.dataset.gridRow === gridRow) {
|
||||
node.classList.add("o_grid_highlighted");
|
||||
if (node.dataset.gridColumn === gridColumn) {
|
||||
node.classList.add("o_grid_cell_highlighted");
|
||||
} else {
|
||||
node.classList.add("o_grid_row_highlighted");
|
||||
}
|
||||
}
|
||||
}
|
||||
this.hoveredElement = highlightableElement;
|
||||
const cell = this.editCellProps.reactive.cell;
|
||||
if (
|
||||
row &&
|
||||
column &&
|
||||
!(cell && cell.dataset.row === row && cell.dataset.column === column)
|
||||
) {
|
||||
this.hoveredCellProps.reactive.cell = highlightableElement;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse out handler
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onMouseOut(ev) {
|
||||
if (!this.hoveredElement) {
|
||||
// If hoveredElement is not set this means were not in a o_grid_highlightable. So ignore it.
|
||||
return;
|
||||
}
|
||||
/** @type {HTMLElement | null} */
|
||||
let relatedTarget = ev.relatedTarget;
|
||||
const gridCell = relatedTarget?.closest(".o_grid_cell");
|
||||
if (
|
||||
gridCell &&
|
||||
gridCell.dataset.gridRow === this.hoveredElement.dataset.gridRow &&
|
||||
gridCell.dataset.gridColumn === this.hoveredElement.dataset.gridColumn &&
|
||||
gridCell !== this.editCellProps.reactive.cell
|
||||
) {
|
||||
return;
|
||||
}
|
||||
while (relatedTarget) {
|
||||
// Go up the parent chain
|
||||
if (relatedTarget === this.hoveredElement) {
|
||||
// Check that we are still inside hoveredConnector.
|
||||
// If so it means it is a transition between child elements so ignore it.
|
||||
return;
|
||||
}
|
||||
relatedTarget = relatedTarget.parentElement;
|
||||
}
|
||||
const { gridRow, gridColumn } = this.hoveredElement.dataset;
|
||||
const elementsHighlighted = this.rendererRef.el.querySelectorAll(
|
||||
`.o_grid_highlightable[data-grid-row="${gridRow}"], .o_grid_highlightable[data-grid-column="${gridColumn}"]`
|
||||
);
|
||||
for (const node of elementsHighlighted) {
|
||||
node.classList.remove(
|
||||
"o_grid_highlighted",
|
||||
"o_grid_row_highlighted",
|
||||
"o_grid_cell_highlighted"
|
||||
);
|
||||
}
|
||||
this.hoveredElement = null;
|
||||
if (this.hoveredCellProps.reactive.cell) {
|
||||
this.hoveredCellProps.reactive.cell
|
||||
.querySelector(".o_grid_cell_readonly")
|
||||
.classList.remove("d-none");
|
||||
this.hoveredCellProps.reactive.cell = null;
|
||||
}
|
||||
}
|
||||
|
||||
onEditCell(value) {
|
||||
if (this.editCellProps.reactive.cell) {
|
||||
this.editCellProps.reactive.cell
|
||||
.querySelector(".o_grid_cell_readonly")
|
||||
.classList.remove("d-none");
|
||||
}
|
||||
if (value) {
|
||||
this.editCellProps.reactive.cell = this.hoveredCellProps.reactive.cell;
|
||||
this.hoveredCellProps.reactive.cell = null;
|
||||
} else {
|
||||
this.editCellProps.reactive.cell = null;
|
||||
}
|
||||
}
|
||||
|
||||
_onKeyDown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (hotkey === "escape" && this.editCellProps.reactive.cell) {
|
||||
this.onEditCell(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on any element in the grid
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
if (
|
||||
!this.editCellProps.reactive.cell ||
|
||||
ev.target.closest(".o_grid_highlighted") ||
|
||||
ev.target.closest(".o_grid_cell")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.onEditCell(false);
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
this._onKeyDown(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the click on a cell in mobile
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onCellClick(ev) {
|
||||
ev.stopPropagation();
|
||||
const cell = ev.target.closest(".o_grid_highlightable");
|
||||
const { row, column } = cell.dataset;
|
||||
if (row && column) {
|
||||
if (this.editCellProps.reactive.cell) {
|
||||
this.editCellProps.reactive.cell
|
||||
.querySelector(".o_grid_cell_readonly")
|
||||
.classList.remove("d-none");
|
||||
}
|
||||
this.editCellProps.reactive.cell = cell;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown when cell is edited in the grid view.
|
||||
*
|
||||
* @param {KeyboardEvent} ev
|
||||
* @param {import("./grid_model").GridCell | null} cell
|
||||
*/
|
||||
onCellKeydown(ev, cell) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (!this.rendererRef.el || !cell || !["tab", "shift+tab", "enter"].includes(hotkey)) {
|
||||
this._onKeyDown(ev);
|
||||
return;
|
||||
}
|
||||
// Purpose: prevent browser defaults
|
||||
ev.preventDefault();
|
||||
// Purpose: stop other window keydown listeners (e.g. home menu)
|
||||
ev.stopImmediatePropagation();
|
||||
let rowId = cell.row.id;
|
||||
let columnId = cell.column.id;
|
||||
const columnIds = this.props.columns.map((c) => c.id);
|
||||
const rowIds = [];
|
||||
for (const item of this.props.rows) {
|
||||
if (!item.isSection) {
|
||||
rowIds.push(item.id);
|
||||
}
|
||||
}
|
||||
let columnIndex = columnIds.indexOf(columnId);
|
||||
let rowIndex = rowIds.indexOf(rowId);
|
||||
if (hotkey === "tab") {
|
||||
columnIndex += 1;
|
||||
rowIndex += 1;
|
||||
if (columnIndex < columnIds.length) {
|
||||
columnId = columnIds[columnIndex];
|
||||
} else {
|
||||
columnId = columnIds[0];
|
||||
if (rowIndex < rowIds.length) {
|
||||
rowId = rowIds[rowIndex];
|
||||
} else {
|
||||
rowId = rowIds[0];
|
||||
}
|
||||
}
|
||||
} else if (hotkey === "shift+tab") {
|
||||
columnIndex -= 1;
|
||||
rowIndex -= 1;
|
||||
if (columnIndex >= 0) {
|
||||
columnId = columnIds[columnIndex];
|
||||
} else {
|
||||
columnId = columnIds[columnIds.length - 1];
|
||||
if (rowIndex >= 0) {
|
||||
rowId = rowIds[rowIndex];
|
||||
} else {
|
||||
rowId = rowIds[rowIds.length - 1];
|
||||
}
|
||||
}
|
||||
} else if (hotkey === "enter") {
|
||||
rowIndex += 1;
|
||||
if (rowIndex >= rowIds.length) {
|
||||
columnIndex = (columnIndex + 1) % columnIds.length;
|
||||
columnId = columnIds[columnIndex];
|
||||
}
|
||||
rowIndex = rowIndex % rowIds.length;
|
||||
rowId = rowIds[rowIndex];
|
||||
}
|
||||
this.onEditCell(false);
|
||||
this.hoveredCellProps.reactive.cell = this.rendererRef.el.querySelector(
|
||||
`.o_grid_highlightable[data-row="${rowId}"][data-column="${columnId}"]`
|
||||
);
|
||||
this.onEditCell(true);
|
||||
}
|
||||
|
||||
async openRecords(actionTitle, domain, context) {
|
||||
const resModel = this.props.model.resModel;
|
||||
if (this.props.openAction) {
|
||||
const resIds = await this.props.model.orm.search(resModel, domain);
|
||||
this.actionService.doActionButton({
|
||||
...this.props.openAction,
|
||||
resModel,
|
||||
resIds,
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
// retrieve form and list view ids from the action
|
||||
const { views = [] } = this.env.config;
|
||||
const openRecordsViews = ["list", "form"].map((viewType) => {
|
||||
const view = views.find((view) => view[1] === viewType);
|
||||
return [view ? view[0] : false, viewType];
|
||||
});
|
||||
this.actionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: actionTitle,
|
||||
res_model: resModel,
|
||||
views: openRecordsViews,
|
||||
domain,
|
||||
context,
|
||||
help: this._getNoContentHelper(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Return grid cell action helper when no records are found. */
|
||||
_getNoContentHelper() {
|
||||
const noActivitiesFound = _t("No activities found");
|
||||
return markup`<p class='o_view_nocontent_smiling_face'>${noActivitiesFound}</p>`;
|
||||
}
|
||||
|
||||
onMagnifierGlassClick(section, column) {
|
||||
const title = `${section.title} (${column.title})`;
|
||||
const domain = Domain.and([section.domain, column.domain]).toList();
|
||||
this.openRecords(title, domain, section.context);
|
||||
}
|
||||
|
||||
get rangesArray() {
|
||||
return Object.values(this.props.ranges);
|
||||
}
|
||||
|
||||
async onRangeClick(name) {
|
||||
await this.props.model.setRange(name);
|
||||
this.props.state.activeRangeName = name;
|
||||
this.shouldFocusOnToday = true;
|
||||
}
|
||||
|
||||
async onTodayButtonClick() {
|
||||
await this.props.model.setTodayAnchor();
|
||||
this.shouldFocusOnToday = true;
|
||||
}
|
||||
|
||||
async onPreviousButtonClick() {
|
||||
await this.props.model.moveAnchor("backward");
|
||||
this.shouldFocusOnToday = true;
|
||||
}
|
||||
|
||||
async onNextButtonClick() {
|
||||
await this.props.model.moveAnchor("forward");
|
||||
this.shouldFocusOnToday = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// These variables should be moved to a seperate file `grid_renderer.variables.scss`
|
||||
// These values are hard coded in grid_renderer.js
|
||||
$o-web-grid-first-column-width: 3rem;
|
||||
$o-web-grid-cell-minwidth: 10ch;
|
||||
|
||||
@mixin o-grid-zindex($level) {
|
||||
z-index: map-get(
|
||||
(
|
||||
interact: 20,
|
||||
// Top-most elements
|
||||
top: 30,
|
||||
),
|
||||
$level
|
||||
);
|
||||
}
|
||||
|
||||
.o_grid_renderer {
|
||||
height: inherit;
|
||||
|
||||
.o_grid_grid {
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.o_grid_row, .o_grid_section, .o_grid_column_title {
|
||||
&:not(.o_grid_highlighted) {
|
||||
> .o_grid_cell_overlay:not(.o_grid_unavailable), > .o_grid_cell_overlay_total {
|
||||
--bg-opacity: 0;
|
||||
}
|
||||
|
||||
> .o_grid_cell_overlay.o_grid_unavailable {
|
||||
--bg-opacity: .05;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_column_title {
|
||||
@include o-grid-zindex(interact);
|
||||
|
||||
> .o_grid_cell_overlay_today {
|
||||
--bg-opacity: .5;
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_row_title, .o_grid_section_title {
|
||||
@include o-grid-zindex(interact);
|
||||
}
|
||||
|
||||
.o_grid_highlighted, .o_grid_row_highlighted {
|
||||
> .o_grid_cell_overlay {
|
||||
--bg-opacity: .1;
|
||||
}
|
||||
|
||||
> .o_grid_cell_overlay_today, > .o_grid_cell_overlay_total {
|
||||
--bg-opacity: .4;
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_cell_highlighted:not(.o_grid_row_title):not(.o_grid_section_title):not(.o_grid_column_total) {
|
||||
> .o_grid_cell_overlay {
|
||||
--bg-opacity: .15;
|
||||
}
|
||||
|
||||
> .o_grid_cell_overlay_today, > .o_grid_cell_overlay_total {
|
||||
--bg-opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_section .o_grid_cell_overlay_today {
|
||||
--bg-opacity: .25;
|
||||
}
|
||||
|
||||
.o_grid_section ~ .o_grid_row.o_grid_row_title {
|
||||
padding-left: calc(#{map-get($spacers, 3)} + #{$o-avatar-size} + #{map-get($spacers, 1)}) !important;
|
||||
}
|
||||
|
||||
.o_grid_row_title {
|
||||
.o_form_uri {
|
||||
@include text-truncate();
|
||||
@include o-hover-text-color($body-color, $o-action);
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
// To have at least one day shown in My Timesheets on mobile
|
||||
.o_grid_row_timer + .o_grid_row_title {
|
||||
@include media-breakpoint-down(md) {
|
||||
max-width: calc(100vw - #{$o-web-grid-cell-minwidth} - #{$o-web-grid-first-column-width})
|
||||
}
|
||||
}
|
||||
|
||||
.o_grid_grid .o_input {
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.o_grid_navigation_wrap {
|
||||
@include o-grid-zindex(top);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<templates>
|
||||
<t t-name="odex30_web_grid.Renderer">
|
||||
<div class="o_grid_renderer position-relative z-0" t-ref="renderer">
|
||||
<div class="o_grid_grid d-grid gap-0 border-bottom bg-100"
|
||||
t-attf-style="grid-template-rows: {{ gridTemplateRows }}; grid-template-columns: {{ gridTemplateColumns }}"
|
||||
t-on-mouseover.stop="onMouseOver"
|
||||
t-on-mouseout.stop="onMouseOut"
|
||||
>
|
||||
<t t-call="odex30_web_grid.Header"/>
|
||||
<t t-foreach="virtualRows" t-as="row" t-key="row.id">
|
||||
<t t-if="row.isSection">
|
||||
<t t-call="odex30_web_grid.Section"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="odex30_web_grid.Row"/>
|
||||
<t t-if="displayAddLine">
|
||||
<t t-call="odex30_web_grid.AddLine"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="props.createInline and !props.model.hasData()" t-call="odex30_web_grid.AddLine"/>
|
||||
<t t-call="odex30_web_grid.Footer"/>
|
||||
<t t-if="props.options.hasBarChartTotal" t-call="odex30_web_grid.barChart"/>
|
||||
<GridComponent
|
||||
t-props="hoveredCellProps"
|
||||
/>
|
||||
<GridComponent
|
||||
t-props="editCellProps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.Header">
|
||||
<div class="o_grid_column_title o_grid_navigation_wrap position-md-sticky top-0 start-0 d-flex align-items-center gap-2 px-3 border-bottom bg-100 overflow-visible"
|
||||
t-attf-style="grid-row: {{rowsGap}}; grid-column: {{columnsGap}};">
|
||||
<t t-call="odex30_web_grid.NavigationButtons"/>
|
||||
</div>
|
||||
<div t-foreach="props.columns"
|
||||
t-as="column"
|
||||
t-key="column.id"
|
||||
class="o_grid_column_title position-relative position-md-sticky top-0 d-flex align-items-center justify-content-center px-3 py-2 border-bottom text-bg-100"
|
||||
t-att-class="{
|
||||
'fw-bolder': column.isToday,
|
||||
'fw-bold' : !column.isToday,
|
||||
'text-opacity-25' : getUnavailableClass(column) === 'o_grid_unavailable'
|
||||
}"
|
||||
t-att-data-grid-row="rowsGap"
|
||||
t-att-data-grid-column="column_index + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowsGap}}; grid-column: {{column_index + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100"
|
||||
t-att-class="{
|
||||
'o_grid_cell_overlay_today bg-info': column.isToday,
|
||||
'o_grid_cell_overlay bg-700' : !column.isToday,
|
||||
}"
|
||||
t-attf-class="{{getUnavailableClass(column)}}"/>
|
||||
<span class="z-1 text-center" t-out="column.title"/>
|
||||
</div>
|
||||
<div t-if="!props.options.hideLineTotal"
|
||||
class="o_grid_column_title o_grid_row_total position-md-sticky top-0 end-0 d-flex align-items-center justify-content-center text-bg-200 px-3 py-2 border-bottom fw-bold text-center"
|
||||
t-attf-style="grid-row: {{rowsGap}}; grid-column: {{props.columns.length + 1 + columnsGap}};"
|
||||
>
|
||||
<span class="z-1" t-out="measureLabel"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.Section">
|
||||
<t t-set="section" t-value="row"/>
|
||||
<t t-set="rowPosition" t-value="getRowPosition(section)"/>
|
||||
<t name="section" t-if="!section.isFake and (props.sections.length > 1 or section.value)">
|
||||
<div class="o_grid_section o_grid_section_title o_grid_highlightable d-flex align-items-center ps-3 border-top bg-200 fw-bold"
|
||||
t-att-class="{
|
||||
'position-md-sticky start-0': !props.model.useSampleModel,
|
||||
}"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{columnsGap}};"
|
||||
>
|
||||
<div class="o_grid_cell_overlay position-absolute top-0 start-0 w-100 h-100 bg-700"/>
|
||||
<GridComponent
|
||||
classNames="'z-1 text-truncate'"
|
||||
name="props.sectionField.name"
|
||||
model="props.model"
|
||||
row="section"
|
||||
t-props="getFieldAdditionalProps(props.sectionField.name)"
|
||||
/>
|
||||
</div>
|
||||
<div t-foreach="props.columns"
|
||||
t-as="column"
|
||||
t-key="column.id"
|
||||
t-attf-class="o_grid_section o_grid_highlightable position-relative d-flex align-items-center justify-content-center fw-bold {{ getCellColorClass(column, section) }}"
|
||||
t-att-class="getSectionColumnsClasses(column, section)"
|
||||
t-att-data-row="section.id"
|
||||
t-att-data-column="column.id"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="column_index + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{column_index + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100"
|
||||
t-att-class="{
|
||||
'o_grid_cell_overlay_today bg-info bg-opacity-25' : column.isToday,
|
||||
'o_grid_cell_overlay bg-700' : !column.isToday,
|
||||
}"
|
||||
t-attf-class="{{getUnavailableClass(column)}}"
|
||||
/>
|
||||
<div class="o_grid_cell_readonly position-relative d-flex justify-content-center align-items-center w-100 h-100">
|
||||
<span class="z-1" t-att-class="getSectionCellsClasses(column, section)"
|
||||
t-out="formatValue(section.cells[column.id].value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set="grandTotal" t-value="section.getGrandTotal(props.state.isWeekendVisible)" />
|
||||
<div t-if="!props.options.hideLineTotal"
|
||||
class="o_grid_section o_grid_row_total o_grid_highlightable position-relative position-md-sticky end-0 z-1 d-flex align-items-center justify-content-center px-3 py-1 border-top fw-bold"
|
||||
t-att-class="getSectionTotalRowClass(section, grandTotal)"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="props.columns.length + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{props.columns.length + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="o_grid_cell_overlay_total position-absolute top-0 start-0 w-100 h-100 bg-900"/>
|
||||
<div class="position-relative d-flex align-items-center">
|
||||
<span t-out="formatValue(grandTotal)"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.Row">
|
||||
<t t-set="rowPosition" t-value="getRowPosition(row)"/>
|
||||
<t t-set="isEven" t-value="rowPosition % 2 !== 0"/>
|
||||
<div name="row"
|
||||
class="o_grid_row o_grid_row_title o_grid_highlightable position-relative d-flex flex-column flex-md-row justify-content-center justify-content-md-start align-items-md-center px-3 py-1"
|
||||
t-att-class="{
|
||||
'position-md-sticky start-0': !props.model.useSampleModel,
|
||||
'bg-view': isEven,
|
||||
'bg-100': !isEven,
|
||||
'fst-italic': row.isAdditionalRow,
|
||||
'text-opacity-25' : getUnavailableClass(row) === 'o_grid_unavailable'
|
||||
}"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{columnsGap}};"
|
||||
>
|
||||
<div class="o_grid_cell_overlay position-absolute top-0 start-0 w-100 h-100 bg-700"/>
|
||||
<t t-set="rowFields" t-value="props.model.rowFields.map(field => field.name)"/>
|
||||
<t t-foreach="props.rowFields" t-as="rowField" t-key="rowField_index">
|
||||
<t t-set="fieldName" t-value="rowField.name"/>
|
||||
<GridComponent
|
||||
t-if="row.initialRecordValues[fieldName] or rowField_index === 0"
|
||||
classNames="`d-flex z-1 text-truncate o_grid_field_${fieldName.replace('_id','')}${isMobile ? '' : ' text-nowrap'}${row.isAdditionalRow ? ' pe-1' : ''}`"
|
||||
name="fieldName"
|
||||
value="row.initialRecordValues[fieldName]"
|
||||
model="props.model"
|
||||
row="row"
|
||||
t-props="getFieldAdditionalProps(fieldName)"
|
||||
/>
|
||||
<span t-if="!rowField_last and row.initialRecordValues[props.rowFields[rowField_index + 1].name]"
|
||||
class="o_grid_row_data_separator d-none d-md-inline px-2 text-300"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
<div t-foreach="props.columns"
|
||||
t-as="column"
|
||||
t-key="column.id"
|
||||
class="o_grid_row o_grid_highlightable position-relative d-flex align-items-center justify-content-center"
|
||||
t-att-class="getCellsClasses(column, row, section, isEven)"
|
||||
t-att-data-row="row.id"
|
||||
t-att-data-column="column.id"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="column_index + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{column_index + 1 + columnsGap}};"
|
||||
t-on-click.prevent.synthetic="onCellClick"
|
||||
>
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100"
|
||||
t-att-class="{
|
||||
'o_grid_cell_overlay_today bg-info bg-opacity-25' : column.isToday,
|
||||
'o_grid_cell_overlay bg-700' : !column.isToday,
|
||||
}"
|
||||
t-attf-class="{{getUnavailableClass(column)}}"
|
||||
/>
|
||||
<div class="o_grid_cell_readonly position-relative d-flex justify-content-center align-items-center w-100 h-100">
|
||||
<span t-att-class="getCellsTextClasses(column, row)"
|
||||
t-esc="formatValue(row.cells[column.id].value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set="grandTotal" t-value="row.getGrandTotal(props.state.isWeekendVisible)" />
|
||||
<div t-if="!props.options.hideLineTotal"
|
||||
class="o_grid_row o_grid_row_total o_grid_highlightable position-relative position-md-sticky end-0 d-flex align-items-center justify-content-center px-3 py-1"
|
||||
t-att-class="getTotalCellsTextClasses(row, grandTotal)"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="props.columns.length + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{props.columns.length + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="o_grid_cell_overlay_total position-absolute top-0 start-0 w-100 h-100 bg-900"/>
|
||||
<span class="z-1" t-out="formatValue(grandTotal)"/>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.AddLine">
|
||||
<t t-set="rowPosition" t-value="getRowPosition(row or undefined, true)"/>
|
||||
<t t-set="isEven" t-value="rowPosition % 2 !== 0"/>
|
||||
<div t-if="props.createInline"
|
||||
class="o_grid_row o_grid_add_line o_grid_add_line_first_cell position-md-sticky start-0 d-flex align-items-center z-1 ps-3"
|
||||
t-att-class="{
|
||||
'bg-view': isEven,
|
||||
'bg-100': !isEven,
|
||||
}"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{columnsGap}};"
|
||||
>
|
||||
<a class="btn btn-link o_text_overflow"
|
||||
t-on-click="() => this.onCreateInlineClick(row?.section)"
|
||||
data-hotkey="i">
|
||||
<i class="fa fa-plus-circle fs-4 me-1"/>Add a line
|
||||
</a>
|
||||
</div>
|
||||
<div t-foreach="props.columns"
|
||||
t-as="column"
|
||||
t-key="column.id"
|
||||
class="o_grid_row o_grid_add_line position-relative"
|
||||
t-att-class="{
|
||||
'o_grid_cell_today' : column.isToday,
|
||||
'bg-view': isEven,
|
||||
}"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="column_index + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{column_index + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100"
|
||||
t-att-class="{
|
||||
'o_grid_cell_overlay_today bg-info bg-opacity-25' : column.isToday,
|
||||
'o_grid_cell_overlay bg-700' : !column.isToday,
|
||||
}"
|
||||
t-attf-class="{{getUnavailableClass(column)}}"
|
||||
/>
|
||||
</div>
|
||||
<div class="o_grid_add_line position-md-sticky end-0 text-bg-200"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{props.columns.length + 1 + columnsGap}};"
|
||||
></div>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.Footer">
|
||||
<t t-if="!props.options.hideColumnTotal">
|
||||
<t t-set="rowPosition" t-value="getTotalRowPosition()"/>
|
||||
<t t-set="isEven" t-value="rowPosition % 2 !== 0"/>
|
||||
<div class=""
|
||||
t-att-class="{
|
||||
'bg-view': isEven,
|
||||
'bg-100': !isEven,
|
||||
'z-1 position-md-sticky start-0': !props.model.useSampleModel,
|
||||
}"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{columnsGap}};"/>
|
||||
<t t-set="grandTotal"
|
||||
t-value="0"
|
||||
/>
|
||||
<div t-foreach="props.columns"
|
||||
t-as="column"
|
||||
t-key="column.id"
|
||||
class="o_grid_row o_grid_column_total o_grid_highlightable position-relative"
|
||||
t-att-class="{
|
||||
'bg-view': isEven and column.grandTotal >= 0,
|
||||
'text-danger': column.grandTotal lt 0,
|
||||
}"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="column_index + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{column_index + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100"
|
||||
t-att-class="{
|
||||
'o_grid_cell_overlay_today bg-info bg-opacity-25' : column.isToday,
|
||||
'o_grid_cell_overlay bg-700' : !column.isToday,
|
||||
}"
|
||||
t-attf-class="{{getUnavailableClass(column)}}"/>
|
||||
<t t-set="grandTotal"
|
||||
t-value="grandTotal + column.grandTotal"
|
||||
/>
|
||||
<div class="h-100 d-flex justify-content-center align-items-center"
|
||||
t-if="column.grandTotal !== 0">
|
||||
<div class="o_grid_bar_chart_total_title">
|
||||
<span class="fs-5 fw-bolder"
|
||||
t-att-class="{
|
||||
'text-danger' : getUnavailableClass(column) === 'o_grid_unavailable' and column.grandTotal gt 0
|
||||
}">
|
||||
<t t-esc="formatValue(column.grandTotal)"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!props.options.hideLineTotal"
|
||||
t-att-data-grid-row="rowPosition"
|
||||
t-att-data-grid-column="props.columns.length + 1 + columnsGap"
|
||||
t-attf-class="o_grid_highlightable position-md-sticky end-0 d-flex align-items-center justify-content-center text-black fw-bold {{ getFooterTotalCellClasses(grandTotal) }}"
|
||||
t-attf-style="grid-row: {{rowPosition}}; grid-column: {{props.columns.length + 1 + columnsGap}};"
|
||||
>
|
||||
<span>
|
||||
<t t-esc="formatValue(grandTotal)"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.barChart">
|
||||
<t t-if="!props.options.hideColumnTotal">
|
||||
<t t-set="rowPosition" t-value="getTotalRowPosition()"/>
|
||||
<t t-set="isEven" t-value="(rowPosition + 1) % 2 !== 0"/>
|
||||
<div class="o_grid_row_barChart"
|
||||
t-att-class="{
|
||||
'bg-view': isEven,
|
||||
'bg-100': !isEven,
|
||||
'z-1 position-md-sticky start-0': !props.model.useSampleModel,
|
||||
}"
|
||||
t-attf-style="grid-row: {{rowPosition + 1}}; grid-column: {{columnsGap}};"/>
|
||||
<t t-set="grandTotal" t-value="0"/>
|
||||
<div t-foreach="props.columns"
|
||||
t-as="column"
|
||||
t-key="column.id"
|
||||
class="o_grid_row o_grid_column_total o_grid_highlightable o_grid_bar_chart_container"
|
||||
t-att-class="{'bg-view': isEven}"
|
||||
t-att-data-grid-row="rowPosition + 1"
|
||||
t-att-data-grid-column="column_index + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition + 1}}; grid-column: {{column_index + 1 + columnsGap}};"
|
||||
>
|
||||
<t t-set="grandTotal" t-value="grandTotal + column.grandTotal"/>
|
||||
<div class="h-100 position-relative"
|
||||
t-if="column.grandTotal !== 0">
|
||||
<div class="o_grid_bar_chart_total_pill position-absolute w-100 border-top border-primary border-2 bg-primary bg-opacity-50" t-if="props.options.hasBarChartTotal"
|
||||
t-att-style="getColumnBarChartHeightStyle(column)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="!props.options.hideLineTotal"
|
||||
class="o_grid_row o_grid_column_total o_grid_row_total o_grid_highlightable position-md-sticky end-0 bg-200"
|
||||
t-att-data-grid-row="rowPosition + 1"
|
||||
t-att-data-grid-column="props.columns.length + 1 + columnsGap"
|
||||
t-attf-style="grid-row: {{rowPosition + 1}}; grid-column: {{props.columns.length + 1 + columnsGap}};"
|
||||
>
|
||||
<div class="h-100 position-relative"
|
||||
t-if="grandTotal !== 0"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="odex30_web_grid.NavigationButtons">
|
||||
<div class="o_grid_navigation_buttons position-md-sticky d-flex gap-2" t-if="props.model.columnFieldIsDate">
|
||||
<button class="btn btn-secondary"
|
||||
data-hotkey="t"
|
||||
type="button"
|
||||
t-on-click="onTodayButtonClick"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary"
|
||||
type="button"
|
||||
t-on-click="onPreviousButtonClick"
|
||||
data-hotkey="p"
|
||||
>
|
||||
<span aria-label="Previous" class="oi oi-arrow-left" role="img" title="Previous"/>
|
||||
</button>
|
||||
<ViewScaleSelector
|
||||
t-if="rangesArray.length"
|
||||
scales="props.ranges"
|
||||
currentScale="props.state.activeRangeName"
|
||||
setScale.bind="onRangeClick"
|
||||
isWeekendVisible="props.state.isWeekendVisible"
|
||||
toggleWeekendVisibility="props.toggleWeekendVisibility"
|
||||
/>
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
t-on-click="onNextButtonClick"
|
||||
data-hotkey="n"
|
||||
>
|
||||
<span aria-label="Next" class="oi oi-arrow-right" role="img" title="Next"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { registry } from "@web/core/registry";
|
||||
import { GridArchParser } from "@odex30_web_grid/views/grid_arch_parser";
|
||||
import { GridController } from "@odex30_web_grid/views/grid_controller";
|
||||
import { GridModel } from "@odex30_web_grid/views/grid_model";
|
||||
import { GridRenderer } from "@odex30_web_grid/views/grid_renderer";
|
||||
|
||||
export const gridView = {
|
||||
type: "grid",
|
||||
ArchParser: GridArchParser,
|
||||
Controller: GridController,
|
||||
Model: GridModel,
|
||||
Renderer: GridRenderer,
|
||||
buttonTemplate: "odex30_web_grid.Buttons",
|
||||
|
||||
props: (genericProps, view) => {
|
||||
const { ArchParser, Model, Renderer, buttonTemplate: viewButtonTemplate } = view;
|
||||
const { arch, relatedModels, resModel, buttonTemplate } = genericProps;
|
||||
return {
|
||||
...genericProps,
|
||||
archInfo: new ArchParser().parse(arch, relatedModels, resModel),
|
||||
buttonTemplate: buttonTemplate || viewButtonTemplate,
|
||||
Model,
|
||||
Renderer,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registry.category('views').add('grid', gridView);
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { hover, queryFirst } from "@odoo/hoot-dom";
|
||||
import { animationFrame, mockDate, runAllTimers } from "@odoo/hoot-mock";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
class Grid extends models.Model {
|
||||
foo_id = fields.Many2one({ string: "Foo", relation: "foo" });
|
||||
date = fields.Date({ string: "Date" });
|
||||
time = fields.Float({
|
||||
string: "Float time field",
|
||||
digits: [2, 1],
|
||||
aggregator: "sum",
|
||||
});
|
||||
|
||||
_records = [{ id: 1, date: "2023-03-20", foo_id: 1, time: 9.1 }];
|
||||
|
||||
_views = {
|
||||
grid: `<grid editable="1">
|
||||
<field name="foo_id" type="row"/>
|
||||
<field name="date" type="col">
|
||||
<range name="day" string="Day" span="day" step="day"/>
|
||||
</field>
|
||||
<field name="time" type="measure" widget="float_time"/>
|
||||
</grid>`,
|
||||
};
|
||||
}
|
||||
|
||||
class Foo extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_records = [{ name: "Foo" }];
|
||||
}
|
||||
|
||||
defineModels([Grid, Foo]);
|
||||
|
||||
test.tags("desktop");
|
||||
test("FloatTimeGridCell in grid view", async () => {
|
||||
mockDate("2023-03-20 00:00:00");
|
||||
onRpc("grid_unavailability", () => {
|
||||
return {};
|
||||
});
|
||||
await mountView({
|
||||
type: "grid",
|
||||
resModel: "grid",
|
||||
});
|
||||
await hover(queryFirst(".o_grid_row .o_grid_cell_readonly"));
|
||||
await runAllTimers();
|
||||
expect(".o_grid_cell").toHaveCount(1, { message: "The component should be mounted" });
|
||||
await animationFrame();
|
||||
expect(".o_grid_cell span").toHaveCount(1, {
|
||||
message: "The component should be readonly once it is mounted",
|
||||
});
|
||||
expect(".o_grid_cell input").toHaveCount(0, {
|
||||
message: "No input should be displayed in the component since it is readonly.",
|
||||
});
|
||||
expect(".o_grid_cell").toHaveText("9:06", {
|
||||
message: "The component should have the value correctly formatted",
|
||||
});
|
||||
await contains(".o_grid_cell").click();
|
||||
await animationFrame();
|
||||
expect(".o_grid_cell input").toHaveCount(1, {
|
||||
message: "The component should be in edit mode.",
|
||||
});
|
||||
expect(".o_grid_cell input").toHaveAttribute("inputmode", "text");
|
||||
expect(".o_grid_cell span").toHaveCount(0, {
|
||||
message: "The component should no longer be in readonly mode.",
|
||||
});
|
||||
await contains(".o_grid_cell input").edit("09:30");
|
||||
expect(".o_grid_cell_readonly").toHaveText("9:30", {
|
||||
message: "The edition should be taken into account.",
|
||||
});
|
||||
expect(".o_grid_component[name='foo_id'] .o_form_uri").toHaveAttribute("href", "/odoo/m-foo/1");
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { hover, press, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame, mockDate, runAllTimers } from "@odoo/hoot-mock";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
fields,
|
||||
models,
|
||||
mountView,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
class Grid extends models.Model {
|
||||
foo_id = fields.Many2one({ string: "Foo", relation: "foo" });
|
||||
date = fields.Date({ string: "Date" });
|
||||
time = fields.Float({
|
||||
string: "Float time field",
|
||||
digits: [2, 1],
|
||||
aggregator: "sum",
|
||||
});
|
||||
|
||||
_records = [
|
||||
{ date: "2023-03-20", foo_id: 1, time: 0.0 },
|
||||
{ date: "2023-03-21", foo_id: 2, time: 0.0 },
|
||||
];
|
||||
|
||||
_views = {
|
||||
grid: /* xml */ `
|
||||
<grid editable="1">
|
||||
<field name="foo_id" type="row"/>
|
||||
<field name="date" type="col">
|
||||
<range name="day" string="Day" span="day" step="day"/>
|
||||
</field>
|
||||
<field name="time" type="measure" widget="float_toggle"/>
|
||||
</grid>`,
|
||||
"grid,1": /* xml */ `
|
||||
<grid editable="1">
|
||||
<field name="foo_id" type="row"/>
|
||||
<field name="date" type="col">
|
||||
<range name="day" string="Day" span="day" step="day"/>
|
||||
<range name="week" string="Week" span="week" step="day" default="1"/>
|
||||
</field>
|
||||
<field name="time" type="measure" widget="float_toggle"/>
|
||||
</grid>`,
|
||||
};
|
||||
}
|
||||
|
||||
class Foo extends models.Model {
|
||||
name = fields.Char();
|
||||
|
||||
_records = [{ name: "Foo" }, { name: "Bar" }];
|
||||
}
|
||||
|
||||
defineModels([Grid, Foo]);
|
||||
|
||||
onRpc("grid_unavailability", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockDate("2023-03-20 00:00:00");
|
||||
});
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("FloatToggleGridCell: click to focus", async () => {
|
||||
await mountView({
|
||||
type: "grid",
|
||||
resModel: "grid",
|
||||
});
|
||||
|
||||
const cell = queryOne(
|
||||
".o_grid_row:not(.o_grid_row_total,.o_grid_row_title,.o_grid_column_total)"
|
||||
);
|
||||
expect(cell).toHaveText("0.00", { message: "Initial cell content should be 0.00" });
|
||||
await hover(cell);
|
||||
await runAllTimers();
|
||||
await contains(".o_grid_cell").click();
|
||||
await animationFrame();
|
||||
expect(".o_grid_cell").toHaveText("0.50", {
|
||||
message: "Clicking on the cell alters the content of the cell and focuses it",
|
||||
});
|
||||
});
|
||||
|
||||
test("FloatToggleGridCell: keyboard navigation", async () => {
|
||||
await mountView({
|
||||
type: "grid",
|
||||
resModel: "grid",
|
||||
viewId: 1,
|
||||
});
|
||||
|
||||
function checkGridCellInRightPlace(expectedGridRow, expectedGridColumn) {
|
||||
const gridCell = queryOne(".o_grid_cell");
|
||||
expect(gridCell.dataset.gridRow).toBe(expectedGridRow);
|
||||
expect(gridCell.dataset.gridColumn).toBe(expectedGridColumn);
|
||||
}
|
||||
|
||||
const firstCell = queryOne(".o_grid_row[data-row='1'][data-column='0']");
|
||||
expect(firstCell.dataset.gridRow).toBe("2");
|
||||
expect(firstCell.dataset.gridColumn).toBe("2");
|
||||
await hover(firstCell);
|
||||
await runAllTimers();
|
||||
expect(".o_grid_cell").toHaveCount(1, {
|
||||
message: "The GridCell component should be mounted on the grid cell hovered",
|
||||
});
|
||||
checkGridCellInRightPlace(firstCell.dataset.gridRow, firstCell.dataset.gridColumn);
|
||||
await contains(".o_grid_cell").click();
|
||||
await animationFrame();
|
||||
expect(".o_grid_cell").toHaveText("0.50", {
|
||||
message: "Clicking on the cell alters the content of the cell and focuses it",
|
||||
});
|
||||
expect(".o_grid_cell button.o_field_float_toggle").toBeFocused({
|
||||
message: "The element focused should be the button of the grid cell",
|
||||
});
|
||||
|
||||
// Go to the next cell
|
||||
await press("tab");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("2", "3");
|
||||
|
||||
// Go to the previous cell
|
||||
await press("shift+tab");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("2", "2");
|
||||
|
||||
// Go the cell below
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("3", "2");
|
||||
|
||||
// Go up since it is the cell in the row
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("2", "3");
|
||||
|
||||
await press("shift+tab");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("2", "2");
|
||||
|
||||
// Go to the last editable cell in the grid view since it is the first cell.
|
||||
await press("shift+tab");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("3", "8");
|
||||
|
||||
// Go back to the first cell since it is the last cell in grid view.
|
||||
await press("tab");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("2", "2");
|
||||
|
||||
// Go to the last editable cell in the grid view since it is the first cell.
|
||||
await press("shift+tab");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("3", "8");
|
||||
|
||||
// Go back to the first cell since it is the last cell in grid view.
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
checkGridCellInRightPlace("2", "2");
|
||||
});
|
||||
|
||||
test("FloatToggleGridCell: click on magnifying glass", async () => {
|
||||
await mountView({
|
||||
type: "grid",
|
||||
resModel: "grid",
|
||||
});
|
||||
|
||||
const cell = queryOne(
|
||||
".o_grid_row:not(.o_grid_row_total,.o_grid_row_title,.o_grid_column_total)"
|
||||
);
|
||||
expect(cell).toHaveText("0.00", { message: "Initial cell content should be 0.00" });
|
||||
await hover(cell);
|
||||
await runAllTimers();
|
||||
await contains(".o_grid_search_btn").click();
|
||||
expect(".o_grid_cell").toHaveText("0.00", {
|
||||
message: "Clicking on the magnifying glass shouldn't alter the content of the cell",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { onRpc } from "@web/../tests/web_test_helpers";
|
||||
|
||||
onRpc("grid_update_cell", function gridUpdateCell({ args, kwargs, model }) {
|
||||
const [domain, fieldNameToUpdate, value] = args;
|
||||
const records = this.env[model].search_read(domain, [fieldNameToUpdate], kwargs);
|
||||
if (records.length > 1) {
|
||||
this.env[model].copy(records[0].id, { [fieldNameToUpdate]: value });
|
||||
} else if (records.length === 1) {
|
||||
const record = records[0];
|
||||
this.env[model].write(record.id, {
|
||||
[fieldNameToUpdate]: record[fieldNameToUpdate] + value,
|
||||
});
|
||||
} else {
|
||||
this.env[model].create({ [fieldNameToUpdate]: value }, kwargs);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,16 @@
|
|||
/** @odoo-module alias=@odex30_web_grid/../tests/helpers default=false */
|
||||
|
||||
import { nextTick, triggerEvents } from "@web/../tests/helpers/utils";
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} cell
|
||||
*/
|
||||
export async function hoverGridCell(cell) {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const evAttrs = {
|
||||
clientX: rect.x,
|
||||
clientY: rect.y,
|
||||
};
|
||||
await triggerEvents(cell, null, ["mouseover", ["mousemove", evAttrs]]);
|
||||
await nextTick();
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import grid_view_validation
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from odoo.tools import misc, view_validation
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_grid_validator = None
|
||||
|
||||
|
||||
@view_validation.validate('grid')
|
||||
def schema_grid(arch, **kwargs):
|
||||
|
||||
global _grid_validator
|
||||
|
||||
if _grid_validator is None:
|
||||
with misc.file_open(os.path.join('odex30_web_grid', 'views', 'grid.rng')) as f:
|
||||
_grid_validator = etree.RelaxNG(etree.parse(f))
|
||||
|
||||
if _grid_validator.validate(arch):
|
||||
return True
|
||||
|
||||
for error in _grid_validator.error_log:
|
||||
_logger.error("%s", error)
|
||||
return False
|
||||
|
||||
|
||||
@view_validation.validate('grid')
|
||||
def valid_field_types(arch, **kwargs):
|
||||
|
||||
types = {'col', 'measure', 'readonly'}
|
||||
for f in arch.iterdescendants('field'):
|
||||
field_type = f.get('type')
|
||||
if field_type == 'row':
|
||||
continue
|
||||
|
||||
if field_type in types:
|
||||
types.remove(field_type)
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
<grammar xmlns="http://relaxng.org/ns/structure/1.0"
|
||||
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
|
||||
<start>
|
||||
<ref name="grid"/>
|
||||
</start>
|
||||
<define name="grid">
|
||||
<element name="grid">
|
||||
<ref name="acl"/>
|
||||
<attribute name="string"/>
|
||||
<optional>
|
||||
<attribute name="editable"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="class"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="create_inline"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="display_empty"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="js_class"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="hide_column_total"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="hide_line_total"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="barchart_total"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="action"/>
|
||||
<attribute name="type">
|
||||
<choice>
|
||||
<value>object</value>
|
||||
<value>action</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="form_view_id"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="sample"/>
|
||||
</optional>
|
||||
<interleave>
|
||||
<oneOrMore><ref name="field"/></oneOrMore>
|
||||
<zeroOrMore><ref name="button"/></zeroOrMore>
|
||||
<zeroOrMore><ref name="xpath"/></zeroOrMore>
|
||||
<optional>
|
||||
<element name="empty">
|
||||
<oneOrMore>
|
||||
<element name="p">
|
||||
<optional>
|
||||
<attribute name="class"/>
|
||||
</optional>
|
||||
<interleave>
|
||||
<text/>
|
||||
<zeroOrMore>
|
||||
<ref name="link"/>
|
||||
</zeroOrMore>
|
||||
<zeroOrMore>
|
||||
<ref name="image"/>
|
||||
</zeroOrMore>
|
||||
</interleave>
|
||||
</element>
|
||||
</oneOrMore>
|
||||
</element>
|
||||
</optional>
|
||||
</interleave>
|
||||
</element>
|
||||
</define>
|
||||
<define name="overload">
|
||||
<optional>
|
||||
<!--
|
||||
Alter matched element with content
|
||||
-->
|
||||
<choice>
|
||||
<attribute name="position">
|
||||
<choice>
|
||||
<!-- Insert content before first child -->
|
||||
<value>before</value>
|
||||
<!-- Insert content after last child -->
|
||||
<value>after</value>
|
||||
<!-- Replace all children with content -->
|
||||
<value>inside</value>
|
||||
<!-- Replace matched element itself with content -->
|
||||
<value>replace</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
<group>
|
||||
<attribute name="position">
|
||||
<!-- Edit element attributes -->
|
||||
<value>attributes</value>
|
||||
</attribute>
|
||||
<oneOrMore>
|
||||
<element name="attribute">
|
||||
<attribute name="name"><text/></attribute>
|
||||
<text />
|
||||
</element>
|
||||
</oneOrMore>
|
||||
</group>
|
||||
</choice>
|
||||
</optional>
|
||||
</define>
|
||||
<define name="any">
|
||||
<element>
|
||||
<anyName/>
|
||||
<zeroOrMore>
|
||||
<choice>
|
||||
<attribute>
|
||||
<anyName/>
|
||||
</attribute>
|
||||
<text/>
|
||||
<ref name="any"/>
|
||||
</choice>
|
||||
</zeroOrMore>
|
||||
</element>
|
||||
</define>
|
||||
<define name="xpath">
|
||||
<element name="xpath">
|
||||
<optional><attribute name="expr"/></optional>
|
||||
<ref name="overload"/>
|
||||
<zeroOrMore>
|
||||
<choice>
|
||||
<ref name="any"/>
|
||||
<ref name="button"/>
|
||||
</choice>
|
||||
</zeroOrMore>
|
||||
</element>
|
||||
</define>
|
||||
<define name="field">
|
||||
<element name="field">
|
||||
<attribute name="name"/>
|
||||
<choice>
|
||||
<group>
|
||||
<attribute name="type"><value>row</value></attribute>
|
||||
<optional>
|
||||
<attribute name="section">
|
||||
<value>1</value>
|
||||
</attribute>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="widget"></attribute>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="invisible"/>
|
||||
</optional>
|
||||
</group>
|
||||
<group>
|
||||
<attribute name="type"><value>col</value></attribute>
|
||||
<optional><attribute name="operator"/></optional>
|
||||
<zeroOrMore>
|
||||
<element name="range">
|
||||
<attribute name="name"/>
|
||||
<attribute name="string"/>
|
||||
<attribute name="span">
|
||||
<choice>
|
||||
<value>day</value>
|
||||
<value>week</value>
|
||||
<value>month</value>
|
||||
<value>year</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
<attribute name="step">
|
||||
<choice>
|
||||
<value>day</value>
|
||||
<value>month</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
<optional><attribute name="default"/></optional>
|
||||
<optional><attribute name="hotkey"/></optional>
|
||||
</element>
|
||||
</zeroOrMore>
|
||||
</group>
|
||||
<group>
|
||||
<attribute name="type"><value>measure</value></attribute>
|
||||
<optional>
|
||||
<attribute name="widget"/>
|
||||
<optional>
|
||||
<attribute name="options"/>
|
||||
</optional>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="string"/>
|
||||
</optional>
|
||||
<optional><attribute name="operator"/></optional>
|
||||
</group>
|
||||
<group>
|
||||
<attribute name="type"><value>readonly</value></attribute>
|
||||
<optional><attribute name="operator"/></optional>
|
||||
</group>
|
||||
<group>
|
||||
<ref name="overload"/>
|
||||
<zeroOrMore>
|
||||
<choice>
|
||||
<ref name="field"/>
|
||||
<ref name="button"/>
|
||||
</choice>
|
||||
</zeroOrMore>
|
||||
</group>
|
||||
</choice>
|
||||
<!-- other garbage -->
|
||||
<optional><attribute name="on_change"/></optional>
|
||||
<optional><attribute name="modifiers"/></optional>
|
||||
<optional><attribute name="can_create"/></optional>
|
||||
<optional><attribute name="can_write"/></optional>
|
||||
</element>
|
||||
</define>
|
||||
<define name="button">
|
||||
<element name="button">
|
||||
<attribute name="string"/>
|
||||
<attribute name="type">
|
||||
<choice>
|
||||
<value>object</value>
|
||||
<value>action</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
<!-- method name or action id -->
|
||||
<attribute name="name"/>
|
||||
<optional><attribute name="class"/></optional>
|
||||
<optional><attribute name="data-hotkey"/></optional>
|
||||
<!-- Python dict literal -->
|
||||
<optional><attribute name="context"/></optional>
|
||||
<optional><attribute name="invisible"/></optional>
|
||||
</element>
|
||||
</define>
|
||||
<define name="acl">
|
||||
<optional>
|
||||
<attribute name="create">
|
||||
<choice>
|
||||
<value>true</value>
|
||||
<value>false</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="edit">
|
||||
<choice>
|
||||
<value>true</value>
|
||||
<value>false</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="delete">
|
||||
<choice>
|
||||
<value>true</value>
|
||||
<value>false</value>
|
||||
</choice>
|
||||
</attribute>
|
||||
</optional>
|
||||
</define>
|
||||
<define name="image">
|
||||
<element name="img">
|
||||
<attribute name="src"/>
|
||||
<optional>
|
||||
<attribute name="class"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="alt"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="style"/>
|
||||
</optional>
|
||||
</element>
|
||||
</define>
|
||||
<define name="link">
|
||||
<element name="a">
|
||||
<attribute name="href"/>
|
||||
<optional>
|
||||
<attribute name="class"/>
|
||||
</optional>
|
||||
<optional>
|
||||
<attribute name="target"/>
|
||||
</optional>
|
||||
<mixed>
|
||||
<zeroOrMore>
|
||||
<ref name="image"/>
|
||||
</zeroOrMore>
|
||||
</mixed>
|
||||
</element>
|
||||
</define>
|
||||
</grammar>
|
||||
|
|
@ -20,14 +20,14 @@
|
|||
|
||||
'assets': {
|
||||
'web.assets_backend_lazy': [
|
||||
'web_map/static/src/**/*',
|
||||
'odex30_web_map/static/src/**/*',
|
||||
],
|
||||
'web.assets_unit_tests': [
|
||||
'web_map/static/lib/**/*',
|
||||
'web_map/static/tests/**/*',
|
||||
'odex30_web_map/static/lib/**/*',
|
||||
'odex30_web_map/static/tests/**/*',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'web_map/static/lib/**/*',
|
||||
'odex30_web_map/static/lib/**/*',
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ export class MapController extends Component {
|
|||
|
||||
onWillStart(() =>
|
||||
Promise.all([
|
||||
loadJS("/web_map/static/lib/leaflet/leaflet.js"),
|
||||
loadCSS("/web_map/static/lib/leaflet/leaflet.css"),
|
||||
loadJS("/odex30_web_map/static/lib/leaflet/leaflet.js"),
|
||||
loadCSS("/odex30_web_map/static/lib/leaflet/leaflet.css"),
|
||||
])
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue