From 768747ca18e869970fdec3ba64484714881d4716 Mon Sep 17 00:00:00 2001 From: mohammed-alkhazrji Date: Sun, 4 Jan 2026 23:16:13 +0300 Subject: [PATCH] fix bug and make web grid modul --- odex30_base/odex30_web_grid/__init__.py | 3 + odex30_base/odex30_web_grid/__manifest__.py | 30 + odex30_base/odex30_web_grid/i18n/ar.po | 133 + .../odex30_web_grid/models/__init__.py | 6 + .../odex30_web_grid/models/ir_actions.py | 8 + .../odex30_web_grid/models/ir_ui_view.py | 25 + odex30_base/odex30_web_grid/models/models.py | 16 + .../src/components/float_factor_grid_cell.js | 38 + .../src/components/float_time_grid_cell.js | 29 + .../src/components/float_toggle_grid_cell.js | 95 + .../src/components/float_toggle_grid_cell.xml | 15 + .../static/src/components/grid_cell.js | 170 ++ .../static/src/components/grid_cell.xml | 25 + .../static/src/components/grid_component.js | 36 + .../static/src/components/grid_component.xml | 8 + .../static/src/components/grid_row.js | 39 + .../static/src/components/grid_row.xml | 11 + .../src/components/many2one_grid_row.js | 70 + .../src/components/many2one_grid_row.xml | 32 + .../static/src/hooks/grid_cell_hook.js | 42 + .../static/src/hooks/input_hook.js | 151 ++ .../static/src/views/grid_arch_parser.js | 177 ++ .../static/src/views/grid_controller.js | 189 ++ .../static/src/views/grid_controller.scss | 18 + .../static/src/views/grid_controller.xml | 75 + .../static/src/views/grid_model.js | 1146 ++++++++ .../static/src/views/grid_renderer.dark.scss | 27 + .../static/src/views/grid_renderer.js | 664 +++++ .../static/src/views/grid_renderer.scss | 103 + .../static/src/views/grid_renderer.xml | 377 +++ .../static/src/views/grid_view.js | 28 + .../grid_cells/float_time_grid_cell.test.js | 80 + .../grid_cells/float_toggle_grid_cell.test.js | 177 ++ .../static/tests/grid_mock_server.js | 17 + .../static/tests/grid_view.test.js | 2326 +++++++++++++++++ .../static/tests/legacy/helpers.js | 16 + odex30_base/odex30_web_grid/tools/__init__.py | 4 + .../tools/grid_view_validation.py | 45 + odex30_base/odex30_web_grid/views/grid.rng | 287 ++ odex30_base/odex30_web_map/__manifest__.py | 8 +- .../static/src/map_view/map_controller.js | 4 +- 41 files changed, 6744 insertions(+), 6 deletions(-) create mode 100644 odex30_base/odex30_web_grid/__init__.py create mode 100644 odex30_base/odex30_web_grid/__manifest__.py create mode 100644 odex30_base/odex30_web_grid/i18n/ar.po create mode 100644 odex30_base/odex30_web_grid/models/__init__.py create mode 100644 odex30_base/odex30_web_grid/models/ir_actions.py create mode 100644 odex30_base/odex30_web_grid/models/ir_ui_view.py create mode 100644 odex30_base/odex30_web_grid/models/models.py create mode 100644 odex30_base/odex30_web_grid/static/src/components/float_factor_grid_cell.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/float_time_grid_cell.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.xml create mode 100644 odex30_base/odex30_web_grid/static/src/components/grid_cell.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/grid_cell.xml create mode 100644 odex30_base/odex30_web_grid/static/src/components/grid_component.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/grid_component.xml create mode 100644 odex30_base/odex30_web_grid/static/src/components/grid_row.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/grid_row.xml create mode 100644 odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.js create mode 100644 odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.xml create mode 100644 odex30_base/odex30_web_grid/static/src/hooks/grid_cell_hook.js create mode 100644 odex30_base/odex30_web_grid/static/src/hooks/input_hook.js create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_arch_parser.js create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_controller.js create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_controller.scss create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_controller.xml create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_model.js create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_renderer.dark.scss create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_renderer.js create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_renderer.scss create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_renderer.xml create mode 100644 odex30_base/odex30_web_grid/static/src/views/grid_view.js create mode 100644 odex30_base/odex30_web_grid/static/tests/grid_cells/float_time_grid_cell.test.js create mode 100644 odex30_base/odex30_web_grid/static/tests/grid_cells/float_toggle_grid_cell.test.js create mode 100644 odex30_base/odex30_web_grid/static/tests/grid_mock_server.js create mode 100644 odex30_base/odex30_web_grid/static/tests/grid_view.test.js create mode 100644 odex30_base/odex30_web_grid/static/tests/legacy/helpers.js create mode 100644 odex30_base/odex30_web_grid/tools/__init__.py create mode 100644 odex30_base/odex30_web_grid/tools/grid_view_validation.py create mode 100644 odex30_base/odex30_web_grid/views/grid.rng diff --git a/odex30_base/odex30_web_grid/__init__.py b/odex30_base/odex30_web_grid/__init__.py new file mode 100644 index 0000000..0b50506 --- /dev/null +++ b/odex30_base/odex30_web_grid/__init__.py @@ -0,0 +1,3 @@ + +from . import models +from . import tools diff --git a/odex30_base/odex30_web_grid/__manifest__.py b/odex30_base/odex30_web_grid/__manifest__.py new file mode 100644 index 0000000..737a837 --- /dev/null +++ b/odex30_base/odex30_web_grid/__manifest__.py @@ -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, + +} diff --git a/odex30_base/odex30_web_grid/i18n/ar.po b/odex30_base/odex30_web_grid/i18n/ar.po new file mode 100644 index 0000000..9836585 --- /dev/null +++ b/odex30_base/odex30_web_grid/i18n/ar.po @@ -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 , 2022 +# "Tiffany Chang (tic)" , 2025. +# "Dylan Kiss (dyki)" , 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 "نوع واجهة العرض" diff --git a/odex30_base/odex30_web_grid/models/__init__.py b/odex30_base/odex30_web_grid/models/__init__.py new file mode 100644 index 0000000..e4a6762 --- /dev/null +++ b/odex30_base/odex30_web_grid/models/__init__.py @@ -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 diff --git a/odex30_base/odex30_web_grid/models/ir_actions.py b/odex30_base/odex30_web_grid/models/ir_actions.py new file mode 100644 index 0000000..fc5b627 --- /dev/null +++ b/odex30_base/odex30_web_grid/models/ir_actions.py @@ -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'}) diff --git a/odex30_base/odex30_web_grid/models/ir_ui_view.py b/odex30_base/odex30_web_grid/models/ir_ui_view.py new file mode 100644 index 0000000..572e63d --- /dev/null +++ b/odex30_base/odex30_web_grid/models/ir_ui_view.py @@ -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 diff --git a/odex30_base/odex30_web_grid/models/models.py b/odex30_base/odex30_web_grid/models/models.py new file mode 100644 index 0000000..d16f16e --- /dev/null +++ b/odex30_base/odex30_web_grid/models/models.py @@ -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 {} diff --git a/odex30_base/odex30_web_grid/static/src/components/float_factor_grid_cell.js b/odex30_base/odex30_web_grid/static/src/components/float_factor_grid_cell.js new file mode 100644 index 0000000..02ef96b --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/float_factor_grid_cell.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/src/components/float_time_grid_cell.js b/odex30_base/odex30_web_grid/static/src/components/float_time_grid_cell.js new file mode 100644 index 0000000..b336139 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/float_time_grid_cell.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.js b/odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.js new file mode 100644 index 0000000..e546f89 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.xml b/odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.xml new file mode 100644 index 0000000..5d1a522 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/float_toggle_grid_cell.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/odex30_base/odex30_web_grid/static/src/components/grid_cell.js b/odex30_base/odex30_web_grid/static/src/components/grid_cell.js new file mode 100644 index 0000000..74b6d0e --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/grid_cell.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/src/components/grid_cell.xml b/odex30_base/odex30_web_grid/static/src/components/grid_cell.xml new file mode 100644 index 0000000..f1c92cf --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/grid_cell.xml @@ -0,0 +1,25 @@ + + + +
+ +
+ + +
+
+
+
diff --git a/odex30_base/odex30_web_grid/static/src/components/grid_component.js b/odex30_base/odex30_web_grid/static/src/components/grid_component.js new file mode 100644 index 0000000..8ab32c0 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/grid_component.js @@ -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; + } +} diff --git a/odex30_base/odex30_web_grid/static/src/components/grid_component.xml b/odex30_base/odex30_web_grid/static/src/components/grid_component.xml new file mode 100644 index 0000000..990a0ef --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/grid_component.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/odex30_base/odex30_web_grid/static/src/components/grid_row.js b/odex30_base/odex30_web_grid/static/src/components/grid_row.js new file mode 100644 index 0000000..8fe5e6c --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/grid_row.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/src/components/grid_row.xml b/odex30_base/odex30_web_grid/static/src/components/grid_row.xml new file mode 100644 index 0000000..5b1c43c --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/grid_row.xml @@ -0,0 +1,11 @@ + + + + +
+ + None +
+
+ +
diff --git a/odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.js b/odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.js new file mode 100644 index 0000000..3e8b4f6 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.xml b/odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.xml new file mode 100644 index 0000000..3845777 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/components/many2one_grid_row.xml @@ -0,0 +1,32 @@ + + + + +
+ + + + +
+ +
+
+
+ + + + +
+ +
+
+
+ None +
+
+ +
diff --git a/odex30_base/odex30_web_grid/static/src/hooks/grid_cell_hook.js b/odex30_base/odex30_web_grid/static/src/hooks/grid_cell_hook.js new file mode 100644 index 0000000..01124ed --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/hooks/grid_cell_hook.js @@ -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] + ); +} diff --git a/odex30_base/odex30_web_grid/static/src/hooks/input_hook.js b/odex30_base/odex30_web_grid/static/src/hooks/input_hook.js new file mode 100644 index 0000000..4dc6984 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/hooks/input_hook.js @@ -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; +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_arch_parser.js b/odex30_base/odex30_web_grid/static/src/views/grid_arch_parser.js new file mode 100644 index 0000000..ba644b4 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_arch_parser.js @@ -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 }; + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_controller.js b/odex30_base/odex30_web_grid/static/src/views/grid_controller.js new file mode 100644 index 0000000..26b195f --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_controller.js @@ -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); + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_controller.scss b/odex30_base/odex30_web_grid/static/src/views/grid_controller.scss new file mode 100644 index 0000000..b4b8230 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_controller.scss @@ -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; + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_controller.xml b/odex30_base/odex30_web_grid/static/src/views/grid_controller.xml new file mode 100644 index 0000000..45f9abf --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_controller.xml @@ -0,0 +1,75 @@ + + + +
+
+ +
+ +
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_model.js b/odex30_base/odex30_web_grid/static/src/views/grid_model.js new file mode 100644 index 0000000..9cd8441 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_model.js @@ -0,0 +1,1146 @@ +import { KeepLast, Mutex } from "@web/core/utils/concurrency"; +import { useService } from "@web/core/utils/hooks"; +import { Domain } from "@web/core/domain"; +import { serializeDate } from "@web/core/l10n/dates"; +import { localization } from "@web/core/l10n/localization"; +import { _t } from "@web/core/l10n/translation"; +import { pyToJsLocale } from "@web/core/l10n/utils"; +import { Model } from "@web/model/model"; +import { browser } from "@web/core/browser/browser"; + +const { DateTime, Interval } = luxon; + +export class GridCell { + /** + * Constructor + * + * @param model {GridModel} the grid model. + * @param row {GridRow} the grid row linked to the cell. + * @param column {GridColumn} the grid column linked to the cell. + * @param value {Number} the value of the cell. + * @param isHovered {Boolean} is the cell in a hover state? + */ + constructor(model, row, column, value = 0, isHovered = false) { + this.row = row; + this.column = column; + this.model = model; + this.value = value; + this.isHovered = isHovered; + this._readonly = false; + this.column.addCell(this); + } + + get readonly() { + return this._readonly || this.column.readonly; + } + + /** + * Get the domain of the cell, it will be the domain of row AND the one of the column associated + * + * @return {Domain} the domain of the cell + */ + get domain() { + const domains = [this.model.searchParams.domain, this.row.domain, this.column.domain]; + return Domain.and(domains); + } + + /** + * Get the context to get the default values + */ + get context() { + return { + ...(this.model.searchParams.context || {}), + ...this.row.section?.context, + ...this.row.context, + ...this.column.context, + }; + } + + get title() { + const rowTitle = + !this.row.section || this.row.section.isFake + ? this.row.title + : `${this.row.section.title} / ${this.row.title}`; + const columnTitle = this.column.title; + return `${rowTitle} (${columnTitle})`; + } + + /** + * Update the grid cell according to the value set by the current user. + * + * @param {Number} value the value entered by the current user. + */ + async update(value) { + return this.model.mutex.exec(async () => { + await this._update(value); + }); + } + + async _update(value) { + const oldValue = this.value; + const result = await this.model.orm.call( + this.model.resModel, + "grid_update_cell", + [this.domain.toList({}), this.model.measureFieldName, value - oldValue], + { context: this.context } + ); + if (result) { + this.model.actionService.doAction(result); + return; + } + this.row.updateCell(this.column, value, this.model.data); + this.model.notify(); + } +} + +export class GridRow { + /** + * Constructor + * + * @param domain {Domain} the domain of the row. + * @param valuePerFieldName {{string: string}} the list of to display the label of the row. + * @param model {GridModel} the grid model. + * @param section {GridSection} the section of the grid. + * @param columns {GridColumn[]} the columns of the grid. + */ + constructor(data, domain, valuePerFieldName, model, section, isAdditionalRow = false) { + this._domain = domain; + this.model = model; + this.cells = {}; + this.valuePerFieldName = valuePerFieldName; + this.id = model.rowId++; + this.section = section; + if (section) { + this.section.addRow(this); + } + this.grandTotal = 0; + this.grandTotalWeekendHidden = 0; + this.isAdditionalRow = isAdditionalRow; + this._generateCells(data); + } + + get initialRecordValues() { + return this.valuePerFieldName; + } + + get title() { + const labelArray = []; + for (const rowField of this.model.rowFields) { + let title = this.valuePerFieldName[rowField.name]; + if (this.model.fieldsInfo[rowField.name].type === "many2one") { + if (title) { + title = title[1]; + } else if (labelArray.length) { + title = ""; + } else { + title = "None"; + } + } + if (title) { + labelArray.push(title); + } + } + return labelArray.join(" / "); + } + + get domain() { + if (this.section.isFake) { + return this._domain; + } + return Domain.and([this.section.domain, this._domain]); + } + + get context() { + const context = {}; + const getValue = (fieldName, value) => + this.model.fieldsInfo[fieldName].type === "many2one" ? value && value[0] : value; + for (const [key, value] of Object.entries(this.valuePerFieldName)) { + context[`default_${key}`] = getValue(key, value); + } + return context; + } + + getSection() { + return !this.section.isFake && this.section; + } + + /** + * Generate the cells for each column that is present in the row. + * @private + */ + _generateCells(data) { + for (const column of Object.values(data.columns)) { + this.cells[column.id] = new this.model.constructor.Cell(this.model, this, column, 0); + } + } + + _ensureColumnExist(column, data) { + if (!(column.id in data.columns)) { + throw new Error("Unbound index: the columnId is not in the row columns"); + } + return true; + } + + /** + * Update the cell value of a cell. + * @param {GridColumn} column containing the cell to update. + * @param {number} value the value to update + */ + updateCell(column, value, data) { + this._ensureColumnExist(column, data); + const cell = this.cells[column.id]; + const oldValue = cell.value; + cell.value = value; + const delta = value - oldValue; + this.section.updateGrandTotal(column, delta); + this.grandTotal += delta; + this.grandTotalWeekendHidden += column.isWeekDay ? delta : 0; + column.grandTotal += delta; + if (this.isAdditionalRow && delta > 0) { + this.isAdditionalRow = false; + } + } + + setReadonlyCell(column, readonly, data) { + this._ensureColumnExist(column, data); + if (readonly instanceof Array) { + readonly = readonly.length > 0; + } else if (!(readonly instanceof Boolean)) { + readonly = Boolean(readonly); + } + this.cells[column.id]._readonly = readonly; + } + + getGrandTotal(showWeekend) { + return showWeekend ? this.grandTotal : this.grandTotalWeekendHidden; + } +} + +export class GridSection extends GridRow { + constructor() { + super(...arguments); + this.sectionId = this.model.sectionId++; + this.rows = {}; + this.isSection = true; + this.lastRow = null; + } + + get value() { + return this.valuePerFieldName && this.valuePerFieldName[this.model.sectionField.name]; + } + + get domain() { + let value = this.value; + if (this.model.fieldsInfo[this.model.sectionField.name].type === "many2one") { + value = value && value[0]; + } + return new Domain([[this.model.sectionField.name, "=", value]]); + } + + get title() { + let title = this.value; + if ( + this.model.sectionField && + this.model.fieldsInfo[this.model.sectionField.name].type === "many2one" + ) { + title = (title && title[1]) || "None"; + } + return title; + } + + get initialRecordValues() { + return { [this.model.sectionField.name]: this.value }; + } + + get isFake() { + return this.value == null; + } + + get context() { + const context = {}; + const getValue = (fieldName, value) => + this.model.fieldsInfo[fieldName].type === "many2one" ? value && value[0] : value; + + if (!this.isFake) { + const sectionFieldName = this.model.sectionField.name; + context[`default_${sectionFieldName}`] = getValue(sectionFieldName, this.value); + } + return context; + } + + getSection() { + return !this.isFake && this; + } + + /** + * Add row to the section rows. + * @param row {GridRow} the row to add. + */ + addRow(row) { + if (row.id in this.rows) { + throw new Error("Row already added in section"); + } + this.rows[row.id] = row; + this.lastRow = row; + } + + /** + * Update the grand totals according to the provided column and delta. + * @param column {GridColumn} the column the grand total has to be updated for. + * @param delta {Number} the delta to apply on the grand totals. + */ + updateGrandTotal(column, delta) { + this.cells[column.id].value += delta; + this.grandTotal += delta; + this.grandTotalWeekendHidden += column.isWeekDay ? delta : 0; + } +} + +export class GridColumn { + /** + * Constructor + * + * @param model {GridModel} the grid model. + * @param title {string} the title of the column to display. + */ + constructor(model, title, value, readonly = false) { + this.model = model; + this.title = title; + this.value = value; + this.cells = []; + this.id = model.columnId++; + this.grandTotal = 0; + this.readonly = readonly; + } + + /** + * Add the cell to the column cells. + * @param cell {GridCell} the cell to add. + */ + addCell(cell) { + if (cell.id in this.cells) { + throw new Error("Cell already added in column"); + } + this.cells.push(cell); + this.grandTotal += cell.value; + } + + get domain() { + return new Domain([[this.model.columnFieldName, "=", this.value]]); + } + + get context() { + return { [`default_${this.model.columnFieldName}`]: this.value }; + } +} + +export class DateGridColumn extends GridColumn { + /** + * Constructor + * + * @param model {GridModel} the grid model. + * @param title {string} the title of the column to display. + * @param dateStart {String} the date start serialized + * @param dateEnd {String} the date end serialized + * @param isToday {Boolean} is the date column representing today? + */ + constructor(model, title, dateStart, dateEnd, isToday, isWeekDay, readonly = false) { + super(model, title, dateStart, readonly); + this.dateEnd = dateEnd; + this.isToday = isToday; + this.isWeekDay = isWeekDay; + } + + get domain() { + return new Domain([ + "&", + [this.model.columnFieldName, ">=", this.value], + [this.model.columnFieldName, "<", this.dateEnd], + ]); + } +} + +export class GridNavigationInfo { + constructor(anchor, model) { + this.anchor = anchor; + this.model = model; + } + + get _targetWeekday() { + const firstDayOfWeek = localization.weekStart; + return this.anchor.weekday < firstDayOfWeek ? firstDayOfWeek - 7 : firstDayOfWeek; + } + + get periodStart() { + if (this.range.span !== "week") { + return this.anchor.startOf(this.range.span); + } + // Luxon's default is monday to monday week so we need to change its behavior. + return this.anchor.set({ weekday: this._targetWeekday }).startOf("day"); + } + + get periodEnd() { + if (this.range.span !== "week") { + return this.anchor.endOf(this.range.span); + } + // Luxon's default is monday to monday week so we need to change its behavior. + return this.anchor + .set({ weekday: this._targetWeekday }) + .plus({ weeks: 1, days: -1 }) + .endOf("day"); + } + + get interval() { + return Interval.fromDateTimes(this.periodStart, this.periodEnd); + } + + contains(date) { + return this.interval.contains(date.startOf("day")); + } +} + +export class GridModel extends Model { + static Cell = GridCell; + static Column = GridColumn; + static DateColumn = DateGridColumn; + static Row = GridRow; + static Section = GridSection; + static NavigationInfo = GridNavigationInfo; + + setup(params) { + this.notificationService = useService("notification"); + this.actionService = useService("action"); + this.keepLast = new KeepLast(); + this.mutex = new Mutex(); + this.defaultSectionField = params.sectionField; + this.defaultRowFields = params.rowFields; + this.sectionField = undefined; + this.rowFields = []; + this.searchParams = {}; + this.resModel = params.resModel; + this.fieldsInfo = params.fieldsInfo; + this.columnFieldName = params.columnFieldName; + this.columnFieldIsDate = this.fieldsInfo[params.columnFieldName].type === "date"; + this.measureField = params.measureField; + this.readonlyField = params.readonlyField; + this.ranges = params.ranges; + this.defaultAnchor = params.defaultAnchor || this.today; + this.navigationInfo = new this.constructor.NavigationInfo(this.defaultAnchor, this); + this.data = undefined; + this.record = undefined; + const activeRangeName = + browser.localStorage.getItem(this.storageKey) || params.activeRangeName; + if (Object.keys(this.ranges).length && activeRangeName) { + this.navigationInfo.range = this.ranges[activeRangeName]; + } + this._resetGridComponentsId(); + } + + get aggregates() { + const aggregates = [this.measureGroupByFieldName, "id:array_agg"]; + if (this.readonlyField) { + aggregates.push(`${this.readonlyField.name}:${this.readonlyField.aggregator}`); + } + return aggregates; + } + + get today() { + return DateTime.local().startOf("day"); + } + + get sectionsArray() { + return Object.values(this.data.sections); + } + + get rowsArray() { + return Object.values(this.data.rows); + } + + get columnsArray() { + return Object.values(this.data.columns); + } + + get itemsArray() { + return this.data.items; + } + + get maxColumnsTotal() { + return Math.max(...this.columnsArray.map((c) => c.grandTotal)); + } + + get measureFieldName() { + return this.measureField.name; + } + + get measureGroupByFieldName() { + if (this.measureField.aggregator) { + return `${this.measureFieldName}:${this.measureField.aggregator}`; + } + return this.measureFieldName; + } + + get storageKey() { + return `scaleOf-viewId-${this.env.config.viewId}`; + } + + get columnGroupByFieldName() { + let columnGroupByFieldName = this.columnFieldName; + if (this.columnFieldIsDate) { + columnGroupByFieldName += `:${this.navigationInfo.range.step}`; + } + return columnGroupByFieldName; + } + + isToday(date) { + return date.startOf("day").equals(this.today.startOf("day")); + } + + /** + * Get fields to use in the group by or in fields of the read_group + * @private + * @params {Object} metaData + * @return {string[]} list of fields name. + */ + _getGroupByFields({ rowFields, sectionField } = this.metaData) { + const fields = [this.columnGroupByFieldName, ...rowFields.map((r) => r.name)]; + if (sectionField) { + fields.push(sectionField.name); + } + return fields; + } + + _getDateColumnTitle(date) { + const granularity = this.navigationInfo.range.step; + if (!["day", "month"].includes(granularity)) { + return serializeDate(date); + } + const locale = pyToJsLocale(this.navigationInfo.anchor.locale); + + const options = { + day: { weekday: "short", month: "short", day: "numeric" }, + month: { month: "long", year: "numeric" }, + }[granularity]; + + const parts = new Intl.DateTimeFormat(locale, options).formatToParts(date); + + const splitAfter = granularity === "day" ? "weekday" : "month"; + + let splitIndex = parts.findIndex((p) => p.type === splitAfter); + if (splitIndex === -1) { + return parts.map((p) => p.value).join(""); + } + + // split after the first literal + while (++splitIndex < parts.length) { + if (parts[splitIndex].type === "literal") { + break; + } + } + + const firstLineParts = parts.slice(0, splitIndex + 1); + const secondLineParts = parts.slice(splitIndex + 1); + + const firstLine = firstLineParts + .map((p) => p.value) + .join("") + .trim(); + const secondLine = secondLineParts + .map((p) => p.value) + .join("") + .trim(); + return `${firstLine}\n${secondLine}`; + } + + /** + * @override + */ + hasData() { + return this.sectionsArray.length; + } + + /** + * Set the new range according to the range name passed into parameter. + * @param rangeName {string} the range name to set. + */ + async setRange(rangeName) { + this.navigationInfo.range = this.ranges[rangeName]; + browser.localStorage.setItem(this.storageKey, rangeName); + await this.reload(); + } + + async setAnchor(anchor) { + this.navigationInfo.anchor = anchor; + await this.reload(); + } + + async setTodayAnchor() { + await this.setAnchor(this.today); + } + + generateNavigationDomain() { + if (this.columnFieldIsDate) { + const { periodStart, periodEnd } = this.navigationInfo; + return new Domain([ + "&", + [this.columnFieldName, ">=", serializeDate(periodStart)], + [this.columnFieldName, "<=", serializeDate(periodEnd)], + ]); + } else { + return Domain.TRUE; + } + } + + /** + * Reset the anchor + */ + async resetAnchor() { + await this.setAnchor(this.defaultAnchor); + } + + /** + * Move the anchor to the next/previous step + * @param direction {"forward"|"backward"} the direction to the move the anchor + */ + async moveAnchor(direction) { + if (direction == "forward") { + this.navigationInfo.anchor = this.navigationInfo.anchor.plus({ + [this.navigationInfo.range.span]: 1, + }); + } else if (direction == "backward") { + this.navigationInfo.anchor = this.navigationInfo.anchor.minus({ + [this.navigationInfo.range.span]: 1, + }); + } else { + throw Error("Invalid argument"); + } + if ( + this.navigationInfo.contains(this.today) && + this.navigationInfo.anchor.startOf("day").equals(this.today.startOf("day")) + ) { + this.navigationInfo.anchor = this.today; + } + await this.reload(); + } + + async loadData(metaData) { + this._resetGridComponentsId(); + Object.assign(metaData, await this._getInitialData(metaData)); + const { data, sectionField } = metaData; + + const mergeAdditionalData = (fetchedData) => { + const additionalData = {}; + for (const fetchedDatum of fetchedData) { + for (const [sectionKey, sectionInfo] of Object.entries(fetchedDatum)) { + if (!(sectionKey in additionalData)) { + additionalData[sectionKey] = sectionInfo; + } else { + for (const [rowKey, rowInfo] of Object.entries(sectionInfo.rows)) { + if (!(rowKey in additionalData[sectionKey].rows)) { + additionalData[sectionKey].rows[rowKey] = rowInfo; + } + } + } + } + } + return additionalData; + }; + + const appendAdditionData = (additionalData) => { + for (const [sectionKey, sectionInfo] of Object.entries(additionalData)) { + if (!(sectionKey in data.sectionsKeyToIdMapping)) { + if (sectionField) { + const newSection = new this.constructor.Section( + data, + null, + { [sectionField.name]: sectionInfo.value }, + this, + null + ); + data.sections[newSection.id] = newSection; + data.sectionsKeyToIdMapping[sectionKey] = newSection.id; + data.rows[newSection.id] = newSection; + data.rowsKeyToIdMapping[sectionKey] = newSection.id; + } else { + // if no sectionField and the section is not in sectionsKeyToIdMapping then no section is generated + this._generateFakeSection(data); + } + } + const section = data.sections[data.sectionsKeyToIdMapping[sectionKey]]; + for (const [rowKey, rowInfo] of Object.entries(sectionInfo.rows)) { + if (!(rowKey in data.rowsKeyToIdMapping)) { + const newRow = new this.constructor.Row( + data, + rowInfo.domain, + rowInfo.values, + this, + section, + true + ); + data.rows[newRow.id] = newRow; + data.rowsKeyToIdMapping[rowKey] = newRow.id; + for (const column of Object.values(data.columns)) { + newRow.updateCell(column, 0, data); + } + } + } + } + }; + + const [dataFetched, additionalDataFetched] = await Promise.all([ + this.fetchData(metaData), + Promise.all(this._fetchAdditionalData(metaData)), + ]); + this._generateData(dataFetched, metaData); + appendAdditionData(mergeAdditionalData(additionalDataFetched)); + if (!this.orm.isSample) { + const [, postFetchAdditionalData] = await Promise.all([ + Promise.all(this._getAdditionalPromises(metaData)), + Promise.all(this._postFetchAdditionalData(metaData)), + ]); + appendAdditionData(mergeAdditionalData(postFetchAdditionalData)); + } + + const { items } = data; + for (const section of Object.values(data.sections)) { + items.push(section); + this._itemsPostProcess(section, metaData); + for (const rowId in section.rows) { + const row = section.rows[rowId]; + this._itemsPostProcess(row, metaData); + items.push(row); + } + } + } + + /** + * Load the model + * + * @override + * @param params {Object} the search parameters (domain, groupBy, etc.) + * @return {Promise} + */ + async load(params = {}) { + const searchParams = { + ...this.searchParams, + ...params, + }; + const groupBys = []; + let notificationDisplayed = false; + for (const groupBy of searchParams.groupBy) { + if (groupBy.startsWith(this.columnFieldName)) { + if (!notificationDisplayed) { + this.notificationService.add( + _t( + "Grouping by the field used in the column of the grid view is not possible." + ), + { type: "warning" } + ); + notificationDisplayed = true; + } + } else { + groupBys.push(groupBy); + } + } + if (searchParams.length !== groupBys.length) { + searchParams.groupBy = groupBys; + } + let rowFields = []; + let sectionField; + if (searchParams.groupBy.length) { + if ( + this.defaultSectionField && + searchParams.groupBy.length > 1 && + searchParams.groupBy[0] === this.defaultSectionField.name + ) { + sectionField = this.defaultSectionField; + } + const rowFieldPerFieldName = Object.fromEntries( + this.defaultRowFields.map((r) => [r.name, r]) + ); + for (const groupBy of searchParams.groupBy) { + if (sectionField && groupBy === sectionField.name) { + continue; + } + if (groupBy in rowFieldPerFieldName) { + rowFields.push({ + ...rowFieldPerFieldName[groupBy], + invisible: "False", + }); + } else { + rowFields.push({ name: groupBy }); + } + } + } else { + if ( + this.defaultSectionField && + this.defaultSectionField.invisible !== "True" && + this.defaultSectionField.invisible !== "1" + ) { + sectionField = this.defaultSectionField; + } + rowFields = this.defaultRowFields.filter( + (r) => r.invisible !== "True" && r.invisible !== "1" + ); + } + + const metaData = { + searchParams, + rowFields, + sectionField, + }; + await this.keepLast.add(this.loadData(metaData)); + Object.assign(this, { + ...metaData, + }); + } + + async reload(params = {}) { + await this.load(params); + this.useSampleModel = false; + this.notify(); + } + + /** + * Generate the date columns. + * @private + * @return {GridColumn[]} + */ + _generateDateColumns({ data }) { + const generateNext = (dateStart) => + dateStart.plus({ [`${this.navigationInfo.range.step}s`]: 1 }); + for ( + let currentDate = this.navigationInfo.periodStart; + currentDate < this.navigationInfo.periodEnd; + currentDate = generateNext(currentDate) + ) { + const domainStart = currentDate; + const domainStop = generateNext(currentDate); + const domainStartSerialized = serializeDate(domainStart); + const isWeekDay = currentDate.weekday < 6; + const column = new this.constructor.DateColumn( + this, + this._getDateColumnTitle(currentDate), + domainStartSerialized, + serializeDate(domainStop), + currentDate.startOf("day").equals(this.today.startOf("day")), + isWeekDay + ); + data.columns[column.id] = column; + data.columnsKeyToIdMapping[domainStartSerialized] = column.id; + } + } + + /** + * Search grid columns + * + * @param {Array} domain domain to filter the result + * @param {string} readonlyField field uses to make column readonly if true + * @returns {Array} array containing id, display_name and readonly if readonlyField is defined. + */ + async _searchMany2oneColumns(domain, readonlyField) { + const fieldsToFetch = ["id", "display_name"]; + if (readonlyField) { + fieldsToFetch.push(readonlyField); + } + const columnField = this.fieldsInfo[this.columnFieldName]; + const columnRecords = await this.orm.searchRead( + columnField.relation, + domain || [], + fieldsToFetch + ); + return columnRecords.map((read) => Object.values(read)); + } + + /** + * Initialize the data. + * @private + */ + async _getInitialData(metaData) { + const initialData = { + data: { + columnsKeyToIdMapping: {}, + columns: {}, + rows: {}, + rowsKeyToIdMapping: {}, + fieldsInfo: this.fieldsInfo, + sections: {}, + sectionsKeyToIdMapping: {}, + items: [], + }, + record: { + context: {}, + resModel: this.resModel, + resIds: [], + }, + }; + let columnRecords = []; + const columnField = this.fieldsInfo[this.columnFieldName]; + if (this.columnFieldIsDate) { + this._generateDateColumns(initialData); + } else { + if (columnField.type === "selection") { + const selectionFieldValues = await this.orm.call( + "ir.model.fields", + "get_field_selection", + [this.resModel, this.columnFieldName] + ); + columnRecords = selectionFieldValues; + } else if (columnField.type === "many2one") { + columnRecords = await this._searchMany2oneColumns(); + } else { + throw new Error( + "Unmanaged column type. Supported types are date, selection and many2one." + ); + } + for (const record of columnRecords) { + let readonly = false; + let key, value; + if (record.length === 2) { + [key, value] = record; + } else { + [key, value, readonly] = record; + } + const column = new this.constructor.Column(this, value, key, Boolean(readonly)); + initialData.data.columns[column.id] = column; + initialData.data.columnsKeyToIdMapping[key] = column.id; + } + } + return initialData; + } + + async fetchData(metaData) { + const { searchParams } = metaData; + let dataFetched = await this.orm.formattedReadGroup( + this.resModel, + Domain.and([searchParams.domain, this.generateNavigationDomain()]).toList({}), + this._getGroupByFields(metaData), + this.aggregates, + ); + if (this.orm.isSample) { + dataFetched = dataFetched.filter((group) => { + const date = DateTime.fromISO(group[this.columnGroupByFieldName][0]); + const { periodStart, periodEnd } = this.navigationInfo; + return date >= periodStart && date <= periodEnd; + }); + } + return dataFetched; + } + + /** + * Gets additional groups to be added to the grid. The call to this function is made in parallel to the main data + * fetching. + * + * This function is intended to be overriden in modules where we want to display additional sections and/or rows in + * the grid than what would be returned by the formattedReadGroup. + * The model `sectionField` and `rowFields` can be used in order to know what need to be returned. + * + * An example of this is: + * - when considering timesheet, we want to ease their encoding by adding (to the data that is fetched for scale), + * the entries that have been entered the week before. That way, the first day of week + * (or month, depending on the scale), a line is already displayed with 0's and can directly been used in the + * grid instead of having to use the create button. + * + * @return {Array>} an array of Promise of Object of type: + * { + * sectionKey: { + * value: Any, + * rows: { + * rowKey: { + * domain: Domain, + * values: [Any], + * }, + * }, + * }, + * } + * @private + */ + _fetchAdditionalData(metaData) { + return []; + } + + /** + * Gets additional groups to be added to the grid. The call to this function is made after the main data fetching + * has been processed which allows using `data` in the code. + * This function is intended to be overriden in modules where we want to display additional sections and/or rows in + * the grid than what would be returned by the formattedReadGroup. + * The model `sectionField`, `rowFields` as well as `data` can be used in order to know what need to be returned. + * + * @return {Array>} an array of Promise of Object of type: + * { + * sectionKey: { + * value: Any, + * rows: { + * rowKey: { + * domain: Domain, + * values: [Any], + * }, + * }, + * }, + * } + * @private + */ + _postFetchAdditionalData(metaData) { + return []; + } + + _getAdditionalPromises(metaData) { + return [this._fetchUnavailabilityDays(metaData)]; + } + + async _fetchUnavailabilityDays(metaData, args = {}) { + if (!this.columnFieldIsDate) { + return {}; + } + const result = await this.orm.call( + this.resModel, + "grid_unavailability", + [ + serializeDate(this.navigationInfo.periodStart), + serializeDate(this.navigationInfo.periodEnd), + ], + { + ...args, + } + ); + this._processUnavailabilityDays(result); + } + + _processUnavailabilityDays(result) { + return; + } + + /** + * Generate the row key according to the provided read group result. + * @param readGroupResult {Array} the read group result the key has to be generated for. + * @private + * @return {string} + */ + _generateRowKey(readGroupResult, { sectionField, rowFields }) { + let key = ""; + const sectionKey = + (sectionField && this._generateSectionKey(readGroupResult, sectionField)) || false; + for (const rowField of rowFields) { + let value = rowField.name in readGroupResult && readGroupResult[rowField.name]; + if (this.fieldsInfo[rowField.name].type === "many2one") { + value = value && value[0]; + } + key += `${value}\\|/`; + } + return `${sectionKey}@|@${key}`; + } + + /** + * Generate the section + * @param readGroupResult + * @private + */ + _generateSectionKey(readGroupResult, sectionField) { + let value = readGroupResult[sectionField.name]; + if (this.fieldsInfo[sectionField.name].type === "many2one") { + value = value && value[0]; + } + return `/|\\${value.toString()}`; + } + + /** + * Generate the row domain for the provided read group result. + * @param readGroupResult {Array} the read group result the domain has to be generated for. + * @return {{domain: Domain, values: Object}} the generated domain and values. + */ + _generateRowDomainAndValues(readGroupResult, rowFields) { + let domain = new Domain(); + const values = {}; + for (const rowField of rowFields) { + const result = rowField.name in readGroupResult && readGroupResult[rowField.name]; + let value = result; + if (this.fieldsInfo[rowField.name].type === "many2one") { + value = value && value[0]; + } + values[rowField.name] = result; + domain = Domain.and([domain, [[rowField.name, "=", value]]]); + } + return { domain, values }; + } + + _generateFakeSection(data) { + const section = new this.constructor.Section(data, null, null, this, null); + data.sections[section.id] = section; + data.sectionsKeyToIdMapping["false"] = section.id; + data.rows[section.id] = section; + data.rowsKeyToIdMapping["false"] = section.id; + return section; + } + + async _generateData(readGroupResults, metaData) { + const { data, record, sectionField, rowFields } = metaData; + let section; + for (const readGroupResult of readGroupResults) { + if (!this.orm.isSample) { + record.resIds.push(...readGroupResult['id:array_agg']); + } + const rowKey = this._generateRowKey(readGroupResult, metaData); + if (sectionField) { + const sectionKey = this._generateSectionKey(readGroupResult, sectionField); + if (!(sectionKey in data.sectionsKeyToIdMapping)) { + const newSection = new this.constructor.Section( + data, + null, + { [sectionField.name]: readGroupResult[sectionField.name] }, + this, + null + ); + data.sections[newSection.id] = newSection; + data.sectionsKeyToIdMapping[sectionKey] = newSection.id; + data.rows[newSection.id] = newSection; + data.rowsKeyToIdMapping[sectionKey] = newSection.id; + } + section = data.sections[data.sectionsKeyToIdMapping[sectionKey]]; + } else if (Object.keys(data.sections).length === 0) { + section = this._generateFakeSection(data); + } + let row; + if (!(rowKey in data.rowsKeyToIdMapping)) { + const { domain, values } = this._generateRowDomainAndValues( + readGroupResult, + rowFields + ); + row = new this.constructor.Row(data, domain, values, this, section); + data.rows[row.id] = row; + data.rowsKeyToIdMapping[rowKey] = row.id; + } else { + row = data.rows[data.rowsKeyToIdMapping[rowKey]]; + } + let columnKey; + const columnField = this.fieldsInfo[this.columnFieldName]; + if (this.columnFieldIsDate || columnField.type === "many2one") { + columnKey = readGroupResult[this.columnGroupByFieldName][0]; + } else if (columnField.type === "selection") { + columnKey = readGroupResult[this.columnGroupByFieldName]; + } else { + throw new Error( + "Unmanaged column type. Supported types are date, selection and many2one." + ); + } + if (data.columnsKeyToIdMapping[columnKey] in data.columns) { + const column = data.columns[data.columnsKeyToIdMapping[columnKey]]; + row.updateCell(column, readGroupResult[this.measureGroupByFieldName], data); + const readonlyFieldAggregator = this.readonlyField && `${this.readonlyField.name}:${this.readonlyField.aggregator}`; + if (readonlyFieldAggregator && readonlyFieldAggregator in readGroupResult) { + row.setReadonlyCell(column, readGroupResult[readonlyFieldAggregator], data); + } + } + } + } + + /** + * Method meant to be overridden whenever an item (row and section) post process is needed. + * @param item {GridSection|GridRow} + */ + _itemsPostProcess(item, metaData) {} + + _resetGridComponentsId() { + this.columnId = 0; + this.rowId = 0; + this.sectionId = 0; + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_renderer.dark.scss b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.dark.scss new file mode 100644 index 0000000..371601a --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.dark.scss @@ -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}; + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_renderer.js b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.js new file mode 100644 index 0000000..ca89257 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.js @@ -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`

${noActivitiesFound}

`; + } + + 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; + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_renderer.scss b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.scss new file mode 100644 index 0000000..f351e34 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.scss @@ -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); + } +} diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_renderer.xml b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.xml new file mode 100644 index 0000000..55fb481 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_renderer.xml @@ -0,0 +1,377 @@ + + + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+ +
+
+ +
+ + + + + +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+ + + + + +
+
+ + + + + + | + + +
+
+
+
+ +
+
+ +
+
+ + +
+ + + + + +
+
+
+
+ + + + + +
+ +
+
+ +
+
+ + + +
+
+
+
+ + + +
+ + + + + + +
+ +
+ +
+
+
+
+
+
+
+
+ + + +
+ +
+ + + +
+
+
+ diff --git a/odex30_base/odex30_web_grid/static/src/views/grid_view.js b/odex30_base/odex30_web_grid/static/src/views/grid_view.js new file mode 100644 index 0000000..bc70f26 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/src/views/grid_view.js @@ -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); diff --git a/odex30_base/odex30_web_grid/static/tests/grid_cells/float_time_grid_cell.test.js b/odex30_base/odex30_web_grid/static/tests/grid_cells/float_time_grid_cell.test.js new file mode 100644 index 0000000..b04b310 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/tests/grid_cells/float_time_grid_cell.test.js @@ -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: ` + + + + + + `, + }; +} + +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"); +}); diff --git a/odex30_base/odex30_web_grid/static/tests/grid_cells/float_toggle_grid_cell.test.js b/odex30_base/odex30_web_grid/static/tests/grid_cells/float_toggle_grid_cell.test.js new file mode 100644 index 0000000..66a8dbc --- /dev/null +++ b/odex30_base/odex30_web_grid/static/tests/grid_cells/float_toggle_grid_cell.test.js @@ -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,1": /* xml */ ` + + + + + + + + `, + }; +} + +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", + }); +}); diff --git a/odex30_base/odex30_web_grid/static/tests/grid_mock_server.js b/odex30_base/odex30_web_grid/static/tests/grid_mock_server.js new file mode 100644 index 0000000..9d7b9b2 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/tests/grid_mock_server.js @@ -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; +}); diff --git a/odex30_base/odex30_web_grid/static/tests/grid_view.test.js b/odex30_base/odex30_web_grid/static/tests/grid_view.test.js new file mode 100644 index 0000000..01cd203 --- /dev/null +++ b/odex30_base/odex30_web_grid/static/tests/grid_view.test.js @@ -0,0 +1,2326 @@ +import { beforeEach, describe, expect, test } from "@odoo/hoot"; +import { + hover, + press, + queryAll, + queryAllTexts, + queryFirst, + queryOne, + scroll, +} from "@odoo/hoot-dom"; +import { animationFrame, mockDate, runAllTimers } from "@odoo/hoot-mock"; +import { + contains, + defineModels, + fields, + getService, + makeMockEnv, + mockService, + models, + mountView, + mountWithCleanup, + onRpc, + patchWithCleanup, + selectFieldDropdownItem, + serverState, + toggleMenuItem, + toggleSearchBarMenu, +} from "@web/../tests/web_test_helpers"; +import { browser } from "@web/core/browser/browser"; +import { Domain } from "@web/core/domain"; +import { WebClient } from "@web/webclient/webclient"; + +class Line extends models.Model { + _name = "analytic.line"; + + project_id = fields.Many2one({ string: "Project", relation: "project" }); + task_id = fields.Many2one({ string: "Task", relation: "task" }); + selection_field = fields.Selection({ + string: "Selection Field", + selection: [ + ["abc", "ABC"], + ["def", "DEF"], + ["ghi", "GHI"], + ], + }); + date = fields.Date(); + unit_amount = fields.Float({ + string: "Unit Amount", + aggregator: "sum", + }); + + _records = [ + { + id: 1, + project_id: 31, + selection_field: "abc", + date: "2017-01-24", + unit_amount: 2.5, + }, + { + id: 2, + project_id: 31, + task_id: 1, + selection_field: "def", + date: "2017-01-25", + unit_amount: 2, + }, + { + id: 3, + project_id: 31, + task_id: 1, + selection_field: "def", + date: "2017-01-25", + unit_amount: 5.5, + }, + { + id: 4, + project_id: 31, + task_id: 1, + selection_field: "def", + date: "2017-01-30", + unit_amount: 10, + }, + { + id: 5, + project_id: 142, + task_id: 12, + selection_field: "ghi", + date: "2017-01-31", + unit_amount: -3.5, + }, + ]; + + _views = { + form: ` +
+ + + + + + + + +
`, + list: ` + + + + + + + `, + grid: ` + + + + + + + + + `, + "grid,1": ` + + + + + + + + + `, + search: ` + + + + + + + `, + }; +} + +class Project extends models.Model { + name = fields.Char(); + + _records = [ + { id: 31, name: "P1" }, + { id: 142, name: "Webocalypse Now" }, + ]; +} + +class Task extends models.Model { + name = fields.Char(); + project_id = fields.Many2one({ string: "Project", relation: "project" }); + + _records = [ + { id: 1, name: "BS task", project_id: 31 }, + { id: 12, name: "Another BS task", project_id: 142 }, + { id: 54, name: "yet another task", project_id: 142 }, + ]; + + _views = { + form: `
`, + }; +} + +defineModels([Line, Project, Task]); + +beforeEach(() => { + mockDate("2017-01-30 00:00:00"); +}); + +onRpc("grid_unavailability", () => ({})); +onRpc("has_group", () => true); + +describe.tags("desktop"); +describe("grid_view_desktop", () => { + test("basic empty grid view", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + + `, + domain: Domain.FALSE.toList({}), + }); + + expect(".o_grid_view").toHaveCount(1); + expect(".o_grid_renderer").toHaveCount(1); + expect(".o_grid_buttons:visible").toHaveCount(1); + expect(".o_grid_custom_buttons").toHaveCount(0); + expect(".o_grid_navigation_buttons").toHaveCount(1); + expect(".o_grid_navigation_buttons button:eq(0)").toHaveText("Today", { + message: "The first navigation button should be the Today one.", + }); + expect(".o_grid_navigation_buttons button > span.oi-arrow-left").toHaveCount(1, { + message: "The previous button should be there", + }); + expect(".o_grid_navigation_buttons button > span.oi-arrow-right").toHaveCount(1, { + message: "The next button should be there", + }); + expect(".o_view_scale_selector").toHaveCount(1); + expect(".o_view_scale_selector button.scale_button_selection").toHaveText("Day", { + message: "The default active range should be the first one define in the grid view", + }); + await contains(".scale_button_selection").click(); + expect(".o-dropdown--menu .o_scale_button_day").toHaveCount(1, { + message: "The Day scale should be in the dropdown menu", + }); + expect(".o-dropdown--menu .o_scale_button_week").toHaveCount(1, { + message: "The week scale should be in the dropdown menu", + }); + expect(".o-dropdown--menu .o_scale_button_month").toHaveCount(1, { + message: "The month scale should be in the dropdown menu", + }); + expect(".o_grid_column_title.fw-bolder").toHaveCount(1, { + message: "The column title containing the date should be the current date", + }); + expect(".o_grid_column_title.fw-bolder").toHaveText("Mon,\nJan 30", { + message: "The current date should be Monday on 30 January 2023", + }); + expect(".o_grid_column_title:not(.o_grid_navigation_wrap, .o_grid_row_total)").toHaveCount( + 1, + { + message: "It should have 1 column", + } + ); + expect(".o_grid_column_title.o_grid_row_total").toHaveCount(1, { + message: "It should have 1 column for the total", + }); + expect(".o_grid_column_title.o_grid_row_total").toHaveCount(1); + expect(".o_grid_column_title.o_grid_row_total").toHaveText("Unit Amount", { + message: "The column title of row totals should be the string of the measure field", + }); + + expect(".o_grid_add_line a").toHaveCount(0, { + message: + "No Add a line button should be displayed when create_inline is false (default behavior)", + }); + }); + + test("basic empty grid view using a specific range by default", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + + `, + domain: Domain.FALSE.toList({}), + }); + + expect(".o_grid_view").toHaveCount(1); + expect(".o_grid_renderer").toHaveCount(1); + expect(".o_grid_column_title:not(.o_grid_navigation_wrap, .o_grid_row_total)").toHaveCount( + 7, + { + message: "It should have 7 column representing the dates on a week.", + } + ); + expect( + queryAllTexts(".o_grid_column_title:not(.o_grid_navigation_wrap, .o_grid_row_total)") + ).toEqual( + [ + "Sun,\nJan 29", + "Mon,\nJan 30", + "Tue,\nJan 31", + "Wed,\nFeb 1", + "Thu,\nFeb 2", + "Fri,\nFeb 3", + "Sat,\nFeb 4", + ], + { message: "check the columns title is correctly formatted when the range is week" } + ); + expect(".o_grid_column_title.o_grid_row_total").toHaveCount(1, { + message: "It should have 1 column for the total", + }); + expect(".o_grid_column_title.fw-bolder").toHaveCount(1, { + message: "The column title containing the current date should not be there.", + }); + expect(".o_grid_column_title.fw-bolder").toHaveText("Mon,\nJan 30", { + message: "The current date should be Monday on 30 January", + }); + }); + + test("basic grid view", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + + `, + }); + + expect( + ".o_grid_row.o_grid_highlightable:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveCount(14, { + message: + "The number of cells containing numeric value and whom is not a total cell should be 14 (2 rows and 7 cells to represent the week)", + }); + expect( + ".o_grid_row.o_grid_highlightable.text-danger:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveCount(1, { + message: "In those 14 cells, one has a value less than 0 and so the text should be red", + }); + expect( + ".o_grid_row.o_grid_highlightable.text-danger:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveText("-3.50", { + message: "The cell with text color in red should contain `-3.50`", + }); + expect(".o_grid_row.o_grid_highlightable.o_grid_column_total.text-danger").toHaveCount(1, { + message: + "The cell containing the column total and in that column a cell is negative to also get a total negative should have text color in red", + }); + expect(".o_grid_row.o_grid_highlightable.o_grid_column_total.text-danger").toHaveText( + "-3.50" + ); + expect(".o_grid_row.o_grid_highlightable.o_grid_row_total.bg-danger").toHaveCount(1); + expect(".o_grid_row.o_grid_highlightable.o_grid_row_total.bg-danger").toHaveText("-3.50"); + expect(".o_grid_row.o_grid_highlightable > div.bg-info").toHaveCount(3, { + message: + "The cell in the column of the current should have `bg-info` class as the header", + }); + expect(".o_grid_row.o_grid_row_title.o_grid_highlightable").toHaveCount(2); + expect(queryAllTexts(".o_grid_row.o_grid_row_title.o_grid_highlightable")).toEqual([ + "P1\n|\nBS task", + "Webocalypse Now\n|\nAnother BS task", + ]); + await contains(".o_grid_navigation_buttons button span.oi-arrow-right").click(); + expect( + ".o_grid_row.o_grid_highlightable:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveCount(0, { + message: "No cell should be found because no records is found next week", + }); + expect(".o_view_nocontent").toHaveCount(1, { + message: "No content div should be displayed", + }); + expect("div.bg-info").toHaveCount(0, { + message: "No column should be the current date since we move in the following week.", + }); + await contains(".o_grid_navigation_buttons button span.oi-arrow-right").click(); + expect("div.o_grid_row_title").toHaveCount(0, { message: "should not have any cell" }); + }); + + test("basic grouped grid view", async () => { + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_section.o_grid_section_title").toHaveCount(1, { + message: "A section should be displayed (for the project P1)", + }); + expect(".o_grid_section.o_grid_section_title").toHaveText("P1", { + message: "The title of the section should be the project name", + }); + expect(".o_grid_section:not(.o_grid_section_title, .o_grid_row_total)").toHaveCount(7, { + message: + "7 cells for the section should be displayed to represent the total per day of the section", + }); + expect(".o_grid_section.o_grid_row_total").toHaveCount(1, { + message: + "One cell should be displayed to display the total of the week for the whole section", + }); + expect(".o_grid_section.o_grid_row_total").toHaveText("10:00", { + message: "The total of the section should be equal to 10 hours.", + }); + expect(".o_grid_row.o_grid_row_title").toHaveCount(2, { + message: "2 rows should be displayed below that section (one per task)", + }); + expect(queryAllTexts(".o_grid_row.o_grid_row_title")).toEqual(["None", "BS task"]); + expect( + ".o_grid_row:not(.o_grid_row_title,.o_grid_row_total,.o_grid_column_total,.o_grid_add_line)" + ).toHaveCount(14, { + message: "7 cells per row should be displayed to get value per day in the current week", + }); + expect( + queryAllTexts( + ".o_grid_row:not(.o_grid_row_title,.o_grid_row_total,.o_grid_column_total,.o_grid_add_line)" + ) + ).toEqual([ + // row 1 + "0:00", + "0:00", + "2:30", + "0:00", + "0:00", + "0:00", + "0:00", + // row 2 + "0:00", + "0:00", + "0:00", + "7:30", + "0:00", + "0:00", + "0:00", + ]); + expect(".o_grid_row.o_grid_row_total").toHaveCount(2, { + message: "One cell per row should be displayed to display the total of the week", + }); + expect(queryAllTexts(".o_grid_row.o_grid_row_total")).toEqual(["2:30", "7:30"]); + expect(".o_grid_search_btn").toHaveCount(0, { + message: "No search button should be displayed in the grid cells.", + }); + await hover(".o_grid_section.o_grid_highlightable:eq(1)"); + await contains(".o_grid_cell button.o_grid_search_btn").click(); + + // Click on next period to have no data + await contains(".o_grid_navigation_buttons button span.oi-arrow-left").click(); + expect(".o_grid_section").toHaveCount(0); + expect( + ".o_grid_row.o_grid_highlightable:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveCount(0, { + message: "No cell should be found because no records is found next week", + }); + expect(".o_view_nocontent").toHaveCount(1, { + message: "No content div should be displayed", + }); + expect("div.bg-info").toHaveCount(0, { + message: "No column should be the current date since we move in the following week.", + }); + }); + + test("clicking on the info icon on a cell triggers a do_action for section rows", async () => { + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + expect(".o_grid_search_btn").toHaveCount(0, { + message: "No search button should be displayed in the grid cells.", + }); + await hover(".o_grid_section.o_grid_highlightable:eq(1)"); + await contains(".o_grid_cell button.o_grid_search_btn").click(); + }); + + test("Add/remove groupbys in search view", async () => { + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + searchViewArch: ` + + + + + `, + }); + + await toggleSearchBarMenu(); + await toggleMenuItem("Task"); + await toggleMenuItem("Project"); + expect(".o_grid_section").toHaveCount(0); + expect(".o_grid_row_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["None\n|\nP1", "BS task\n|\nP1"]); + await contains(".o_grid_navigation_buttons button span.oi-arrow-right").click(); + expect(".o_grid_section").toHaveCount(0); + expect(".o_grid_row_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_row_title")).toEqual([ + "BS task\n|\nP1", + "Another BS task\n|\nWebocalypse Now", + ]); + + // Remove the group and check the default groupbys defined in the view are correctly used. + await toggleSearchBarMenu(); + await toggleMenuItem("Task"); + await toggleMenuItem("Project"); + expect(".o_grid_section_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_section_title")).toEqual(["P1", "Webocalypse Now"]); + expect(".o_grid_row_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["BS task", "Another BS task"]); + }); + + test("groupBy with a field", async () => { + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + searchViewArch: ` + + + + `, + }); + + await toggleSearchBarMenu(); + await contains("span.o_menu_item:not(.o_add_custom_filter)").click(); + expect(".o_grid_section").toHaveCount(0); + expect(".o_grid_row_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["None", "BS task"]); + await contains(".o_grid_navigation_buttons button span.oi-arrow-right").click(); + expect(".o_grid_section").toHaveCount(0); + expect(".o_grid_row_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["BS task", "Another BS task"]); + }); + + test("groupBy doesn't change the scale", async () => { + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + searchViewArch: ` + + + + `, + }); + + await contains(".scale_button_selection").click(); + await contains(".o-dropdown--menu .o_scale_button_month").click(); + expect(".o_view_scale_selector button.scale_button_selection").toHaveText("Month", { + message: "The active range should be Month", + }); + await toggleSearchBarMenu(); + await contains("div.o_group_by_menu > span.o_menu_item").click(); + expect(".o_view_scale_selector button.scale_button_selection").toHaveText("Month", { + message: "The active range should still be Month", + }); + }); + + test("groupBy with column field should not be supported", async () => { + expect.assertions(7); + mockDate("2017-01-25 00:00:00"); + onRpc("formatted_read_group", ({ kwargs }) => { + expect(kwargs.groupby).toEqual(["date:day", "task_id", "project_id"]); + }); + mockService("notification", { + add: (message, options) => { + expect(message).toBe( + "Grouping by the field used in the column of the grid view is not possible." + ); + expect(options.type).toBe("warning"); + }, + }); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + searchViewArch: ` + + + + `, + }); + + await toggleSearchBarMenu(); + await toggleMenuItem("Date"); + const dateOptionNodes = queryAll(".o_item_option"); + await contains(dateOptionNodes[0]).click(); + await contains(dateOptionNodes[1]).click(); + }); + + test("DOM keys are unique", async () => { + Line._records = [ + { id: 1, project_id: 31, date: "2017-01-24", unit_amount: 2.5 }, + { id: 3, project_id: 143, date: "2017-01-25", unit_amount: 5.5 }, + { id: 2, project_id: 33, date: "2017-01-25", unit_amount: 2 }, + { id: 4, project_id: 143, date: "2017-01-18", unit_amount: 0 }, + { id: 5, project_id: 142, date: "2017-01-18", unit_amount: 0 }, + { id: 10, project_id: 31, date: "2017-01-18", unit_amount: 0 }, + { id: 12, project_id: 142, date: "2017-01-17", unit_amount: 0 }, + { id: 22, project_id: 33, date: "2017-01-19", unit_amount: 0 }, + { id: 21, project_id: 99, date: "2017-01-19", unit_amount: 0 }, + ]; + Project._records = [ + { id: 31, name: "Rem" }, + { id: 33, name: "Rer" }, + { id: 99, name: "Sar" }, + { id: 142, name: "Sas" }, + { id: 143, name: "Sassy" }, + ]; + mockDate("2017-01-25 00:00:00"); + + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + `, + }); + + expect(queryAllTexts(".o_grid_row_title")).toEqual(["Rem", "Rer", "Sassy"]); + await contains(".o_grid_navigation_buttons button span.oi-arrow-left").click(); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["Sas", "Rem", "Sassy", "Rer", "Sar"]); + }); + + test("Group By Selection field", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(queryAllTexts(".o_grid_row_title")).toEqual(["DEF", "GHI"]); + await contains(".o_grid_navigation_buttons button span.oi-arrow-left").click(); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["ABC", "DEF"]); + }); + + test("Create record with Add button in grid view", async () => { + mockDate("2017-02-25 00:00:00"); + + onRpc("create", (args) => { + expect(args.args[0][0].date).toBe("2017-02-25", { + message: "default date should be the current day", + }); + }); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + `, + }); + + expect(".o_grid_row_title").toHaveCount(0); + expect(".modal").toHaveCount(0); + expect(".o_view_nocontent").toHaveCount(0); + await contains(".o_grid_button_add").click(); + expect(".modal").toHaveCount(1); + await selectFieldDropdownItem("project_id", "P1"); + await selectFieldDropdownItem("task_id", "BS task"); + + // input unit_amount + await contains(".modal .o_field_widget[name=unit_amount] input").edit("4"); + + // save + await contains(".modal .modal-footer button.o_form_button_save").click(); + + expect(".o_grid_row_title").toHaveCount(1, { + message: "the record should be created and a row should be added", + }); + expect(".o_grid_row_title").toHaveText("P1\n|\nBS task"); + }); + + test("Create record with Add button in grid view grouped", async () => { + mockDate("2017-02-25 00:00:00"); + + onRpc("create", (args) => { + expect(args.args[0][0].date).toBe("2017-02-25", { + message: "default date should be the current day", + }); + }); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + `, + }); + + expect(".o_grid_row_title").toHaveCount(0); + expect(".modal").toHaveCount(0); + expect(".o_view_nocontent").toHaveCount(0); + await contains(".o_grid_button_add").click(); + expect(".modal").toHaveCount(1); + await selectFieldDropdownItem("project_id", "P1"); + await selectFieldDropdownItem("task_id", "BS task"); + + // input unit_amount + await contains(".modal .o_field_widget[name=unit_amount] input").edit("4"); + + // save + await contains(".modal .modal-footer button.o_form_button_save").click(); + + expect(".o_grid_section_title").toHaveCount(1, { + message: "the record should be created and a row should be added", + }); + expect(".o_grid_section_title").toHaveText("P1"); + expect(".o_grid_row_title").toHaveCount(1, { + message: "the record should be created and a row should be added", + }); + expect(".o_grid_row_title").toHaveText("BS task"); + }); + + test("switching active range", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_view_scale_selector button.scale_button_selection").toHaveText("Week", { + message: "The default active range should be the first one define in the grid view", + }); + expect(".o_grid_column_title:not(.o_grid_navigation_wrap, .o_grid_row_total").toHaveCount( + 7, + { + message: "It should have 7 columns (one for each day)", + } + ); + await contains(".scale_button_selection").click(); + expect(".o-dropdown--menu .o_scale_button_week").toHaveCount(1, { + message: "The week scale should be in the dropdown menu", + }); + expect(".o-dropdown--menu .o_scale_button_month").toHaveCount(1, { + message: "The month scale should be in the dropdown menu", + }); + await contains(".o-dropdown--menu .o_scale_button_month").click(); + expect(".o_view_scale_selector button.scale_button_selection").toHaveText("Month", { + message: "The active range should be Month", + }); + expect(".o_grid_column_title:not(.o_grid_navigation_wrap, .o_grid_row_total)").toHaveCount( + 31, + { + message: "It should have 31 columns (one for each day)", + } + ); + }); + + test("clicking on the info icon on a cell triggers a do_action", async () => { + onRpc("get_views", (args) => { + if (args.kwargs.views.find((v) => v[1] === "list")) { + const context = args.kwargs.context; + const expectedContext = { + default_project_id: 31, + default_task_id: 1, + default_date: "2017-01-30", + }; + for (const [key, value] of Object.entries(expectedContext)) { + expect(context[key]).toBe(value); + } + } + }); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_search_btn").toHaveCount(0, { + message: "No search button should be displayed in the grid cells.", + }); + await hover(".o_grid_row .o_grid_cell_readonly:eq(1)"); + await contains(".o_grid_cell button.o_grid_search_btn").click(); + }); + + test("editing a value", async () => { + onRpc("grid_update_cell", (args) => { + expect(args.model).toBe("analytic.line", { + message: "The update cell should be called in the current model.", + }); + const [domain, fieldName, value] = args.args; + const domainExpected = Domain.and([ + [ + ["project_id", "=", 31], + ["task_id", "=", 1], + ], + [ + ["date", ">=", "2017-01-29"], + ["date", "<", "2017-01-30"], + ], + ]).toList({}); + expect(domain).toEqual(domainExpected); + expect(fieldName).toBe("unit_amount", { + message: "The value updated should be the measure field", + }); + expect(value).toBe(2, { + message: "The value should be the one entered by the user, that is 2", + }); + }); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + const cell = queryFirst(".o_grid_row .o_grid_cell_readonly"); + const cellContainer = cell.closest(".o_grid_highlightable"); + const columnTotal = queryOne( + `.o_grid_row.o_grid_column_total[data-grid-column="${cellContainer.dataset.gridColumn}"]` + ); + const [columnTotalHours, columnTotalMinutes] = ( + (columnTotal.textContent?.length && columnTotal.textContent.split(":")) || [0, 0] + ).map((value) => Number(value)); + const rowTotal = queryOne( + `.o_grid_row_total[data-grid-row="${cellContainer.dataset.gridRow}"]` + ); + const [rowTotalHours, rowTotalMinutes] = ( + (rowTotal.textContent?.length && rowTotal.textContent.split(":")) || [0, 0] + ).map((value) => Number(value)); + expect(cell).toHaveText("0:00"); + await hover(cell); + await runAllTimers(); + expect(".o_grid_cell").toHaveCount(1, { + message: "The GridCell component should be mounted on the grid cell hovered.", + }); + const gridCellComponentEl = queryFirst(".o_grid_cell"); + const gridCell = cell.closest(".o_grid_row"); + expect(gridCellComponentEl.style["grid-row"]).toBe(gridCell.style["grid-row"], { + message: + "The GridCell component should be mounted in the same cell than the one hovered in the grid view.", + }); + expect(gridCellComponentEl.style["grid-column"]).toBe(gridCell.style["grid-column"], { + message: + "The GridCell component should be mounted in the same cell than the one hovered in the grid view.", + }); + expect(gridCellComponentEl.style["z-index"]).toBe("1", { + message: + "The GridCell component should be mounted in the same cell than the one hovered in the grid view.", + }); + await contains(".o_grid_cell").click(); + await animationFrame(); + expect(".o_grid_cell input").toHaveCount(1); + await contains(".o_grid_cell input").edit("2"); + await animationFrame(); + expect(cell).toHaveText("2:00"); + expect(columnTotal).toHaveText( + `${columnTotalHours + 2}:${String(columnTotalMinutes).padStart(2, "0")}` + ); + expect(rowTotal).toHaveText( + `${rowTotalHours + 2}:${String(rowTotalMinutes).padStart(2, "0")}` + ); + }); + + test("edit grid cell with action context defined", async () => { + onRpc("grid_update_cell", (args) => { + const context = args.kwargs.context; + expect.step("grid_update_cell"); + // the project in the action context should be replaced by the project linked to the grid cell altered + expect(context.default_project_id).toBe(31); + expect(context.default_selection_field).toBe("abc"); + }); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + context: { + default_project_id: 1, + default_selection_field: "abc", + }, + }); + + await hover(".o_grid_row .o_grid_cell_readonly"); + await runAllTimers(); + await contains(".o_grid_cell").click(); + await animationFrame(); + expect(".o_grid_cell input").toHaveCount(1); + await contains(".o_grid_cell input").edit("2"); + expect.verifySteps(["grid_update_cell"]); + }); + + test("hide row total", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_row_title").toHaveCount(2); + expect(".o_grid_row_total").toHaveCount(0, { message: "No row total should be displayed" }); + expect(".o_grid_column_total").toHaveCount(7, { + message: "Columns total should be displayed", + }); + }); + + test("hide column total", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_row_title").toHaveCount(2); + expect(".o_grid_row_total").toHaveCount(3, { message: "Rows total should be displayed" }); + expect(".o_grid_column_total").toHaveCount(0, { + message: " No column total should be displayed", + }); + }); + + test("display bar chart total", async () => { + Line._records.push({ + id: 8, + project_id: 142, + task_id: 54, + date: "2017-01-25", + unit_amount: 4, + }); + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_row_title").toHaveCount(3); + expect(".o_grid_row_total").toHaveCount(5, { message: "Rows total should be displayed" }); + expect(".o_grid_column_total:not(.o_grid_bar_chart_container)").toHaveCount(8, { + message: "8 cells should be visible to display the total per colunm", + }); + expect(".o_grid_bar_chart_container").toHaveCount(7, { + message: "The bar chart total container should be displayed (one per column)", + }); + expect(".o_grid_bar_chart_total_pill").toHaveCount(2, { + message: + "2 bar charts totals should be displayed because the 5 others columns as a total equals to 0.", + }); + + expect(queryAllTexts(".o_grid_bar_chart_total_pill")).toEqual(["", ""]); + + const cell = queryFirst(".o_grid_row .o_grid_cell_readonly"); + expect(cell).toHaveText("0:00"); + await hover(cell); + await contains(".o_grid_cell").click(); + await animationFrame(); + expect(".o_grid_cell input").toHaveCount(1); + await contains(".o_grid_cell input").edit("2"); + expect(".o_grid_bar_chart_total_pill").toHaveCount(3, { + message: + "3 bar chart totals should be now displayed because a new column as a total greater than 0.", + }); + }); + + test("row and column are highlighted when hovering a cell", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_row.o_grid_highlightable.bg-700").toHaveCount(0, { + message: "No cell should be highlighted", + }); + await hover(".o_grid_row .o_grid_cell_readonly"); + await runAllTimers(); + expect( + ".o_grid_row.o_grid_highlightable.o_grid_highlighted.o_grid_row_highlighted" + ).toHaveCount(8, { + message: + "8 cells should be highlighted (the cells in the same rows (title row included))", + }); + expect(".o_grid_row_total.o_grid_highlighted.o_grid_row_highlighted").toHaveCount(1, { + message: "The row total should also be highlighted", + }); + }); + + test("grid_anchor stays when navigating", async () => { + // create an action manager to test the interactions with the search view + await mountWithCleanup(WebClient); + await getService("action").doAction({ + res_model: "analytic.line", + type: "ir.actions.act_window", + views: [[false, "grid"]], + context: { + search_default_project_id: 31, + grid_anchor: "2017-01-31", + }, + }); + + // check first column header + expect(queryAllTexts(".o_grid_column_title")).toEqual([ + "Today\nWeek", + "Sun,\nJan 29", + "Mon,\nJan 30", + "Tue,\nJan 31", + "Wed,\nFeb 1", + "Thu,\nFeb 2", + "Fri,\nFeb 3", + "Sat,\nFeb 4", + "Unit Amount", + ]); + + // move to previous week, and check first column header + await contains(".oi-arrow-left").click(); + // check first column header + expect(queryAllTexts(".o_grid_column_title")).toEqual([ + "Today\nWeek", + "Sun,\nJan 22", + "Mon,\nJan 23", + "Tue,\nJan 24", + "Wed,\nJan 25", + "Thu,\nJan 26", + "Fri,\nJan 27", + "Sat,\nJan 28", + "Unit Amount", + ]); + + // remove the filter in the searchview + await contains(".o_facet_remove").click(); + expect(queryAllTexts(".o_grid_column_title")).toEqual([ + "Today\nWeek", + "Sun,\nJan 22", + "Mon,\nJan 23", + "Tue,\nJan 24", + "Wed,\nJan 25", + "Thu,\nJan 26", + "Fri,\nJan 27", + "Sat,\nJan 28", + "Unit Amount", + ]); + }); + + for (const [locale, expected] of Object.entries({ + en_US: "Sun,\nJan 29", + fr_FR: "dim.\n29 janv.", + de_DE: "So.,\n29. Jan.", + ru_RU: "вс,\n29 янв.", + ar_SY: "الأحد،\n٢٩ كانون الثاني", + he_IL: "יום א׳,\n29 בינו׳", + fa_IR: "یکشنبه\n۱۰ بهمن", + th_TH: "อา.\n29 ม.ค.", + tr_TR: "29 Oca Paz", + pl_PL: "niedz.,\n29 sty", + // for CJK locales: keep everything on one line + ja_JP: "1月29日(日)", + zh_CN: "1月29日周日", + ko_KR: "1월 29일 (일)", + })) { + test(`header label should be adapted to the ${locale} locale (day step)`, async () => { + mockDate("2017-01-30 00:00:00"); + const view = { + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + `, + }; + + serverState.lang = locale; + await makeMockEnv(); + await mountView(view); + const text = queryAllTexts(".o_grid_column_title")[1]; + expect(text).toEqual(expected); + }); + } + + for (const [locale, expected] of Object.entries({ + en_US: "January\n2025", + fr_FR: "janvier\n2025", + de_DE: "Januar\n2025", + ru_RU: "январь\n2025 г.", + ar_SY: "كانون الثاني\n٢٠٢٥", + he_IL: "ינואר\n2025", + fa_IR: "۱۴۰۳ دی", // 10th month of year 1403, Solar Hijri calendar + th_TH: "มกราคม\n2568", // Buddhist calendar + tr_TR: "Ocak\n2025", + pl_PL: "styczeń\n2025", + // for CJK locales: keep everything on one line + ja_JP: "2025年1月", + zh_CN: "2025年1月", + ko_KR: "2025년 1월", + })) { + test(`header label should be adapted to the ${locale} locale (month step)`, async () => { + mockDate("2025-01-15 00:00:00"); + const view = { + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + `, + }; + + serverState.lang = locale; + await makeMockEnv(); + await mountView(view); + const text = queryAllTexts(".o_grid_column_title")[1]; + expect(text).toEqual(expected); + }); + } + + test("dialog should not close when clicking the link to many2one field", async () => { + // create an action manager to test the interactions with the search view + onRpc("task", "get_formview_id", () => false); + await mountWithCleanup(WebClient); + await getService("action").doAction({ + res_model: "analytic.line", + type: "ir.actions.act_window", + views: [[false, "grid"]], + }); + + await contains(".o_grid_button_add").click(); + await animationFrame(); + expect(".modal[role='dialog']").toHaveCount(1); + + await selectFieldDropdownItem("task_id", "BS task"); + await contains('.modal .o_field_widget[name="task_id"] button.o_external_button').click(); + // Clicking somewhere on the form dialog should not close it + expect(".modal[role='dialog']").toHaveCount(2); + await contains(".modal[role='dialog']").click(); + expect(".modal[role='dialog']").toHaveCount(2); + }); + + test("grid with two tasks with same name, and widget", async () => { + Task._records = [ + { id: 1, name: "Awesome task", project_id: 31 }, + { id: 2, name: "Awesome task", project_id: 31 }, + ]; + Line._records = [ + { id: 1, task_id: 1, date: "2017-01-30", unit_amount: 2 }, + { id: 2, task_id: 2, date: "2017-01-31", unit_amount: 5.5 }, + ]; + await mountWithCleanup(WebClient); + await getService("action").doAction({ + res_model: "analytic.line", + type: "ir.actions.act_window", + views: [[false, "grid"]], + context: { search_default_groupby_task: 1 }, // to avoid creating a new grid view to remove project_id in rows + }); + + expect(".o_grid_row_title").toHaveCount(2); + expect(queryAllTexts(".o_grid_row_title")).toEqual(["Awesome task", "Awesome task"]); + }); + + test("test grid cell formatting with float_time widget", async () => { + mockDate("2017-01-24 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + groupBy: ["task_id", "project_id"], + arch: ` + + + + + + + `, + }); + + expect( + ".o_grid_row.o_grid_highlightable:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveCount(1); + expect( + ".o_grid_row.o_grid_highlightable:not(.o_grid_row_title,.o_grid_column_total,.o_grid_row_total)" + ).toHaveText("2:30", { message: "Check if the cell is correctly formatted as float time" }); + expect( + ".o_grid_column_total:not(.o_grid_row_title,.o_grid_row_total,.o_grid_bar_chart_container)" + ).toHaveCount(1); + expect( + ".o_grid_column_total:not(.o_grid_row_title,.o_grid_row_total,.o_grid_bar_chart_container) span" + ).toHaveText("2:30", { message: "check format time is used" }); + expect(".o_grid_row.o_grid_highlightable.o_grid_row_total").toHaveCount(1); + expect(".o_grid_row.o_grid_highlightable.o_grid_row_total").toHaveText("2:30", { + message: "check format time is used", + }); + }); + + test("The help content is not displayed instead of the grid with `display_empty` is true in the grid tag", async () => { + mockDate("2022-01-01 00:00:00"); // to be sure no data is found + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + `, + }); + + expect(".o_view_nocontent").toHaveCount(0, { + message: "No content div should be displayed", + }); + }); + + test("Test add a line in the grid view", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + expect(".o_grid_button_add:visible").toHaveCount(1, { + message: "'Add a line' control panel button should be visible", + }); + expect(queryAllTexts(".o_grid_renderer .o_grid_add_line a")).toEqual(["Add a line"], { + message: "A button `Add a line` should be displayed in the grid view", + }); + await contains(".o_grid_renderer .o_grid_add_line a").click(); + expect(".modal").toHaveCount(1); + await contains(".modal .modal-footer button.o_form_button_cancel").click(); + await contains(".o_grid_navigation_buttons button span.oi-arrow-right").click(); + expect(".o_grid_button_add:visible").toHaveCount(1, { + message: "'Add a line' control panel button should be visible", + }); + }); + + test("create/edit disabled for readonly grid view", async () => { + Line._fields.validated = fields.Boolean({ + string: "Validation", + aggregator: "bool_or", + }); + Line._records.push({ + id: 8, + project_id: 142, + task_id: 54, + date: "2017-01-25", + unit_amount: 4, + validated: true, + }); + mockDate("2017-01-25 00:00:00"); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + + + `, + }); + await hover(".o_grid_row .o_grid_cell_readonly"); + await runAllTimers(); + expect(".o_grid_cell .o_grid_search_btn").toHaveCount(1); + expect(".o_grid_cell.o_field_cursor_disabled").toHaveCount(0, { + message: "The cell should not be in readonly", + }); + await hover(".o_grid_row .o_grid_cell_readonly:eq(1)"); + await runAllTimers(); + expect(".o_grid_cell .o_grid_search_btn").toHaveCount(1); + expect(".o_grid_cell.o_field_cursor_disabled").toHaveCount(1, { + message: + "The cell should be in readonly since at least one timesheet is validated in that cell", + }); + await contains("button.o_grid_search_btn").click(); + }); + + test("display the empty grid without None line when there is no data", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + domain: Domain.FALSE.toList({}), + }); + + expect(".o_grid_section_title").toHaveCount(0, { + message: "No section should be displayed to display 'None'", + }); + expect(".o_grid_row_title").toHaveCount(0, { + message: "No row should be added to display 'None'", + }); + }); + + test('ensure the "None" is displayed in multi-level groupby', async () => { + await mountWithCleanup(WebClient); + await getService("action").doAction({ + res_model: "analytic.line", + type: "ir.actions.act_window", + views: [[1, "grid"]], + context: { + search_default_project_id: 31, + search_default_groupby_task: 1, + search_default_groupby_selection: 1, + grid_anchor: "2017-01-24", + }, + }); + + expect(".o_grid_section").toHaveCount(0, { + message: + "No section should be displayed since the section field is not first in the groupby", + }); + expect(".o_grid_row_title:eq(0)").toHaveText("None\n|\nABC", { + message: "'None' should be displayed", + }); + }); + + test("Group By selection field without passing selection field in data", async () => { + Line._records.push({ + id: 6, + project_id: 142, + task_id: 12, + date: "2017-01-24", + unit_amount: 7.0, + }); + + await mountWithCleanup(WebClient); + await getService("action").doAction({ + res_model: "analytic.line", + type: "ir.actions.act_window", + views: [[1, "grid"]], + context: { + search_default_groupby_selection: 1, + grid_anchor: "2017-01-24", + }, + }); + + expect(".o_grid_row_title:contains(None)").toHaveCount(1, { + message: "'None' should be displayed.", + }); + }); + + test("stop edition when the user clicks outside", async () => { + const arch = Line._views["grid"].replace("", ''); + await mountView({ + type: "grid", + resModel: "analytic.line", + arch, + }); + + await hover(".o_grid_row .o_grid_cell_readonly:eq(1)"); + await contains(".o_grid_cell").click(); + await animationFrame(); + + expect(".o_grid_cell input").toHaveCount(1, { message: "The cell should be in edit mode" }); + + await contains(".o_grid_view").click(); + expect(".o_grid_cell input").toHaveCount(0, { + message: "The GridCell should no longer be visible and so no cell is in edit mode.", + }); + }); + + test("display no content helper when no data and sample data is used (with display_empty='1')", async () => { + const arch = Line._views["grid"].replace( + "", + `` + ); + + await mountView({ + type: "grid", + resModel: "analytic.line", + arch, + domain: Domain.FALSE.toList({}), + }); + + expect(".o_view_sample_data").toHaveCount(1, { + message: "The sample data should be displayed since no records is found.", + }); + expect(".o_view_nocontent").toHaveCount(1, { + message: + "The action helper should also be displayed since the sample data is displayed even if display_empty='1'.", + }); + + expect(".o_grid_buttons .o_grid_button_add:visible").toHaveCount(1, { + message: + "The `Add a Line` button should be displayed when no content data is displayed to be able to create a record.", + }); + + await contains(".o_grid_navigation_buttons span.oi-arrow-right").click(); + expect(".o_view_sample_data").toHaveCount(0, { + message: + "The sample data should no longer be displayed since display_empty is true in the grid view", + }); + expect(".o_view_nocontent").toHaveCount(0, { + message: + "The no content helper should no longer be displayed since display_empty is true in the grid view.", + }); + expect(".o_grid_buttons .o_grid_button_add:visible").toHaveCount(1, { + message: "The `Add a Line` button should be displayed near the `Today` one", + }); + expect(".o_grid_grid .o_grid_row.o_grid_add_line.position-md-sticky").toHaveCount(1, { + message: + "The `Add a Line` button should be displayed in the grid view since create_inline='1'", + }); + }); + + test("Only relevant grid rows are rendered with larger recordsets", async () => { + // Setup: generates 100 new tasks and related analytic lines distributed + // in all available projects, deterministically based on their ID. + const { _fields: alFields, _records: analyticLines } = Line; + const { _records: tasks } = Task; + const { _records: projects } = Project; + const selectionValues = alFields.selection_field.selection; + const today = luxon.DateTime.local().toFormat("yyyy-MM-dd"); + for (let id = 100; id < 200; id++) { + const projectId = projects[id % projects.length].id; + tasks.push({ + id, + name: `BS task #${id}`, + project_id: projectId, + }); + analyticLines.push({ + id, + project_id: projectId, + task_id: id, + selection_field: selectionValues[id % selectionValues.length][0], + date: today, + unit_amount: (id % 10) + 1, // 1 to 10 + }); + } + + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: /* xml */ ` + + + + + + + + + + + `, + }); + + /** + * Returns unique "data-grid-row" attributes to check for rows equality + * @returns {string[]} + */ + const getCurrentRows = () => [ + ...new Set([...grid.children].map((el) => el.dataset.gridRow)), + ]; + + const content = queryOne(".o_content"); + const grid = queryOne(".o_grid_grid"); + const firstRow = grid.querySelector(".o_grid_column_title"); + content.style = "height: 600px; overflow: scroll;"; + + // This is to ensure that the virtual rows will not be impacted by + // sub-pixel calculations. + await scroll(content, { top: 0 }); + await animationFrame(); + + const initialRows = getCurrentRows(); + let currentRows = initialRows; + + expect(content.scrollTop).toBe(0, { message: "content should be scrolled to the top" }); + expect(content.offsetHeight).toBe(710, { message: "content should have its height fixed" }); + // ! This next assertion is important: it ensures that the grid rows are + // ! hard-coded so that the virtual hook can work with it. Adapt this test + // ! accordingly should the row height change. + expect( + grid.clientHeight - firstRow.offsetHeight /* first row is "auto" so we don't count it */ + ).toBe((tasks.length - 1) /* ignore total row */ * 32 /* base grid row height */, { + message: + "grid content should be the height of its row height times the amount of records", + }); + expect(currentRows.length).toBeLessThan(tasks.length, { + message: "not all rows should be displayed", + }); + + // Scroll to the middle of the grid + await scroll(content, { top: content.scrollHeight / 2 }); + await animationFrame(); + expect(currentRows).not.toEqual(getCurrentRows(), { message: "rows should be different" }); + expect(getCurrentRows().length).toBeLessThan(tasks.length, { + message: "not all rows should be displayed", + }); + currentRows = getCurrentRows(); + + // Scroll to the end of the grid + await scroll(content, { top: content.scrollHeight }); + await animationFrame(); + + expect(currentRows).not.toEqual(getCurrentRows(), { message: "rows should be different" }); + expect(getCurrentRows().length).toBeLessThan(tasks.length, { + message: "not all rows should be displayed", + }); + + // Scroll back to top + await scroll(content, { top: 0 }); + await animationFrame(); + + // FIXME: virtual hook: rows are not exactly the same after scrolling once for some reason + // expect(getCurrentRows()).toEqual(initialRows, { + // message: "rows should be the same as initially", + // }); + }); + + test("Edition navigate with tab/shift+tab and enter key", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` + + + + + + + + `, + }); + + 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, ".o_grid_cell_readonly"); + 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(); + + // 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("Add custom buttons in grid view", async () => { + await mountView({ + type: "grid", + resModel: "analytic.line", + arch: ` +