fix bug and make web grid modul

This commit is contained in:
mohammed-alkhazrji 2026-01-04 23:16:13 +03:00
parent d4ea266ffd
commit 768747ca18
41 changed files with 6744 additions and 6 deletions

View File

@ -0,0 +1,3 @@
from . import models
from . import tools

View File

@ -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,
}

View File

@ -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 "نوع واجهة العرض"

View File

@ -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

View File

@ -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'})

View File

@ -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

View File

@ -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 {}

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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);

View File

@ -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>

View File

@ -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]
);
}

View File

@ -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;
}

View File

@ -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 };
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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};
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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 &gt; 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 &gt;= 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>

View File

@ -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);

View File

@ -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");
});

View File

@ -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",
});
});

View File

@ -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

View File

@ -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();
}

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import grid_view_validation

View File

@ -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

View File

@ -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>

View File

@ -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/**/*',
],
},

View File

@ -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"),
])
);