From 1316dc53ab1e171f6ff9d82285a820239ad5d322 Mon Sep 17 00:00:00 2001 From: "stefanorigano (SRI)" Date: Tue, 7 Sep 2021 06:22:08 +0000 Subject: [PATCH 01/31] [FIX] web: correctly target 'o_dropdown_item' child Partially revert b0c325 removing rules defining '' children as exceptions. Indeed, to have a child wrapping the entry's content (either or ) is now considered the norm. This commit will extend bootstrap 'dropdown-item' design to any 'o_dropdown_item' child rather than on 'o_dropdown_item' itself. Part-of: odoo/odoo#73311 --- addons/web/static/src/core/dropdown/dropdown.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/addons/web/static/src/core/dropdown/dropdown.scss b/addons/web/static/src/core/dropdown/dropdown.scss index 7cbacae0411..b77fd883020 100644 --- a/addons/web/static/src/core/dropdown/dropdown.scss +++ b/addons/web/static/src/core/dropdown/dropdown.scss @@ -122,6 +122,14 @@ } } + &_item > *:first-child:last-child { + @extend .dropdown-item; + } + + &_item.o_dropdown_active > *:first-child:last-child { + @extend .dropdown-item:hover; + } + &_menu_group { @extend .dropdown-header; } From 7f025503aedd88f0480e43799ec0a225ee0b2512 Mon Sep 17 00:00:00 2001 From: Stefano Rigano Date: Tue, 7 Sep 2021 06:22:10 +0000 Subject: [PATCH 02/31] [IMP] web: handle owl nested dropdowns Part-of: odoo/odoo#73311 --- .../static/src/core/dropdown/dropdown.scss | 71 +++++++++++++++++++ .../src/webclient/webclient_layout.scss | 12 ++++ 2 files changed, 83 insertions(+) diff --git a/addons/web/static/src/core/dropdown/dropdown.scss b/addons/web/static/src/core/dropdown/dropdown.scss index b77fd883020..ebd3bffa123 100644 --- a/addons/web/static/src/core/dropdown/dropdown.scss +++ b/addons/web/static/src/core/dropdown/dropdown.scss @@ -135,6 +135,77 @@ } } +// = Owl Nested Dropdowns +// ---------------------------------------------------------------------------- + +.o_dropdown .o_dropdown { + + // Define submenus 'inline' design (nested into the parent menu) + @mixin -o-inline-dropdown-menu { + .o_dropdown_toggler.o_dropdown_active:after { + content: "\f0d7"; + } + + .o_dropdown_menu { + left: 0; + position: relative; + max-height: none; + box-shadow: none; + margin-top: 0; + margin-bottom: $dropdown-padding-y; + padding-top: 0; + background: rgba(#000, .01); + border-width: 0 0 1px 0; + + &:last-child { + border-bottom-width: 0; + margin-bottom: 0; + } + } + } + + // The toggler looks like a regular menu entry + .o_dropdown_toggler { + @extend .dropdown-item; + + &:after { + @include o-position-absolute($right: 0); + transform: translate(-.6em, .3em); + font: .9em/1em FontAwesome; + content: "\f0da"; + } + } + + // Small screen: all submenus are inline + @include media-breakpoint-down('lg') { + @include -o-inline-dropdown-menu ; + } + + // Large screen: submenus floats... + @include media-breakpoint-up('lg') { + .o_dropdown_menu { + top: 0; + left: 100%; + } + + //... except when specified otherwise + &.o_dropdown_inline { + @include -o-inline-dropdown-menu ; + } + } +} + +// Unfortunately is not possible to set the parent menu's overflow to be 'visible' +// horizontally (to show floating submenus) and 'auto/scroll' vertically (to hide +// menu entries when the menu is very long). +// We will set it 'visible' letting 'o_view_controller' handling scrollbars. +@include media-breakpoint-up('lg') { + .o_dropdown_menu { + overflow: visible; + max-height: none; + } +} + // = Owl components specific rules // ----------------------------------------------------------------------------- .o_dropdown_item:not(.disabled):not(:disabled) { diff --git a/addons/web/static/src/webclient/webclient_layout.scss b/addons/web/static/src/webclient/webclient_layout.scss index daaebf51f40..0a9094809d5 100644 --- a/addons/web/static/src/webclient/webclient_layout.scss +++ b/addons/web/static/src/webclient/webclient_layout.scss @@ -19,6 +19,18 @@ html { height: 100%; display: flex; flex-flow: column nowrap; + + @include media-breakpoint-up(lg) { + overflow: auto; + + -ms-overflow-style: none; // IE and Edge + scrollbar-width: none; // Firefox + + &::-webkit-scrollbar { // Chrome, Safari and Opera + display: none; + } + } + > .o_control_panel { flex: 0 0 auto; } From 627c7768c9ab2357b3380c5c963e9c7dee9cdc9b Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 7 Sep 2021 06:22:12 +0000 Subject: [PATCH 03/31] [FIX] web: Support primitives for 'in' in domains Before this commit, the new webclient 'Domain' module only supported arrays for domains constructed with an 'in' operator. Now, it also supports any primitive, that will then be wrapped in an array. Part-of: odoo/odoo#73311 --- addons/web/static/src/core/domain.js | 20 ++++++++++---------- addons/web/static/tests/core/domain_tests.js | 4 ++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js index fa92462d090..af1e0f5c3a4 100644 --- a/addons/web/static/src/core/domain.js +++ b/addons/web/static/src/core/domain.js @@ -203,16 +203,16 @@ function matchCondition(record, condition) { return fieldValue > value; case ">=": return fieldValue >= value; - case "in": - if (Array.isArray(fieldValue)) { - return fieldValue.some((fv) => value.includes(fv)); - } - return value.includes(fieldValue); - case "not in": - if (Array.isArray(fieldValue)) { - return !fieldValue.some((fv) => value.includes(fv)); - } - return !value.includes(fieldValue); + case "in": { + const val = Array.isArray(value) ? value : [value]; + const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; + return fieldVal.some((fv) => val.includes(fv)); + } + case "not in": { + const val = Array.isArray(value) ? value : [value]; + const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; + return !fieldVal.some((fv) => val.includes(fv)); + } case "like": if (fieldValue === false) { return false; diff --git a/addons/web/static/tests/core/domain_tests.js b/addons/web/static/tests/core/domain_tests.js index d3c7edc76b7..f7534d6b4fa 100644 --- a/addons/web/static/tests/core/domain_tests.js +++ b/addons/web/static/tests/core/domain_tests.js @@ -205,12 +205,16 @@ QUnit.module("domain", {}, () => { }); QUnit.test("other operators", function (assert) { + assert.ok(new Domain([["a", "in", 3]]).contains({ a: 3 })); assert.ok(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 3 })); assert.ok(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [3] })); + assert.notOk(new Domain([["a", "in", 3]]).contains({ a: 5 })); assert.notOk(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 5 })); assert.notOk(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [5] })); + assert.notOk(new Domain([["a", "not in", 3]]).contains({ a: 3 })); assert.notOk(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 3 })); assert.notOk(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [3] })); + assert.ok(new Domain([["a", "not in", 3]]).contains({ a: 5 })); assert.ok(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 5 })); assert.ok(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [5] })); assert.ok(new Domain([["a", "like", "abc"]]).contains({ a: "abc" })); From 659e7ca9757d786763b5fb2c6affc548894ce1cb Mon Sep 17 00:00:00 2001 From: "Lucas Perais (lpe)" Date: Tue, 7 Sep 2021 06:22:13 +0000 Subject: [PATCH 04/31] [IMP] web: core: deepCopy function for Objects Part-of: odoo/odoo#73311 --- addons/web/static/src/core/utils/objects.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/addons/web/static/src/core/utils/objects.js b/addons/web/static/src/core/utils/objects.js index 7962bdf01e3..4e7638b0b53 100644 --- a/addons/web/static/src/core/utils/objects.js +++ b/addons/web/static/src/core/utils/objects.js @@ -10,3 +10,14 @@ export function shallowEqual(obj1, obj2) { obj1Keys.every((key) => obj1[key] === obj2[key]) ); } + +/** + * Deep copies an object. As it relies on JSON this function as some limitations + * - no support for circular objects + * - no support for specific classes, that will at best be lost and at worst crash (Map, Set etc...) + * @param {Object} An object that is fully JSON stringifiable + * @return {Object} + */ +export function deepCopy(obj) { + return JSON.parse(JSON.stringify(obj)); +} From 6cfe05ae4bbfeae41a79e8eb902e4660c11f19f2 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Tue, 7 Sep 2021 06:22:15 +0000 Subject: [PATCH 05/31] [REV] web: do not close dropdowns on active el change This is actually not what we want. It changes the behavior of the favorite menu: when the user deletes an item, a confirm dialog opens, but we want the dropdown to remain visible. Part-of: odoo/odoo#73311 --- .../web/static/src/core/dropdown/dropdown.js | 6 -- .../web/static/tests/core/dropdown_tests.js | 66 ------------------- 2 files changed, 72 deletions(-) diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index 6b15cda3461..fdaaec9f9bf 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -48,12 +48,6 @@ export class Dropdown extends Component { // Set up UI active element related behavior --------------------------- this.ui = useService("ui"); - useBus(this.ui.bus, "active-element-changed", (activeElement) => { - if (activeElement !== this.myActiveEl && this.state.open) { - // Close when UI active element changes to something different - this.close(); - } - }); useEffect( () => { Promise.resolve().then(() => { diff --git a/addons/web/static/tests/core/dropdown_tests.js b/addons/web/static/tests/core/dropdown_tests.js index 910fc7e6739..c6d1f5b8a3d 100644 --- a/addons/web/static/tests/core/dropdown_tests.js +++ b/addons/web/static/tests/core/dropdown_tests.js @@ -146,32 +146,6 @@ QUnit.module("Components", ({ beforeEach }) => { assert.containsNone(parent.el, "ul.o_dropdown_menu"); }); - QUnit.test("close on activeElement change", async (assert) => { - class Parent extends owl.Component { - setup() { - this.dc = mainComponentsRegistry.get("DialogContainer"); - } - } - Parent.template = owl.tags.xml` -
- - -
- `; - class CustomDialog extends Dialog {} - CustomDialog.title = "Dropdown Killer"; - serviceRegistry.add("dialog", dialogService); - env = await makeTestEnv(); - parent = await mount(Parent, { env, target }); - await click(parent.el, "button.o_dropdown_toggler"); - await nextTick(); // await that the dropdown sets its active element - assert.containsOnce(parent.el, "ul.o_dropdown_menu"); - env.services.dialog.add(CustomDialog); - await nextTick(); // await that the dropdown starts to close - await nextTick(); // await that the dropdown is closed - assert.containsNone(parent.el, "ul.o_dropdown_menu"); - }); - QUnit.test("close on item selection", async (assert) => { class Parent extends owl.Component {} Parent.template = owl.tags.xml` @@ -256,46 +230,6 @@ QUnit.module("Components", ({ beforeEach }) => { assert.containsNone(parent.el, "ul.o_dropdown_menu"); }); - QUnit.test("multi-level dropdown: close on activeElement change", async (assert) => { - patchWithCleanup(Dropdown.prototype, { - close() { - assert.step("closed"); - this._super(); - }, - }); - class Parent extends owl.Component { - setup() { - this.dc = mainComponentsRegistry.get("DialogContainer"); - } - } - Parent.template = owl.tags.xml` -
- - - - - - -
- `; - class CustomDialog extends Dialog {} - CustomDialog.title = "Dropdown Killer"; - serviceRegistry.add("dialog", dialogService); - env = await makeTestEnv(); - parent = await mount(Parent, { env, target }); - await click(parent.el, "button.o_dropdown_toggler:last-child"); - await click(parent.el, "button.o_dropdown_toggler:last-child"); - await click(parent.el, "button.o_dropdown_toggler:last-child"); - assert.containsN(parent.el, "ul.o_dropdown_menu", 3); - await nextTick(); // await that the dropdowns set their active elements - env.services.dialog.add(CustomDialog); - assert.verifySteps([]); - await nextTick(); // await that the dropdowns start to close - await nextTick(); // await that the dropdowns are closed - assert.containsNone(parent.el, "ul.o_dropdown_menu"); - assert.verifySteps(["closed", "closed", "closed"]); - }); - QUnit.test("multi-level dropdown: close on item selection", async (assert) => { class Parent extends owl.Component {} Parent.template = owl.tags.xml` From 1fd54eeae715fc445e9f9bb1be3b74c879635003 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Tue, 7 Sep 2021 06:22:17 +0000 Subject: [PATCH 06/31] [IMP] web: introduce useTooltip hook This hook automatically generates custom tooltips (instead of native ones) for all elements with the "data-tooltip" attribute set, inside the component using it. The optional attribute "data-tooltip-position" can be used to enforce a specific tooltip position. This commit also makes the WebClient use this hook. Part-of: odoo/odoo#73311 Co-authored-by: Lucas Perais (lpe) --- addons/web/static/src/core/tooltip/tooltip.js | 11 + .../web/static/src/core/tooltip/tooltip.scss | 25 +++ .../web/static/src/core/tooltip/tooltip.xml | 11 + .../static/src/core/tooltip/tooltip_hook.js | 102 +++++++++ addons/web/static/src/core/utils/hooks.js | 78 +++++++ addons/web/static/src/webclient/webclient.js | 2 + ...{popover_hook.js => popover_hook_tests.js} | 0 .../tests/core/tooltip/tooltip_hook_tests.js | 206 ++++++++++++++++++ .../static/tests/core/utils/hooks_tests.js | 92 +++++++- odoo/tools/translate.py | 2 +- 10 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 addons/web/static/src/core/tooltip/tooltip.js create mode 100644 addons/web/static/src/core/tooltip/tooltip.scss create mode 100644 addons/web/static/src/core/tooltip/tooltip.xml create mode 100644 addons/web/static/src/core/tooltip/tooltip_hook.js rename addons/web/static/tests/core/popover/{popover_hook.js => popover_hook_tests.js} (100%) create mode 100644 addons/web/static/tests/core/tooltip/tooltip_hook_tests.js diff --git a/addons/web/static/src/core/tooltip/tooltip.js b/addons/web/static/src/core/tooltip/tooltip.js new file mode 100644 index 00000000000..f35de09b408 --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +const { Component } = owl; + +export class Tooltip extends Component {} +Tooltip.template = "web.Tooltip"; +Tooltip.props = { + tooltip: String, + Component: { optional: true }, + props: { optional: true }, +}; diff --git a/addons/web/static/src/core/tooltip/tooltip.scss b/addons/web/static/src/core/tooltip/tooltip.scss new file mode 100644 index 00000000000..98933b8ad56 --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.scss @@ -0,0 +1,25 @@ +.o-tooltip { + font-size: smaller; + + .o-tooltip--string { + background-color: $o-tooltip-title-background-color; + font-weight: bold; + padding: 5px 8px; + } + + .o-tooltip--help { + white-space: pre-line; + padding: 8px; + margin-bottom: 0px; + } + + .o-tooltip--technical { + padding: 8px; + margin: 0 0 0 15px; + list-style-type: circle; + + .o-tooltip--technical--title { + font-weight: bold; + } + } +} diff --git a/addons/web/static/src/core/tooltip/tooltip.xml b/addons/web/static/src/core/tooltip/tooltip.xml new file mode 100644 index 00000000000..d9c74413ede --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip.xml @@ -0,0 +1,11 @@ + + + + +
+ + +
+
+ +
diff --git a/addons/web/static/src/core/tooltip/tooltip_hook.js b/addons/web/static/src/core/tooltip/tooltip_hook.js new file mode 100644 index 00000000000..96c6d0065cb --- /dev/null +++ b/addons/web/static/src/core/tooltip/tooltip_hook.js @@ -0,0 +1,102 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { useListener } from "@web/core/utils/hooks"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { Tooltip } from "./tooltip"; +import { registry } from "@web/core/registry"; + +/** + * The useTooltip hook allows to display custom tooltips on every elements with + * a "data-tooltip" attribute inside the Component using the hook. This attribute + * can be set on elements for which we prefer a custom tooltip instead of the + * native one displaying the value of the "title" attribute. + * + * Usage (with a parent somewhere using the useTooltip hook): + * + * + * The ideal position of the tooltip can be specified thanks to the attribute + * "data-tooltip-position": + * + */ +const OPEN_DELAY = 400; +const CLOSE_DELAY = 200; +export function useTooltip() { + const popover = usePopover(); + let openTooltipTimeout; + let closeTooltip; + let target = null; + + /** + * Closes the currently opened tooltip if any, or prevent it from opening. + */ + function cleanup() { + browser.clearTimeout(openTooltipTimeout); + if (closeTooltip) { + closeTooltip(); + } + } + + /** + * Opens the tooltip after a delay, if the event's target has a data-tooltip + * attribute. + * + * @param {MouseEvent} ev a "mouseenter" event + */ + function onMouseEnter(ev) { + const dataset = ev.target.dataset; + const tooltip = dataset.tooltip; + + let Component, props; + if ("tooltipComponent" in dataset) { + ({ Component, props } = JSON.parse(dataset["tooltipComponent"])); + Component = registry.category("tooltips").get(Component, null); + } + if (tooltip || Component) { + cleanup(); + target = ev.target; + openTooltipTimeout = browser.setTimeout(() => { + const position = dataset.tooltipPosition; + closeTooltip = popover.add( + target, + Tooltip, + { tooltip, Component, props }, + { position } + ); + }, OPEN_DELAY); + } + } + + // Track mouse position to be able to detect that we are no longer hovering + // the target, thus that we should close the tooltip + let positionX; + let positionY; + useListener("mousemove", (ev) => { + positionX = ev.x; + positionY = ev.y; + }); + + // Regularly check that the target is still in the DOM and we're still + // hovering it, because if not, we have to close the tooltip + browser.setInterval(() => { + if (!target) { + return; + } + if (!document.body.contains(target)) { + return cleanup(); // target is no longer in the DOM + } + const targetRect = target.getBoundingClientRect(); + if ( + positionX < targetRect.left || + positionX > targetRect.right || + positionY < targetRect.top || + positionY > targetRect.bottom + ) { + return cleanup(); // mouse is no longer hovering the target + } + }, CLOSE_DELAY); + + // Listen (using event delegation) to "mouseenter" events on all nodes with + // the "data-tooltip" attribute, to open the tooltip. + useListener("mouseenter", "[data-tooltip]", onMouseEnter, { capture: true }); +} diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js index fabcf18aeeb..c870d5e8659 100644 --- a/addons/web/static/src/core/utils/hooks.js +++ b/addons/web/static/src/core/utils/hooks.js @@ -130,6 +130,84 @@ export function useEffect(effect, computeDependencies = () => [NaN]) { onWillUnmount(() => cleanup()); } +// ----------------------------------------------------------------------------- +// useListener +// ----------------------------------------------------------------------------- + +/** + * The useListener hook offers an alternative to Owl's classical event + * registration mechanism (with attribute 't-on-eventName' in xml). + * + * It is especially useful for abstract components, meant to be extended by + * specific ones. If those abstract components need to define event handlers, + * but don't have any template (because the template completely depends on + * specific cases), then using the 't-on' mechanism isn't adequate, as the + * handlers would be lost by the template override. In this case, using this + * hook instead is more convenient. + * + * Usage: like all Owl hooks, this function has to be called in the + * constructor of an Owl component: + * + * useListener('click', () => { console.log('clicked'); }); + * + * It also allows to do event delegation, by specifying a native query selector + * as second argument. In this case, the handler is only called if the event + * is triggered on an element matching the given selector. + * + * useListener('click', 'button', () => { console.log('clicked'); }); + * + * Note: components that alter the event's target (e.g. Portal) are not + * expected to behave as expected with event delegation. + * + * @param {string} eventName the name of the event + * @param {string} [querySelector] a JS native selector for event delegation + * @param {function} handler the event handler (will be bound to the component) + * @param {Object} [options] to be passed to addEventListener as options. + * Useful for listening in the capture phase + */ +export function useListener(eventName, querySelector, handler, options = {}) { + if (typeof arguments[1] !== "string") { + querySelector = null; + handler = arguments[1]; + options = arguments[2] || {}; + } + if (typeof handler !== "function") { + throw new Error("The handler must be a function"); + } + + const comp = useComponent(); + let boundHandler; + if (querySelector) { + boundHandler = function (ev) { + let el = ev.target; + let target; + while (el && !target) { + if (el.matches(querySelector)) { + target = el; + } else if (el === comp.el) { + el = null; + } else { + el = el.parentElement; + } + } + if (el) { + handler.call(comp, ev); + } + }; + } else { + boundHandler = handler.bind(comp); + } + useEffect( + () => { + comp.el.addEventListener(eventName, boundHandler, options); + return () => { + comp.el.removeEventListener(eventName, boundHandler, options); + }; + }, + () => [] + ); +} + // ----------------------------------------------------------------------------- // useService // ----------------------------------------------------------------------------- diff --git a/addons/web/static/src/webclient/webclient.js b/addons/web/static/src/webclient/webclient.js index 3bcd54e6fdb..0d15fbf5b79 100644 --- a/addons/web/static/src/webclient/webclient.js +++ b/addons/web/static/src/webclient/webclient.js @@ -3,6 +3,7 @@ import { ActionContainer } from "./actions/action_container"; import { NavBar } from "./navbar/navbar"; import { useBus, useEffect, useService } from "@web/core/utils/hooks"; +import { useTooltip } from "@web/core/tooltip/tooltip_hook"; import { NotUpdatable } from "../core/utils/components"; import { MainComponentsContainer } from "../core/main_components_container"; import { useOwnDebugContext } from "../core/debug/debug_context"; @@ -44,6 +45,7 @@ export class WebClient extends Component { () => [] ); useExternalListener(window, "click", this.onGlobalClick, { capture: true }); + useTooltip(); } mounted() { diff --git a/addons/web/static/tests/core/popover/popover_hook.js b/addons/web/static/tests/core/popover/popover_hook_tests.js similarity index 100% rename from addons/web/static/tests/core/popover/popover_hook.js rename to addons/web/static/tests/core/popover/popover_hook_tests.js diff --git a/addons/web/static/tests/core/tooltip/tooltip_hook_tests.js b/addons/web/static/tests/core/tooltip/tooltip_hook_tests.js new file mode 100644 index 00000000000..252fae3e06d --- /dev/null +++ b/addons/web/static/tests/core/tooltip/tooltip_hook_tests.js @@ -0,0 +1,206 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { PopoverContainer } from "@web/core/popover/popover_container"; +import { popoverService } from "@web/core/popover/popover_service"; +import { useTooltip } from "@web/core/tooltip/tooltip_hook"; +import { registry } from "@web/core/registry"; +import { registerCleanup } from "../../helpers/cleanup"; +import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env"; +import { getFixture, nextTick, patchWithCleanup, triggerEvent } from "../../helpers/utils"; + +const { Component, mount, useState } = owl; +const { xml } = owl.tags; + +const mainComponents = registry.category("main_components"); + +/** + * Creates and mounts a parent component that use the "useTooltip" hook. + * + * @param {Component} Child a child Component that contains nodes with "data-tooltip" attribute + * @param {Object} [options] + * @param {Object} options.mockSetTimeout the mocked setTimeout to use (by default, calls the + * callback directly) + * @returns {Promise} + */ +async function makeParent(Child, options = {}) { + const fixture = getFixture(); + + // add the popover service to the registry -> will add the PopoverContainer + // to the mainComponentRegistry + clearRegistryWithCleanup(mainComponents); + registry.category("services").add("popover", popoverService); + const env = await makeTestEnv(); + + class Parent extends Component { + setup() { + this.Components = mainComponents.getEntries(); + useTooltip(); + } + } + Parent.template = xml` +
+ +
+ + + +
+
`; + Parent.components = { PopoverContainer, Child }; + + patchWithCleanup(browser, { + setTimeout: options.mockSetTimeout || ((fn) => fn()), + setInterval: options.mockSetInterval || ((fn) => fn()), + }); + + const comp = await mount(Parent, { env, target: fixture }); + registerCleanup(() => comp.destroy()); + return comp; +} + +QUnit.module("Tooltip hook", () => { + QUnit.test("basic rendering", async (assert) => { + class MyComponent extends Component {} + MyComponent.template = xml``; + let simulateTimeout; + const mockSetTimeout = (fn) => { + simulateTimeout = fn; + }; + let simulateInterval; + const mockSetInterval = (fn) => { + simulateInterval = fn; + }; + const parent = await makeParent(MyComponent, { mockSetTimeout, mockSetInterval }); + + assert.containsNone(parent, ".o_popover_container .o_popover"); + parent.el.querySelector("button").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsNone(parent, ".o_popover_container .o_popover"); + + simulateTimeout(); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual( + parent.el.querySelector(".o_popover_container .o_popover").innerText, + "hello" + ); + + const buttonRect = parent.el.querySelector("button").getBoundingClientRect(); + const x = buttonRect.right + 10; + const y = buttonRect.bottom + 10; + await triggerEvent(parent.el, "button", "mousemove", { + pageX: x, + layerX: x, + screenX: x, + pageY: y, + layerY: y, + screenY: y, + }); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + simulateInterval(); + await nextTick(); + assert.containsNone(parent, ".o_popover_container .o_popover"); + }); + + QUnit.test("remove element with opened tooltip", async (assert) => { + let compState; + class MyComponent extends Component { + setup() { + this.state = useState({ visible: true }); + compState = this.state; + } + } + MyComponent.template = xml` +
+ +
`; + let simulateInterval; + const mockSetInterval = (fn) => { + simulateInterval = fn; + }; + const parent = await makeParent(MyComponent, { mockSetInterval }); + + assert.containsOnce(parent, "button"); + assert.containsNone(parent, ".o_popover_container .o_popover"); + parent.el.querySelector("button").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + + compState.visible = false; + await nextTick(); + assert.containsNone(parent, "button"); + simulateInterval(); + await nextTick(); + assert.containsNone(parent, ".o_popover_container .o_popover"); + }); + + QUnit.test("rendering with several tooltips", async (assert) => { + class MyComponent extends Component {} + MyComponent.template = xml` +
+ + +
`; + const parent = await makeParent(MyComponent); + + assert.containsNone(parent, ".o_popover_container .o_popover"); + parent.el.querySelector("button.button_1").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "tooltip 1"); + parent.el.querySelector("button.button_1").dispatchEvent(new Event("mouseleave")); + parent.el.querySelector("button.button_2").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "tooltip 2"); + }); + + QUnit.test("positionning", async (assert) => { + class MyComponent extends Component {} + MyComponent.template = xml` +
+ + + + + +
`; + const parent = await makeParent(MyComponent); + + // default + parent.el.querySelector("button.default").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "default"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--bm"); + + // top + parent.el.querySelector("button.top").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "top"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--tm"); + + // right + parent.el.querySelector("button.right").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "right"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--rm"); + + // bottom + parent.el.querySelector("button.bottom").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "bottom"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--bm"); + + // left + parent.el.querySelector("button.left").dispatchEvent(new Event("mouseenter")); + await nextTick(); + assert.containsOnce(parent, ".o_popover_container .o_popover"); + assert.strictEqual(parent.el.querySelector(".o_popover").innerText, "left"); + assert.hasClass(parent.el.querySelector(".o_popover"), "o-popper-position--lm"); + }); +}); diff --git a/addons/web/static/tests/core/utils/hooks_tests.js b/addons/web/static/tests/core/utils/hooks_tests.js index 901ea18fa80..632b35ed537 100644 --- a/addons/web/static/tests/core/utils/hooks_tests.js +++ b/addons/web/static/tests/core/utils/hooks_tests.js @@ -1,9 +1,10 @@ /** @odoo-module **/ -import { useBus, useEffect, useService } from "@web/core/utils/hooks"; +import { useBus, useEffect, useListener, useService } from "@web/core/utils/hooks"; import { registry } from "@web/core/registry"; import { makeTestEnv } from "../../helpers/mock_env"; -import { getFixture, nextTick } from "../../helpers/utils"; +import { click, getFixture, nextTick } from "../../helpers/utils"; +import { registerCleanup } from "../../helpers/cleanup"; const { Component, mount, tags, useState } = owl; const serviceRegistry = registry.category("services"); @@ -230,6 +231,93 @@ QUnit.module("utils", () => { } ); + QUnit.module("useListener"); + + QUnit.test("useListener: simple usecase", async function (assert) { + class MyComponent extends Component { + setup() { + useListener("click", () => assert.step("click")); + } + } + MyComponent.template = tags.xml``; + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, { env, target }); + registerCleanup(() => { + comp.destroy(); + }); + + await click(comp.el); + assert.verifySteps(["click"]); + }); + + QUnit.test("useListener: event delegation", async function (assert) { + class MyComponent extends Component { + setup() { + this.flag = true; + useListener("click", "button", () => assert.step("click")); + } + } + MyComponent.template = tags.xml` +
+ + +
`; + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, { env, target }); + registerCleanup(() => { + comp.destroy(); + }); + + await click(comp.el); + assert.verifySteps([]); + await click(comp.el.querySelector("button")); + assert.verifySteps(["click"]); + + comp.flag = false; + await comp.render(); + await click(comp.el.querySelector("button span")); + assert.verifySteps(["click"]); + }); + + QUnit.test("useListener: event delegation with capture option", async function (assert) { + class MyComponent extends Component { + setup() { + this.flag = false; + useListener("click", "button", () => assert.step("click"), { capture: true }); + } + } + MyComponent.template = tags.xml` +
+ + +
`; + + const env = await makeTestEnv(); + const target = getFixture(); + const comp = await mount(MyComponent, { env, target }); + registerCleanup(() => { + comp.destroy(); + }); + + await click(comp.el); + assert.verifySteps([]); + await click(comp.el.querySelector("button")); + assert.verifySteps(["click"]); + + comp.flag = false; + await comp.render(); + await click(comp.el.querySelector("button span")); + assert.verifySteps(["click"]); + }); + QUnit.module("useService"); QUnit.test("useService: unavailable service", async function (assert) { diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index f76e813a438..95736b80790 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -149,7 +149,7 @@ TRANSLATED_ELEMENTS = { TRANSLATED_ATTRS = dict.fromkeys({ 'string', 'add-label', 'help', 'sum', 'avg', 'confirm', 'placeholder', 'alt', 'title', 'aria-label', 'aria-keyshortcuts', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext', - 'value_label', + 'value_label', 'data-tooltip', }, lambda e: True) def translate_attrib_value(node): From 57992c51b5b223d2ecae887f108629dea7698f11 Mon Sep 17 00:00:00 2001 From: Jorge Pinna Puissant Date: Tue, 7 Sep 2021 06:22:19 +0000 Subject: [PATCH 07/31] [IMP] web: add archParseBoolean util function This function must be used to parse boolean attributes from archs. If returns false if the attribute is set to "False", "false", "0" or "", true otherwise. Part-of: odoo/odoo#73311 --- addons/web/static/src/views/helpers/utils.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 addons/web/static/src/views/helpers/utils.js diff --git a/addons/web/static/src/views/helpers/utils.js b/addons/web/static/src/views/helpers/utils.js new file mode 100644 index 00000000000..8b0c0ff1ff1 --- /dev/null +++ b/addons/web/static/src/views/helpers/utils.js @@ -0,0 +1,13 @@ +/** @odoo-module **/ + +/** + * Parse the arch to check if is true or false + * If the string is empyt, 0, False or false it's considered as false + * The rest is considered as true + * + * @param {string} str + * @returns {boolean} + */ +export function archParseBoolean(str) { + return str !== "False" && str !== "false" && str !== "0" && str !== ""; +} From c67b9f9907e85019b6adac80976569553f5e6b7a Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Tue, 7 Sep 2021 06:22:21 +0000 Subject: [PATCH 08/31] [IMP] web: new SampleServer class Reintroduce the SampleServer class with some modifications: - it uses luxon instead of moment - it uses the registry from core folder instead of a dedicated one A util function is also provided to help the construction of a fake orm using the sample server in order to get sample data. Part-of: odoo/odoo#73311 Co-authored-by: Samuel Degueldre Co-authored-by: Lucas Perais (lpe) Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Jorge Pinna Puissant --- addons/web/static/src/core/orm_service.js | 2 +- .../static/src/views/helpers/sample_server.js | 711 ++++++++++++++++++ 2 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 addons/web/static/src/views/helpers/sample_server.js diff --git a/addons/web/static/src/core/orm_service.js b/addons/web/static/src/core/orm_service.js index b3c2f053fbc..fea150e4452 100644 --- a/addons/web/static/src/core/orm_service.js +++ b/addons/web/static/src/core/orm_service.js @@ -87,7 +87,7 @@ function validateArray(name, array) { } } -class ORM { +export class ORM { constructor(rpc, user) { this.rpc = rpc; this.user = user; diff --git a/addons/web/static/src/views/helpers/sample_server.js b/addons/web/static/src/views/helpers/sample_server.js new file mode 100644 index 00000000000..63b090149c1 --- /dev/null +++ b/addons/web/static/src/views/helpers/sample_server.js @@ -0,0 +1,711 @@ +/** @odoo-module **/ + +import { groupBy as arrayGroupBy, sortBy as arraySortBy } from "@web/core/utils/arrays"; +import { registry } from "@web/core/registry"; +import { ORM } from "../../core/orm_service"; +import { parseDate } from "@web/core/l10n/dates"; + +class UnimplementedRouteError extends Error {} + +/** + * Helper function returning the value from a list of sample strings + * corresponding to the given ID. + * @param {number} id + * @param {string[]} sampleTexts + * @returns {string} + */ +function getSampleFromId(id, sampleTexts) { + return sampleTexts[(id - 1) % sampleTexts.length]; +} + +/** + * Helper function returning a regular expression specifically matching + * a given 'term' in a fieldName. For example `fieldNameRegex('abc')`: + * will match: + * - "abc" + * - "field_abc__def" + * will not match: + * - "aabc" + * - "abcd_ef" + * @param {...string} term + * @returns {RegExp} + */ +function fieldNameRegex(...terms) { + return new RegExp(`\\b((\\w+)?_)?(${terms.join("|")})(_(\\w+)?)?\\b`); +} + +const MEASURE_SPEC_REGEX = /(?\w+):(?\w+)(\((?\w+)\))?/; +const DESCRIPTION_REGEX = fieldNameRegex("description", "label", "title", "subject", "message"); +const EMAIL_REGEX = fieldNameRegex("email"); +const PHONE_REGEX = fieldNameRegex("phone"); +const URL_REGEX = fieldNameRegex("url"); + +/** + * Sample server class + * + * Represents a static instance of the server used when a RPC call sends + * empty values/groups while the attribute 'sample' is set to true on the + * view. + * + * This server will generate fake data and send them in the adequate format + * according to the route/method used in the RPC. + */ +export class SampleServer { + /** + * @param {string} modelName + * @param {Object} fields + */ + constructor(modelName, fields) { + this.mainModel = modelName; + this.data = {}; + this.data[modelName] = { + fields, + records: [], + }; + // Generate relational fields' co models + for (const fieldName in fields) { + const field = fields[fieldName]; + if (["many2one", "one2many", "many2many"].includes(field.type)) { + this.data[field.relation] = this.data[field.relation] || { + fields: { + display_name: { type: "char" }, + id: { type: "integer" }, + color: { type: "integer" }, + }, + records: [], + }; + } + } + // On some models, empty grouped Kanban or List view still contain + // real (empty) groups. In this case, we re-use the result of the + // web_read_group rpc to tweak sample data s.t. those real groups + // contain sample records. + this.existingGroups = null; + // Sample records generation is only done if necessary, so we delay + // it to the first "mockRPC" call. These flags allow us to know if + // the records have been generated or not. + this.populated = false; + this.existingGroupsPopulated = false; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * This is the main entry point of the SampleServer. Mocks a request to + * the server with sample data. + * @param {Object} params + * @returns {any} the result obtained with the sample data + * @throws {Error} If called on a route/method we do not handle + */ + mockRpc(params) { + if (!(params.model in this.data)) { + throw new Error(`SampleServer: unknown model ${params.model}`); + } + this._populateModels(); + switch (params.method || params.route) { + case "/web/dataset/search_read": + return this._mockSearchReadController(params); + case "web_read_group": + return this._mockWebReadGroup(params); + case "read_group": + return this._mockReadGroup(params); + case "read_progress_bar": + return this._mockReadProgressBar(params); + case "read": + return this._mockRead(params); + } + // this rpc can't be mocked by the SampleServer itself, so check if there is an handler + // in the registry: either specific for this model (with key 'model/method'), or + // global (with key 'method') + const method = params.method || params.route; + // This allows to register mock version of methods or routes, + // for all models: + // registry.category("sample_server").add('some_route', () => "abcd"); + // for a specific model (e.g. 'res.partner'): + // registry.category("sample_server").add('res.partner/some_method', () => 23); + const mockFunction = + registry.category("sample_server").get(`${params.model}/${method}`, null) || + registry.category("sample_server").get(method, null); + if (mockFunction) { + return mockFunction.call(this, params); + } + console.log(`SampleServer: unimplemented route "${params.method || params.route}"`); + throw new SampleServer.UnimplementedRouteError(); + } + + setExistingGroups(groups) { + this.existingGroups = groups; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @param {Object[]} measures, each measure has the form { fieldName, type } + * @param {Object[]} records + * @returns {Object} + */ + _aggregateFields(measures, records) { + const values = {}; + for (const { fieldName, type } of measures) { + if (["float", "integer", "monetary"].includes(type)) { + if (records.length) { + let value = 0; + for (const record of records) { + value += record[fieldName]; + } + values[fieldName] = this._sanitizeNumber(value); + } else { + values[fieldName] = null; + } + } + if (type === "many2one") { + const ids = new Set(records.map((r) => r[fieldName])); + values.fieldName = ids.size || null; + } + } + return values; + } + + /** + * @param {any} value + * @param {Object} options + * @param {string} [options.interval] + * @param {string} [options.relation] + * @param {string} [options.type] + * @returns {any} + */ + _formatValue(value, options) { + if (!value) { + return false; + } + const { type, interval, relation } = options; + if (["date", "datetime"].includes(type)) { + const fmt = SampleServer.FORMATS[interval]; + return parseDate(value).toFormat(fmt); + } else if (type === "many2one") { + const rec = this.data[relation].records.find(({ id }) => id === value); + return [value, rec.display_name]; + } else { + return value; + } + } + + /** + * Generates field values based on heuristics according to field types + * and names. + * + * @private + * @param {string} modelName + * @param {string} fieldName + * @param {number} id the record id + * @returns {any} the field value + */ + _generateFieldValue(modelName, fieldName, id) { + const field = this.data[modelName].fields[fieldName]; + switch (field.type) { + case "boolean": + return fieldName === "active" ? true : this._getRandomBool(); + case "char": + case "text": + if (["display_name", "name"].includes(fieldName)) { + if (SampleServer.PEOPLE_MODELS.includes(modelName)) { + return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE); + } else if (modelName === "res.country") { + return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES); + } + } + if (fieldName === "display_name") { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (["name", "reference"].includes(fieldName)) { + return `REF${String(id).padStart(4, "0")}`; + } else if (DESCRIPTION_REGEX.test(fieldName)) { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (EMAIL_REGEX.test(fieldName)) { + const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE) + .replace(/ /, ".") + .toLowerCase(); + return `${emailName}@sample.demo`; + } else if (PHONE_REGEX.test(fieldName)) { + return `+1 555 754 ${String(id).padStart(4, "0")}`; + } else if (URL_REGEX.test(fieldName)) { + return `http://sample${id}.com`; + } + return false; + case "date": + case "datetime": { + const format = field.type === "date" ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm:ss"; + return this._getRandomDate(format); + } + case "float": + return this._getRandomFloat(SampleServer.MAX_FLOAT); + case "integer": { + let max = SampleServer.MAX_INTEGER; + if (fieldName.includes("color")) { + max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0; + } + return this._getRandomInt(max); + } + case "monetary": + return this._getRandomInt(SampleServer.MAX_MONETARY); + case "many2one": + if (field.relation === "res.currency") { + /** @todo return session.company_currency_id */ + return 1; + } + if (field.relation === "ir.attachment") { + return false; + } + return this._getRandomSubRecordId(); + case "one2many": + case "many2many": { + const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()]; + return [...new Set(ids)]; + } + case "selection": { + // I hoped we wouldn't have to implement such special cases, but here it is. + // If this (mail) field is set, 'Warning' is displayed instead of the last + // activity, and we don't want to see a bunch of 'Warning's in a list. In the + // future, if we have to implement several special cases like that, we'll setup + // a proper hook to allow external modules to define extensions of this function. + // For now, as we have only one use case, I guess that doing it here is fine. + if (fieldName === "activity_exception_decoration") { + return false; + } + if (field.selection.length > 0) { + return this._getRandomArrayEl(field.selection)[0]; + } + return false; + } + default: + return false; + } + } + + /** + * @private + * @param {any[]} array + * @returns {any} + */ + _getRandomArrayEl(array) { + return array[Math.floor(Math.random() * array.length)]; + } + + /** + * @private + * @returns {boolean} + */ + _getRandomBool() { + return Math.random() < 0.5; + } + + /** + * @private + * @param {string} format + * @returns {moment} + */ + _getRandomDate(format) { + const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA); + return luxon.DateTime.local().plus({ hours: delta }).toFormat(format); + } + + /** + * @private + * @param {number} max + * @returns {number} float in [O, max[ + */ + _getRandomFloat(max) { + return this._sanitizeNumber(Math.random() * max); + } + + /** + * @private + * @param {number} max + * @returns {number} int in [0, max[ + */ + _getRandomInt(max) { + return Math.floor(Math.random() * max); + } + + /** + * @private + * @returns {number} id in [1, SUB_RECORDSET_SIZE] + */ + _getRandomSubRecordId() { + return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1; + } + /** + * Mocks calls to the read method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Array[]} params.args (args[0] is the list of ids, args[1] is + * the list of fields) + * @returns {Object[]} + */ + _mockRead(params) { + const model = this.data[params.model]; + const ids = params.args[0]; + const fieldNames = params.args[1]; + const records = []; + for (const r of model.records) { + if (!ids.includes(r.id)) { + continue; + } + const record = { id: r.id }; + for (const fieldName of fieldNames) { + const field = model.fields[fieldName]; + if (!field) { + record[fieldName] = false; // unknown field + } else if (field.type === "many2one") { + const relModel = this.data[field.relation]; + const relRecord = relModel.records.find((relR) => r[fieldName] === relR.id); + record[fieldName] = relRecord ? [relRecord.id, relRecord.display_name] : false; + } else { + record[fieldName] = r[fieldName]; + } + } + records.push(record); + } + return records; + } + + /** + * Mocks calls to the read_group method. + * + * @param {Object} params + * @param {string} params.model + * @param {string[]} [params.fields] defaults to the list of all fields + * @param {string[]} params.groupBy + * @param {boolean} [params.lazy=true] + * @returns {Object[]} Object with keys groups and length + */ + _mockReadGroup(params) { + const lazy = "lazy" in params ? params.lazy : true; + const model = params.model; + const fields = this.data[model].fields; + const records = this.data[model].records; + + const normalizedGroupBys = []; + let groupBy = []; + if (params.groupBy.length) { + groupBy = lazy ? [params.groupBy[0]] : params.groupBy; + } + for (const groupBySpec of groupBy) { + let [fieldName, interval] = groupBySpec.split(":"); + interval = interval || "month"; + const { type, relation } = fields[fieldName]; + if (type) { + const gb = { fieldName, type, interval, relation, alias: groupBySpec }; + normalizedGroupBys.push(gb); + } + } + const groups = arrayGroupBy(records, (record) => { + const vals = {}; + for (const gb of normalizedGroupBys) { + const { fieldName, type } = gb; + let value; + if (["date", "datetime"].includes(type)) { + value = this._formatValue(record[fieldName], gb); + } else { + value = record[fieldName]; + } + vals[fieldName] = value; + } + return JSON.stringify(vals); + }); + const measures = []; + for (const measureSpec of params.fields || Object.keys(fields)) { + const matches = measureSpec.match(MEASURE_SPEC_REGEX); + const { fieldName, aggregateFunction, measure } = (matches && matches.groups) || {}; + if (!fieldName && !measure) { + continue; // this is for _count measure + } + const fName = fieldName || measure; + const { type } = fields[fName]; + if ( + !params.groupBy.includes(fName) && + type && + (type !== "many2one" || aggregateFunction !== "count_distinct") + ) { + measures.push({ fieldName: fName, type }); + } + } + + let result = []; + for (const id in groups) { + const records = groups[id]; + const group = { __domain: [] }; + let countKey = `__count`; + if (normalizedGroupBys.length && lazy) { + countKey = `${normalizedGroupBys[0].fieldName}_count`; + } + group[countKey] = records.length; + const firstElem = records[0]; + for (const gb of normalizedGroupBys) { + const { alias, fieldName } = gb; + group[alias] = this._formatValue(firstElem[fieldName], gb); + } + Object.assign(group, this._aggregateFields(measures, records)); + result.push(group); + } + if (normalizedGroupBys.length > 0) { + const { alias, interval, type } = normalizedGroupBys[0]; + result = arraySortBy(result, (group) => { + const val = group[alias]; + if (["date", "datetime"].includes(type)) { + return parseDate(val, { format: SampleServer.FORMATS[interval] }); + } + return val; + }); + } + return result; + } + + /** + * Mocks calls to the read_progress_bar method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Object} params.kwargs + * @return {Object} + */ + _mockReadProgressBar(params) { + const groupBy = params.kwargs.group_by; + const progress_bar = params.kwargs.progress_bar; + const groupByField = this.data[params.model].fields[groupBy]; + const data = {}; + for (const record of this.data[params.model].records) { + let groupByValue = record[groupBy]; + if (groupByField.type === "many2one") { + const relatedRecords = this.data[groupByField.relation].records; + const relatedRecord = relatedRecords.find((r) => r.id === groupByValue); + groupByValue = relatedRecord.display_name; + } + if (!(groupByValue in data)) { + data[groupByValue] = {}; + for (const key in progress_bar.colors) { + data[groupByValue][key] = 0; + } + } + const fieldValue = record[progress_bar.field]; + if (fieldValue in data[groupByValue]) { + data[groupByValue][fieldValue]++; + } + } + return data; + } + + /** + * Mocks calls to the /web/dataset/search_read route to return sample + * records. + * @private + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @returns {{ records: Object[], length: number }} + */ + _mockSearchReadController(params) { + const model = this.data[params.model]; + const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT); + const records = this._mockRead({ + model: params.model, + args: [rawRecords.map((r) => r.id), params.fields], + }); + return { records, length: records.length }; + } + + /** + * Mocks calls to the web_read_group method to return groups populated + * with sample records. Only handles the case where the real call to + * web_read_group returned groups, but none of these groups contain + * records. In this case, we keep the real groups, and populate them + * with sample records. + * @private + * @param {Object} params + * @param {Object} [result] the result of a real call to web_read_group + * @returns {{ groups: Object[], length: number }} + */ + _mockWebReadGroup(params) { + let groups; + if (this.existingGroups) { + this._tweakExistingGroups(params); + groups = this.existingGroups; + } else { + groups = this._mockReadGroup(params); + } + return { + groups, + length: groups.length, + }; + } + + /** + * Updates the sample data such that the existing groups (in database) + * also exists in the sample, and such that there are sample records in + * those groups. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.groupBy + */ + _populateExistingGroups(params) { + if (!this.existingGroupsPopulated) { + const groups = this.existingGroups; + this.groupsInfo = groups; + const groupBy = params.groupBy[0]; + const values = groups.map((g) => g[groupBy]); + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === "many2one"; + if (groupedByM2O) { + // re-populate co model with relevant records + this.data[groupByField.relation].records = values.map((v) => { + return { id: v[0], display_name: v[1] }; + }); + } + for (const r of this.data[params.model].records) { + const value = getSampleFromId(r.id, values); + r[groupBy] = groupedByM2O ? value[0] : value; + } + this.existingGroupsPopulated = true; + } + } + + /** + * Generates sample records for the models in this.data. Records will be + * generated once, and subsequent calls to this function will be skipped. + * @private + */ + _populateModels() { + if (!this.populated) { + for (const modelName in this.data) { + const model = this.data[modelName]; + const fieldNames = Object.keys(model.fields).filter((f) => f !== "id"); + const size = + modelName === this.mainModel + ? SampleServer.MAIN_RECORDSET_SIZE + : SampleServer.SUB_RECORDSET_SIZE; + for (let id = 1; id <= size; id++) { + const record = { id }; + for (const fieldName of fieldNames) { + record[fieldName] = this._generateFieldValue(modelName, fieldName, id); + } + model.records.push(record); + } + } + this.populated = true; + } + } + + /** + * Rounds the given number value according to the configured precision. + * @private + * @param {number} value + * @returns {number} + */ + _sanitizeNumber(value) { + return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION)); + } + + /** + * A real (web_)read_group call has been done, and it has returned groups, + * but they are all empty. This function updates the sample data such + * that those group values exist and those groups contain sample records. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @param {string[]} params.groupBy + * @returns {Object[]} groups with count and aggregate values updated + * + * TODO: rename + */ + _tweakExistingGroups(params) { + const groups = this.existingGroups; + this._populateExistingGroups(params); + + // update count and aggregates for each group + const groupBy = params.groupBy[0].split(":")[0]; + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === "many2one"; + const records = this.data[params.model].records; + for (const g of groups) { + const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy]; + const recordsInGroup = records.filter((r) => r[groupBy] === groupValue); + g[`${groupBy}_count`] = recordsInGroup.length; + for (const field of params.fields) { + const fieldType = this.data[params.model].fields[field].type; + if (["integer, float", "monetary"].includes(fieldType)) { + g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0); + } + } + g.__data = { + records: this._mockRead({ + model: params.model, + args: [recordsInGroup.map((r) => r.id), params.fields], + }), + length: recordsInGroup.length, + }; + } + } +} + +SampleServer.FORMATS = { + day: "yyyy-MM-dd", + week: "'W'WW kkkk", + month: "MMMM yyyy", + quarter: "'Q'q yyyy", + year: "y", +}; +SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: "dd MMM yyyy" }); + +SampleServer.MAIN_RECORDSET_SIZE = 16; +SampleServer.SUB_RECORDSET_SIZE = 5; +SampleServer.SEARCH_READ_LIMIT = 10; + +SampleServer.MAX_FLOAT = 100; +SampleServer.MAX_INTEGER = 50; +SampleServer.MAX_COLOR_INT = 7; +SampleServer.MAX_MONETARY = 100000; +SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days +SampleServer.FLOAT_PRECISION = 2; + +SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"]; +SampleServer.SAMPLE_PEOPLE = [ + "John Miller", + "Henry Campbell", + "Carrie Helle", + "Wendi Baltz", + "Thomas Passot", +]; +SampleServer.SAMPLE_TEXTS = [ + "Laoreet id", + "Volutpat blandit", + "Integer vitae", + "Viverra nam", + "In massa", +]; +SampleServer.PEOPLE_MODELS = [ + "res.users", + "res.partner", + "hr.employee", + "mail.followers", + "mailing.contact", +]; + +SampleServer.UnimplementedRouteError = UnimplementedRouteError; + +export function buildSampleORM(resModel, fields, user) { + const sampleServer = new SampleServer(resModel, fields); + const fakeRPC = async (_, params) => { + const { kwargs, method, model } = params; + const { groupby: groupBy } = kwargs; + return sampleServer.mockRpc({ method, model, ...kwargs, groupBy }); + }; + return new ORM(fakeRPC, user); +} From e0294887ec00ec2aa47c386b3a5ad2562eada7b5 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Tue, 7 Sep 2021 06:22:23 +0000 Subject: [PATCH 09/31] [REF] web: debug manager We refactor some items in the debug registry. In particular, we want to make them usable in the legacy and new contexts. Part-of: odoo/odoo#73311 --- .../static/src/core/debug/debug_context.js | 14 +- .../static/src/core/debug/debug_menu_items.js | 6 +- .../web/static/src/core/debug/debug_utils.js | 14 + addons/web/static/src/legacy/debug_manager.js | 291 +----------------- addons/web/static/src/views/debug_items.js | 94 ++++++ .../src/webclient/actions/action_service.js | 3 +- .../src/webclient/actions/debug_items.js | 196 ++++++++++++ .../tests/core/debug/debug_manager_tests.js | 2 +- .../webclient/actions/window_action_tests.js | 2 +- 9 files changed, 327 insertions(+), 295 deletions(-) create mode 100644 addons/web/static/src/core/debug/debug_utils.js create mode 100644 addons/web/static/src/views/debug_items.js create mode 100644 addons/web/static/src/webclient/actions/debug_items.js diff --git a/addons/web/static/src/core/debug/debug_context.js b/addons/web/static/src/core/debug/debug_context.js index d8f6987bcf4..a7593ec6131 100644 --- a/addons/web/static/src/core/debug/debug_context.js +++ b/addons/web/static/src/core/debug/debug_context.js @@ -1,4 +1,5 @@ /** @odoo-module **/ + import { registry } from "../registry"; import { memoize } from "../utils/functions"; import { useEffect } from "@web/core/utils/hooks"; @@ -78,9 +79,12 @@ export function useEnvDebugContext() { } export function useDebugCategory(category, context = {}) { - const debugContext = useEnvDebugContext(); - useEffect( - () => debugContext.activateCategory(category, context), - () => [] - ); + const env = useEnv(); + if (env.debug) { + const debugContext = useEnvDebugContext(); + useEffect( + () => debugContext.activateCategory(category, context), + () => [] + ); + } } diff --git a/addons/web/static/src/core/debug/debug_menu_items.js b/addons/web/static/src/core/debug/debug_menu_items.js index 6ef2c887d35..b01286079c9 100644 --- a/addons/web/static/src/core/debug/debug_menu_items.js +++ b/addons/web/static/src/core/debug/debug_menu_items.js @@ -1,8 +1,8 @@ /** @odoo-module **/ -import { browser } from "../browser/browser"; -import { routeToUrl } from "../browser/router_service"; -import { registry } from "../registry"; +import { browser } from "@web/core/browser/browser"; +import { routeToUrl } from "@web/core/browser/router_service"; +import { registry } from "@web/core/registry"; function activateAssetsDebugging({ env }) { return { diff --git a/addons/web/static/src/core/debug/debug_utils.js b/addons/web/static/src/core/debug/debug_utils.js new file mode 100644 index 00000000000..bd91c81db55 --- /dev/null +++ b/addons/web/static/src/core/debug/debug_utils.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +export function editModelDebug(env, title, model, id) { + return env.services.action.doAction({ + res_model: model, + res_id: id, + name: title, + type: "ir.actions.act_window", + views: [[false, "form"]], + view_mode: "form", + target: "new", + flags: { action_buttons: true, headless: true }, + }); +} diff --git a/addons/web/static/src/legacy/debug_manager.js b/addons/web/static/src/legacy/debug_manager.js index 090892acad7..1264ca824e6 100644 --- a/addons/web/static/src/legacy/debug_manager.js +++ b/addons/web/static/src/legacy/debug_manager.js @@ -1,212 +1,15 @@ /** @odoo-module **/ -import { Dialog } from "../core/dialog/dialog"; -import { formatDateTime, parseDateTime } from "../core/l10n/dates"; -import { _lt } from "../core/l10n/translation"; -import { registry } from "../core/registry"; +import { _lt } from "@web/core/l10n/translation"; +import { Dialog } from "@web/core/dialog/dialog"; +import { formatDateTime, parseDateTime } from "@web/core/l10n/dates"; import { formatMany2one } from "@web/fields/formatters"; +import { registry } from "@web/core/registry"; -const { hooks, tags } = owl; +const { hooks } = owl; const { useState } = hooks; -function editModelDebug(env, title, model, id) { - return env.services.action.doAction({ - res_model: model, - res_id: id, - name: title, - type: "ir.actions.act_window", - views: [[false, "form"]], - view_mode: "form", - target: "new", - flags: { action_buttons: true, headless: true }, - }); -} - -// Action items - -function actionSeparator() { - return { - type: "separator", - sequence: 100, - }; -} - -function accessSeparator({ accessRights, action }) { - const { canSeeModelAccess, canSeeRecordRules } = accessRights; - if (!action.res_model || (!canSeeModelAccess && !canSeeRecordRules)) { - return null; - } - return { - type: "separator", - sequence: 200, - }; -} - -function editAction({ action, env }) { - if (!action.id) { - return null; - } - const description = env._t("Edit Action"); - return { - type: "item", - description, - callback: () => { - editModelDebug(env, description, action.type, action.id); - }, - sequence: 110, - }; -} - -function viewFields({ action, env }) { - if (!action.res_model) { - return null; - } - const description = env._t("View Fields"); - return { - type: "item", - description, - callback: async () => { - const modelId = ( - await env.services.orm.search("ir.model", [["model", "=", action.res_model]], { - limit: 1, - }) - )[0]; - env.services.action.doAction({ - res_model: "ir.model.fields", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - domain: [["model_id", "=", modelId]], - type: "ir.actions.act_window", - context: { - default_model_id: modelId, - }, - }); - }, - sequence: 120, - }; -} - -function manageFilters({ action, env }) { - if (!action.res_model) { - return null; - } - const description = env._t("Manage Filters"); - return { - type: "item", - description, - callback: () => { - // manage_filters - env.services.action.doAction({ - res_model: "ir.filters", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - type: "ir.actions.act_window", - context: { - search_default_my_filters: true, - search_default_model_id: action.res_model, - }, - }); - }, - sequence: 130, - }; -} - -function technicalTranslation({ action, env }) { - if (!action.res_model) { - return null; - } - return { - type: "item", - description: env._t("Technical Translation"), - callback: async () => { - const result = await env.services.orm.call( - "ir.translation", - "get_technical_translations", - [action.res_model] - ); - env.services.action.doAction(result); - }, - sequence: 140, - }; -} - -function viewAccessRights({ accessRights, action, env }) { - if (!action.res_model || !accessRights.canSeeModelAccess) { - return null; - } - const description = env._t("View Access Rights"); - return { - type: "item", - description, - callback: async () => { - const modelId = ( - await env.services.orm.search("ir.model", [["model", "=", action.res_model]], { - limit: 1, - }) - )[0]; - env.services.action.doAction({ - res_model: "ir.model.access", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - domain: [["model_id", "=", modelId]], - type: "ir.actions.act_window", - context: { - default_model_id: modelId, - }, - }); - }, - sequence: 210, - }; -} - -function viewRecordRules({ accessRights, action, env }) { - if (!action.res_model || !accessRights.canSeeRecordRules) { - return null; - } - const description = env._t("Model Record Rules"); - return { - type: "item", - description: env._t("View Record Rules"), - callback: async () => { - const modelId = ( - await env.services.orm.search("ir.model", [["model", "=", action.res_model]], { - limit: 1, - }) - )[0]; - env.services.action.doAction({ - res_model: "ir.rule", - name: description, - views: [ - [false, "list"], - [false, "form"], - ], - domain: [["model_id", "=", modelId]], - type: "ir.actions.act_window", - context: { - default_model_id: modelId, - }, - }); - }, - sequence: 220, - }; -} - -class FieldViewGetDialog extends Dialog {} -FieldViewGetDialog.props = Object.assign({}, Dialog.props, { - arch: { type: String }, - close: Function, -}); -FieldViewGetDialog.bodyTemplate = tags.xml`
`;
-FieldViewGetDialog.title = _lt("Fields View Get");
+const debugRegistry = registry.category("debug");
 
 class GetMetadataDialog extends Dialog {
     setup() {
@@ -243,7 +46,6 @@ class GetMetadataDialog extends Dialog {
 }
 GetMetadataDialog.bodyTemplate = "web.DebugMenu.getMetadataBody";
 GetMetadataDialog.title = _lt("View Metadata");
-
 class SetDefaultDialog extends Dialog {
     setup() {
         super.setup();
@@ -389,66 +191,7 @@ SetDefaultDialog.bodyTemplate = "web.DebugMenu.setDefaultBody";
 SetDefaultDialog.footerTemplate = "web.DebugMenu.SetDefaultFooter";
 SetDefaultDialog.title = _lt("Set Default");
 
-function viewSeparator() {
-    return {
-        type: "separator",
-        sequence: 300,
-    };
-}
-
-function fieldsViewGet({ component, env }) {
-    return {
-        type: "item",
-        description: env._t("Fields View Get"),
-        callback: () => {
-            const props = {
-                arch: component.props.viewInfo.arch,
-            };
-            env.services.dialog.add(FieldViewGetDialog, props);
-        },
-        sequence: 340,
-    };
-}
-
-export function editView({ accessRights, action, component, env }) {
-    if (!accessRights.canEditView) {
-        return null;
-    }
-    let { view_id: viewId, type: viewType } = component.props.viewInfo;
-    viewType = viewType === "tree" ? "list" : viewType;
-    const displayName = action.views.find((v) => v.type === viewType).name.toString();
-    const description = env._t("Edit View: ") + displayName;
-    return {
-        type: "item",
-        description,
-        callback: () => {
-            editModelDebug(env, description, "ir.ui.view", viewId);
-        },
-        sequence: 350,
-    };
-}
-
-export function editSearchView({ accessRights, component, env }) {
-    if (!accessRights.canEditView) {
-        return null;
-    }
-    const description = env._t("Edit SearchView");
-    return {
-        type: "item",
-        description,
-        callback: () => {
-            editModelDebug(
-                env,
-                description,
-                "ir.ui.view",
-                component.props.viewParams.controlPanelFieldsView.view_id
-            );
-        },
-        sequence: 360,
-    };
-}
-
-// Form view itemss
+// Form view items
 
 function setDefaults({ action, component, env }) {
     return {
@@ -515,26 +258,6 @@ function manageAttachments({ action, component, env }) {
     };
 }
 
-const debugRegistry = registry.category("debug");
-
-debugRegistry
-    .category("action")
-    .add("actionSeparator", actionSeparator)
-    .add("editAction", editAction)
-    .add("viewFields", viewFields)
-    .add("manageFilters", manageFilters)
-    .add("technicalTranslation", technicalTranslation)
-    .add("accessSeparator", accessSeparator)
-    .add("viewAccessRights", viewAccessRights)
-    .add("viewRecordRules", viewRecordRules);
-
-debugRegistry
-    .category("view")
-    .add("viewSeparator", viewSeparator)
-    .add("fieldsViewGet", fieldsViewGet)
-    .add("editView", editView)
-    .add("editSearchView", editSearchView);
-
 debugRegistry
     .category("form")
     .add("setDefaults", setDefaults)
diff --git a/addons/web/static/src/views/debug_items.js b/addons/web/static/src/views/debug_items.js
new file mode 100644
index 00000000000..0a37e9f64d3
--- /dev/null
+++ b/addons/web/static/src/views/debug_items.js
@@ -0,0 +1,94 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+import { Dialog } from "@web/core/dialog/dialog";
+import { editModelDebug } from "@web/core/debug/debug_utils";
+import { registry } from "@web/core/registry";
+
+const { tags } = owl;
+
+const debugRegistry = registry.category("debug");
+
+class FieldViewGetDialog extends Dialog {}
+FieldViewGetDialog.props = Object.assign({}, Dialog.props, {
+    arch: { type: String },
+    close: Function,
+});
+FieldViewGetDialog.bodyTemplate = tags.xml`
`;
+FieldViewGetDialog.title = _lt("Fields View Get");
+
+function viewSeparator() {
+    return {
+        type: "separator",
+        sequence: 300,
+    };
+}
+
+function fieldsViewGet({ component, env }) {
+    let { arch } = component.props;
+    if ("viewInfo" in component.props) {
+        //legacy
+        arch = component.props.viewInfo.arch;
+    }
+    return {
+        type: "item",
+        description: env._t("Fields View Get"),
+        callback: () => {
+            env.services.dialog.add(FieldViewGetDialog, { arch });
+        },
+        sequence: 340,
+    };
+}
+
+export function editView({ accessRights, component, env }) {
+    if (!accessRights.canEditView) {
+        return null;
+    }
+    let type;
+    let { viewId } = component.props.info || {}; // fallback is there for legacy
+    if ("viewInfo" in component.props) {
+        // legacy
+        viewId = component.props.viewInfo.view_id;
+        type = component.props.viewInfo.type;
+        type = type === "tree" ? "list" : type;
+    } else {
+        type = component.constructor.type;
+    }
+    const displayName = type[0].toUpperCase() + type.slice(1);
+    const description = env._t("Edit View: ") + displayName;
+    return {
+        type: "item",
+        description,
+        callback: () => {
+            editModelDebug(env, description, "ir.ui.view", viewId);
+        },
+        sequence: 350,
+    };
+}
+
+export function editSearchView({ accessRights, component, env }) {
+    if (!accessRights.canEditView) {
+        return null;
+    }
+    let { searchViewId } = component.props.info || {}; // fallback is there for legacy
+    if ("viewParams" in component.props) {
+        //legacy
+        searchViewId = component.props.viewParams.action.controlPanelFieldsView.view_id;
+    }
+    const description = env._t("Edit ControlPanelView");
+    return {
+        type: "item",
+        description,
+        callback: () => {
+            editModelDebug(env, description, "ir.ui.view", searchViewId);
+        },
+        sequence: 360,
+    };
+}
+
+debugRegistry
+    .category("view")
+    .add("viewSeparator", viewSeparator)
+    .add("fieldsViewGet", fieldsViewGet)
+    .add("editView", editView)
+    .add("editSearchView", editSearchView);
diff --git a/addons/web/static/src/webclient/actions/action_service.js b/addons/web/static/src/webclient/actions/action_service.js
index 7a063912cd2..c7795644ed0 100644
--- a/addons/web/static/src/webclient/actions/action_service.js
+++ b/addons/web/static/src/webclient/actions/action_service.js
@@ -9,6 +9,7 @@ import { evaluateExpr } from "@web/core/py_js/py";
 import { registry } from "@web/core/registry";
 import { KeepLast } from "@web/core/utils/concurrency";
 import { sprintf } from "@web/core/utils/strings";
+import { useDebugCategory } from "@web/core/debug/debug_context";
 import { ActionDialog } from "./action_dialog";
 
 /** @typedef {number|false} ActionId */
@@ -465,7 +466,7 @@ function makeActionManager(env) {
             setup() {
                 this.Component = controller.Component;
                 this.componentRef = hooks.useRef("component");
-                this.registerCallback = null;
+                useDebugCategory("action", { action });
                 if (action.target !== "new") {
                     let beforeLeaveFn;
                     this.registerCallback = (type, fn) => {
diff --git a/addons/web/static/src/webclient/actions/debug_items.js b/addons/web/static/src/webclient/actions/debug_items.js
new file mode 100644
index 00000000000..87ab7ec8b7b
--- /dev/null
+++ b/addons/web/static/src/webclient/actions/debug_items.js
@@ -0,0 +1,196 @@
+/** @odoo-module **/
+
+import { editModelDebug } from "@web/core/debug/debug_utils";
+import { registry } from "@web/core/registry";
+
+const debugRegistry = registry.category("debug");
+
+function actionSeparator({ action }) {
+    if (!action.id || !action.res_model) {
+        return null;
+    }
+    return {
+        type: "separator",
+        sequence: 100,
+    };
+}
+
+function accessSeparator({ accessRights, action }) {
+    const { canSeeModelAccess, canSeeRecordRules } = accessRights;
+    if (!action.res_model || (!canSeeModelAccess && !canSeeRecordRules)) {
+        return null;
+    }
+    return {
+        type: "separator",
+        sequence: 200,
+    };
+}
+
+function editAction({ action, env }) {
+    if (!action.id) {
+        return null;
+    }
+    const description = env._t("Edit Action");
+    return {
+        type: "item",
+        description,
+        callback: () => {
+            editModelDebug(env, description, action.type, action.id);
+        },
+        sequence: 110,
+    };
+}
+
+function viewFields({ action, env }) {
+    if (!action.res_model) {
+        return null;
+    }
+    const description = env._t("View Fields");
+    return {
+        type: "item",
+        description,
+        callback: async () => {
+            const modelId = (
+                await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
+                    limit: 1,
+                })
+            )[0];
+            env.services.action.doAction({
+                res_model: "ir.model.fields",
+                name: description,
+                views: [
+                    [false, "list"],
+                    [false, "form"],
+                ],
+                domain: [["model_id", "=", modelId]],
+                type: "ir.actions.act_window",
+                context: {
+                    default_model_id: modelId,
+                },
+            });
+        },
+        sequence: 120,
+    };
+}
+
+function manageFilters({ action, env }) {
+    if (!action.res_model) {
+        return null;
+    }
+    const description = env._t("Manage Filters");
+    return {
+        type: "item",
+        description,
+        callback: () => {
+            // manage_filters
+            env.services.action.doAction({
+                res_model: "ir.filters",
+                name: description,
+                views: [
+                    [false, "list"],
+                    [false, "form"],
+                ],
+                type: "ir.actions.act_window",
+                context: {
+                    search_default_my_filters: true,
+                    search_default_model_id: action.res_model,
+                },
+            });
+        },
+        sequence: 130,
+    };
+}
+
+function technicalTranslation({ action, env }) {
+    if (!action.res_model) {
+        return null;
+    }
+    return {
+        type: "item",
+        description: env._t("Technical Translation"),
+        callback: async () => {
+            const result = await env.services.orm.call(
+                "ir.translation",
+                "get_technical_translations",
+                [action.res_model]
+            );
+            env.services.action.doAction(result);
+        },
+        sequence: 140,
+    };
+}
+
+function viewAccessRights({ accessRights, action, env }) {
+    if (!action.res_model || !accessRights.canSeeModelAccess) {
+        return null;
+    }
+    const description = env._t("View Access Rights");
+    return {
+        type: "item",
+        description,
+        callback: async () => {
+            const modelId = (
+                await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
+                    limit: 1,
+                })
+            )[0];
+            env.services.action.doAction({
+                res_model: "ir.model.access",
+                name: description,
+                views: [
+                    [false, "list"],
+                    [false, "form"],
+                ],
+                domain: [["model_id", "=", modelId]],
+                type: "ir.actions.act_window",
+                context: {
+                    default_model_id: modelId,
+                },
+            });
+        },
+        sequence: 210,
+    };
+}
+
+function viewRecordRules({ accessRights, action, env }) {
+    if (!action.res_model || !accessRights.canSeeRecordRules) {
+        return null;
+    }
+    const description = env._t("Model Record Rules");
+    return {
+        type: "item",
+        description: env._t("View Record Rules"),
+        callback: async () => {
+            const modelId = (
+                await env.services.orm.search("ir.model", [["model", "=", action.res_model]], {
+                    limit: 1,
+                })
+            )[0];
+            env.services.action.doAction({
+                res_model: "ir.rule",
+                name: description,
+                views: [
+                    [false, "list"],
+                    [false, "form"],
+                ],
+                domain: [["model_id", "=", modelId]],
+                type: "ir.actions.act_window",
+                context: {
+                    default_model_id: modelId,
+                },
+            });
+        },
+        sequence: 220,
+    };
+}
+
+debugRegistry
+    .category("action")
+    .add("actionSeparator", actionSeparator)
+    .add("editAction", editAction)
+    .add("viewFields", viewFields)
+    .add("manageFilters", manageFilters)
+    .add("technicalTranslation", technicalTranslation)
+    .add("accessSeparator", accessSeparator)
+    .add("viewAccessRights", viewAccessRights)
+    .add("viewRecordRules", viewRecordRules);
diff --git a/addons/web/static/tests/core/debug/debug_manager_tests.js b/addons/web/static/tests/core/debug/debug_manager_tests.js
index 4d09b8f31d8..3d725c4112e 100644
--- a/addons/web/static/tests/core/debug/debug_manager_tests.js
+++ b/addons/web/static/tests/core/debug/debug_manager_tests.js
@@ -19,7 +19,7 @@ import {
 import { click, getFixture, legacyExtraNextTick, patchWithCleanup } from "../../helpers/utils";
 import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers";
 import { openViewItem } from "@web/webclient/debug_items";
-import { editView, editSearchView } from "@web/legacy/debug_manager";
+import { editSearchView, editView } from "@web/views/debug_items";
 
 const { Component, mount, tags } = owl;
 const { xml } = tags;
diff --git a/addons/web/static/tests/webclient/actions/window_action_tests.js b/addons/web/static/tests/webclient/actions/window_action_tests.js
index 81066885e67..a0fb9733bbf 100644
--- a/addons/web/static/tests/webclient/actions/window_action_tests.js
+++ b/addons/web/static/tests/webclient/actions/window_action_tests.js
@@ -2,7 +2,7 @@
 
 import { browser } from "@web/core/browser/browser";
 import { registry } from "@web/core/registry";
-import { editView } from "@web/legacy/debug_manager";
+import { editView } from "@web/views/debug_items";
 import { clearUncommittedChanges } from "@web/webclient/actions/action_service";
 import AbstractView from "web.AbstractView";
 import FormView from "web.FormView";

From c064eb068195825c86e09f52168aca6b0585e16c Mon Sep 17 00:00:00 2001
From: Mathieu Duckerts-Antoine 
Date: Tue, 7 Sep 2021 06:22:25 +0000
Subject: [PATCH 10/31] [IMP] web: add XMLParser class

This class can be used by views to parse their arch.

Part-of: odoo/odoo#73311
---
 addons/web/static/src/core/utils/xml.js | 35 +++++++++++++++++++++++++
 1 file changed, 35 insertions(+)
 create mode 100644 addons/web/static/src/core/utils/xml.js

diff --git a/addons/web/static/src/core/utils/xml.js b/addons/web/static/src/core/utils/xml.js
new file mode 100644
index 00000000000..7d618bc566f
--- /dev/null
+++ b/addons/web/static/src/core/utils/xml.js
@@ -0,0 +1,35 @@
+/** @odoo-module **/
+
+export class XMLParser {
+    /**
+     * to override. Should return the parsed content of the arch.
+     * It can call the visitArch function if desired
+     */
+    parse() {}
+
+    visitXML(xml, callback) {
+        function visit(xml) {
+            if (xml) {
+                let didVisitChildren = false;
+                const visitChildren = () => {
+                    for (let child of xml.children) {
+                        visit(child);
+                    }
+                    didVisitChildren = true;
+                };
+                const shouldVisitChildren = callback(xml, visitChildren);
+                if (shouldVisitChildren !== false && !didVisitChildren) {
+                    visitChildren();
+                }
+            }
+        }
+        const xmlDoc = typeof xml === "string" ? this.parseXML(xml) : xml;
+        visit(xmlDoc);
+    }
+
+    parseXML(arch) {
+        const parser = new DOMParser();
+        const xml = parser.parseFromString(arch, "text/xml");
+        return xml.documentElement;
+    }
+}

From 205bb172baf67ca29b0424529ba74da4410f3198 Mon Sep 17 00:00:00 2001
From: Mathieu Duckerts-Antoine 
Date: Tue, 7 Sep 2021 06:22:27 +0000
Subject: [PATCH 11/31] [IMP] web: orm service: add webReadGroup function

Before this commit readGroup was actually calling web_read_group.
With this commit, readGroup calls read_group, and webReadGroup
calls web_read_group, as expected.

Part-of: odoo/odoo#73311
---
 addons/web/static/src/core/orm_service.js     | 18 ++++++++--
 .../static/tests/core/orm_service_tests.js    | 34 +++++++++++++++++--
 2 files changed, 48 insertions(+), 4 deletions(-)

diff --git a/addons/web/static/src/core/orm_service.js b/addons/web/static/src/core/orm_service.js
index fea150e4452..8c3024c487d 100644
--- a/addons/web/static/src/core/orm_service.js
+++ b/addons/web/static/src/core/orm_service.js
@@ -129,9 +129,9 @@ export class ORM {
     }
 
     readGroup(model, domain, fields, groupby, options = {}, ctx = {}) {
+        validateArray("domain", domain);
         validatePrimitiveList("fields", "string", fields);
         validatePrimitiveList("groupby", "string", groupby);
-        validateArray("domain", domain);
         const kwargs = {
             domain,
             groupby,
@@ -139,7 +139,7 @@ export class ORM {
             context: ctx,
         };
         assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]);
-        return this.call(model, "web_read_group", [], kwargs);
+        return this.call(model, "read_group", [], kwargs);
     }
 
     search(model, domain, options = {}, ctx = {}) {
@@ -169,6 +169,20 @@ export class ORM {
         return this.call(model, "unlink", [ids], { context: ctx });
     }
 
+    webReadGroup(model, domain, fields, groupby, options = {}, ctx = {}) {
+        validateArray("domain", domain);
+        validatePrimitiveList("fields", "string", fields);
+        validatePrimitiveList("groupby", "string", groupby);
+        const kwargs = {
+            domain,
+            groupby,
+            fields,
+            context: ctx,
+        };
+        assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]);
+        return this.call(model, "web_read_group", [], kwargs);
+    }
+
     webSearchRead(model, domain, fields, options = {}, ctx = {}) {
         validateArray("domain", domain);
         validatePrimitiveList("fields", "string", fields);
diff --git a/addons/web/static/tests/core/orm_service_tests.js b/addons/web/static/tests/core/orm_service_tests.js
index c8e7d11fcc2..12843cd3179 100644
--- a/addons/web/static/tests/core/orm_service_tests.js
+++ b/addons/web/static/tests/core/orm_service_tests.js
@@ -155,11 +155,11 @@ QUnit.test("write method", async (assert) => {
     });
 });
 
-QUnit.test("readGroup method", async (assert) => {
+QUnit.test("webReadGroup method", async (assert) => {
     const [query, rpc] = makeFakeRPC();
     serviceRegistry.add("rpc", rpc);
     const env = await makeTestEnv();
-    await env.services.orm.readGroup(
+    await env.services.orm.webReadGroup(
         "sale.order",
         [["user_id", "=", 2]],
         ["amount_total:sum"],
@@ -185,6 +185,36 @@ QUnit.test("readGroup method", async (assert) => {
     });
 });
 
+QUnit.test("readGroup method", async (assert) => {
+    const [query, rpc] = makeFakeRPC();
+    serviceRegistry.add("rpc", rpc);
+    const env = await makeTestEnv();
+    await env.services.orm.readGroup(
+        "sale.order",
+        [["user_id", "=", 2]],
+        ["amount_total:sum"],
+        ["date_order"],
+        { offset: 1 }
+    );
+    assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/read_group");
+    assert.deepEqual(query.params, {
+        args: [],
+        kwargs: {
+            domain: [["user_id", "=", 2]],
+            fields: ["amount_total:sum"],
+            groupby: ["date_order"],
+            context: {
+                lang: "en",
+                uid: 7,
+                tz: "taht",
+            },
+            offset: 1,
+        },
+        method: "read_group",
+        model: "sale.order",
+    });
+});
+
 QUnit.test("searchRead method", async (assert) => {
     const [query, rpc] = makeFakeRPC();
     serviceRegistry.add("rpc", rpc);

From 0008067ee56e8227e8b27dc34f51caf5e0a33a26 Mon Sep 17 00:00:00 2001
From: Julien Mougenot 
Date: Tue, 7 Sep 2021 06:22:29 +0000
Subject: [PATCH 12/31] [IMP] web: add date formatter

This commit re-introduces the date formatter to go along with its
datetime counterpart, as well as a few tests.

It also refactors the date parsers to make proper use of the arguments
passed to the functions, namely to alter the timezone.

Part-of: odoo/odoo#73311
---
 addons/web/static/src/core/l10n/dates.js      |   92 +-
 addons/web/static/src/fields/formatters.js    |    3 +-
 addons/web/static/src/fields/parsers.js       |    1 +
 .../web/static/tests/core/l10n/dates_tests.js | 1307 +++++++++--------
 4 files changed, 769 insertions(+), 634 deletions(-)

diff --git a/addons/web/static/src/core/l10n/dates.js b/addons/web/static/src/core/l10n/dates.js
index e2cfa3c6cc5..4dbda3c7566 100644
--- a/addons/web/static/src/core/l10n/dates.js
+++ b/addons/web/static/src/core/l10n/dates.js
@@ -1,18 +1,14 @@
 /** @odoo-module **/
 
-import { memoize } from "../utils/functions";
-import { sprintf } from "../utils/strings";
-import { localization } from "./localization";
-import { _t } from "./translation";
+import { localization } from "@web/core/l10n/localization";
+import { _t } from "@web/core/l10n/translation";
+import { memoize } from "@web/core/utils/functions";
+import { sprintf } from "@web/core/utils/strings";
 
 const { DateTime } = luxon;
 
-/**
- * Change the method toJSON to return the formated value to send server side.
- */
-DateTime.prototype.toJSON = function () {
-    return this.toFormat("yyyy-MM-dd HH:mm:ss");
-};
+const SERVER_DATE_FORMAT = "yyyy-MM-dd";
+const SERVER_TIME_FORMAT = "HH:mm:ss";
 
 // -----------------------------------------------------------------------------
 // Helpers
@@ -146,7 +142,24 @@ export const strftimeToLuxonFormat = memoize(function strftimeToLuxonFormat(valu
 // -----------------------------------------------------------------------------
 
 /**
- * Format a DateTime object
+ * Formats a DateTime object to a date string
+ *
+ * `options.timezone` is defaulted to false on dates since we assume they
+ * shouldn't be affected by timezones like datetimes.
+ *
+ * @see formatDateTime
+ * @returns {string}
+ */
+export function formatDate(value, options = {}) {
+    return formatDateTime(value, {
+        timezone: false, // Timezone should never alter a 'date' value.
+        ...options,
+        format: options.format || localization.dateFormat,
+    });
+}
+
+/**
+ * Formats a DateTime object to a datetime string
  *
  * @param {DateTime | false} value
  * @param {Object} options
@@ -178,28 +191,21 @@ export function formatDateTime(value, options = {}) {
 // -----------------------------------------------------------------------------
 
 /**
- * Parses a string value to an UTC Luxon DateTime object.
+ * Parses a string value to a Luxon DateTime object.
  *
- * @param {string} value value to parse.
- *  - Value can take the form of a smart date:
- *    e.g. "+3w" for three weeks from now.
- *    (`options.format` and `options.timezone` are ignored in this case)
+ * `options.timezone` is defaulted to false on dates since we assume they
+ * shouldn't be affected by timezones like datetimes.
  *
- *  - If value cannot be parsed within the provided format,
- *    ISO8601 and SQL formats are then tried.
- *
- * @param {object} options
- * @param {string} [options.format]
- *  Provided format used to parse the input value.
- *  Default=the session localization format
- * @returns {DateTime|false} Luxon DateTime object (in the UTC timezone)
+ * @see parseDateTime (Note: since we're only interested by the date itself, the
+ *  returned value will always be set at the start of the day)
+ * @returns {DateTime | false} Luxon DateTime object
  */
 export function parseDate(value, options = {}) {
-    return parseDateTime(value, { dateOnly: true, format: options.format });
+    return parseDateTime(value, { timezone: false, ...options }).startOf("day");
 }
 
 /**
- * Parses a string value to an UTC Luxon DateTime object.
+ * Parses a string value to a Luxon DateTime object.
  *
  * @param {string} value value to parse.
  *  - Value can take the form of a smart date:
@@ -210,11 +216,6 @@ export function parseDate(value, options = {}) {
  *    ISO8601 and SQL formats are then tried.
  *
  * @param {object} options
- * @param {boolean} [options.dateOnly]
- *  The output date will represent the start of day (time is truncated).
- *
- *  Default=false.
- *
  * @param {string} [options.format]
  *  Provided format used to parse the input value.
  *
@@ -222,19 +223,18 @@ export function parseDate(value, options = {}) {
  *
  * @param {boolean} [options.timezone]
  *  - True = input value is considered being in localtime.
- *  - False = input value is considered being in utc time.
+ *  - False = input value is considered being in utc time, and the returned
+ *            value will have the UTC zone.
  *
  *  NB: ISO strings containing timezone information
  *      will have priority over this option.
  *
  *  Default=false.
  *
- * @returns {DateTime|false} Luxon DateTime object (in the UTC timezone)
+ * @returns {DateTime | false} Luxon DateTime object
  */
 export function parseDateTime(value, options = {}) {
     if (!value) {
-        // BOI: is {DateTime|false} really what we want ?
-        // I think we  should return a DateTime.invalid when falsy.
         return false;
     }
 
@@ -242,6 +242,7 @@ export function parseDateTime(value, options = {}) {
     const parseOpts = {
         setZone: true,
         zone: options.timezone ? "local" : "utc",
+        locale: options.locale,
     };
 
     let result = constrain(parseSmartDateInput(value));
@@ -279,6 +280,23 @@ export function parseDateTime(value, options = {}) {
         throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value));
     }
 
-    result = result.toUTC();
-    return options.dateOnly ? result.startOf("day") : result;
+    return options.timezone ? result : result.toUTC();
 }
+
+/**
+ * Returns a serialized string representing the given date.
+ * @param {DateTime} value
+ * @returns {string}
+ */
+export const serializeDate = (value) => {
+    return formatDate(value, { format: SERVER_DATE_FORMAT });
+};
+
+/**
+ * Returns a serialized string representing the given datetime.
+ * @param {DateTime} value
+ * @returns {string}
+ */
+export const serializeDateTime = (value) => {
+    return formatDateTime(value, { format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}` });
+};
diff --git a/addons/web/static/src/fields/formatters.js b/addons/web/static/src/fields/formatters.js
index cec5c4ef1a4..e8d6395aa4c 100644
--- a/addons/web/static/src/fields/formatters.js
+++ b/addons/web/static/src/fields/formatters.js
@@ -1,6 +1,6 @@
 /** @odoo-module **/
 
-import { formatDateTime } from "@web/core/l10n/dates";
+import { formatDate, formatDateTime } from "@web/core/l10n/dates";
 import { localization as l10n } from "@web/core/l10n/localization";
 import { _lt } from "@web/core/l10n/translation";
 import { registry } from "@web/core/registry";
@@ -312,6 +312,7 @@ export function formatPercentage(value, options = {}) {
 
 registry
     .category("formatters")
+    .add("date", formatDate)
     .add("datetime", formatDateTime)
     .add("float", formatFloat)
     .add("float_factor", formatFloatFactor)
diff --git a/addons/web/static/src/fields/parsers.js b/addons/web/static/src/fields/parsers.js
index 9b391bbbf58..68d61aa716c 100644
--- a/addons/web/static/src/fields/parsers.js
+++ b/addons/web/static/src/fields/parsers.js
@@ -152,4 +152,5 @@ registry
     .add("float", parseFloat)
     .add("float_time", parseFloatTime)
     .add("integer", parseInteger)
+    .add("monetary", parseMonetary)
     .add("percentage", parsePercentage);
diff --git a/addons/web/static/tests/core/l10n/dates_tests.js b/addons/web/static/tests/core/l10n/dates_tests.js
index e535bc12688..f5729a5de61 100644
--- a/addons/web/static/tests/core/l10n/dates_tests.js
+++ b/addons/web/static/tests/core/l10n/dates_tests.js
@@ -1,6 +1,7 @@
 /** @odoo-module **/
 
 import {
+    formatDate,
     formatDateTime,
     parseDate,
     parseDateTime,
@@ -59,654 +60,768 @@ function runTestSet(assert, testSet, options) {
     }
 }
 
+QUnit.module(
+    "utils",
+    {
+        before() {
+            moment.suppressDeprecationWarnings = true;
+        },
+        after() {
+            moment.suppressDeprecationWarnings = false;
+        },
+    },
+    () => {
+        QUnit.module("dates");
 
-QUnit.module("utils", {
-        before() {moment.suppressDeprecationWarnings = true;},
-        after() {moment.suppressDeprecationWarnings = false;},
-    }, () => {
-    QUnit.module("dates");
+        QUnit.test("formatDate", async (assert) => {
+            patch(localization, "dateformat", { dateFormat: "MM/dd/yyyy" });
 
-    QUnit.test("formatDateTime", async (assert) => {
-        patch(localization, "datetimeformat", { dateTimeFormat: "MM/dd/yyyy HH:mm:ss" });
+            let formatted = formatDate(DateTime.utc(2009, 5, 4, 12, 34, 23));
+            let expected = "05/04/2009";
+            assert.strictEqual(formatted, expected);
 
-        let formatted = formatDateTime(DateTime.utc(2009, 5, 4, 12, 34, 23));
-        let expected = "05/04/2009 12:34:23";
-        assert.strictEqual(formatted, expected);
+            formatted = formatDate(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: false });
+            assert.strictEqual(formatted, expected);
 
-        formatted = formatDateTime(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: false });
-        assert.strictEqual(formatted, expected);
+            formatted = formatDate(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: true });
+            expected = "05/04/2009";
+            assert.strictEqual(formatted, expected);
 
-        formatted = formatDateTime(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: true });
-        expected = "05/04/2009 14:34:23";
-        assert.strictEqual(formatted, expected);
-
-        unpatch(localization, "datetimeformat");
-    });
-
-    QUnit.test("formatDateTime (with different timezone offset)", async (assert) => {
-        patch(localization, "datetimeformat", { dateTimeFormat: "MM/dd/yyyy HH:mm:ss" });
-
-        let str = formatDateTime(DateTime.utc(2017, 1, 1, 10, 0, 0, 0));
-        assert.strictEqual(str, "01/01/2017 10:00:00");
-        str = formatDateTime(DateTime.utc(2017, 6, 1, 10, 0, 0, 0));
-        assert.strictEqual(str, "06/01/2017 10:00:00");
-
-        str = formatDateTime(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: false });
-        assert.strictEqual(str, "01/01/2017 10:00:00");
-        str = formatDateTime(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: false });
-        assert.strictEqual(str, "06/01/2017 10:00:00");
-
-        str = formatDateTime(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: true });
-        assert.strictEqual(str, "01/01/2017 11:00:00");
-        str = formatDateTime(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: true });
-        assert.strictEqual(str, "06/01/2017 12:00:00");
-
-        unpatch(localization, "datetimeformat");
-    });
-
-    QUnit.test("parseDateTime", async (assert) => {
-        patch(localization, "default loc", defaultLocalization);
-
-        assert.throws(
-            function () {
-                parseDateTime("13/01/2019 12:00:00");
-            },
-            /is not a correct/,
-            "Wrongly formated dates should be invalid"
-        );
-        assert.throws(
-            function () {
-                parseDateTime("01/01/0999 12:00:00");
-            },
-            /is not a correct/,
-            "Dates before 1000 should be invalid"
-        );
-        assert.throws(
-            function () {
-                parseDateTime("01/01/10000 12:00:00");
-            },
-            /is not a correct/,
-            "Dates after 9999 should be invalid"
-        );
-        assert.throws(function () {
-            parseDateTime("invalid value");
-        }, /is not a correct/);
-
-        let expected = "2019-01-13T10:05:45.000Z";
-        let dateStr = "01/13/2019 10:05:45";
-        assert.equal(parseDateTime(dateStr).toISO(), expected, "Date with leading 0");
-        dateStr = "1/13/2019 10:5:45";
-        assert.equal(parseDateTime(dateStr).toISO(), expected, "Date without leading 0");
-
-        unpatch(localization, "default loc");
-    });
-
-    QUnit.test("parseDateTime (norwegian locale)", async (assert) => {
-        const dateFormat = strftimeToLuxonFormat("%d. %b %Y");
-        const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
-        patch(localization, "weird loc", {
-            dateFormat,
-            timeFormat,
-            dateTimeFormat: `${dateFormat} ${timeFormat}`,
+            unpatch(localization, "dateformat");
         });
 
-        // Before WOWL we were using MomentJS.
-        // Now it has been replaced by luxon.js, we cannot declare customized locales.
-        // In legacy web, parseDateTime tests were originally defining custom locales
-        // for english and norwegian.
-        // Here, we simply use the vanilla Intl support to switch locale.
-        const originalLocale = Settings.defaultLocale;
-        Settings.defaultLocale = "no"; // Norwegian
+        QUnit.test("formatDate (with different timezone offset)", async (assert) => {
+            patch(localization, "dateformat", { dateFormat: "MM/dd/yyyy" });
 
-        const dateStr = "16. des 2019 10:05:45";
-        const expected = "2019-12-16T10:05:45.000Z";
-        assert.equal(parseDateTime(dateStr).toISO(), expected, "Day/month inverted + month i18n");
+            let str = formatDate(DateTime.utc(2017, 1, 1, 10, 0, 0, 0));
+            assert.strictEqual(str, "01/01/2017");
+            str = formatDate(DateTime.utc(2017, 6, 1, 10, 0, 0, 0));
+            assert.strictEqual(str, "06/01/2017");
 
-        Settings.defaultLocale = originalLocale;
-        unpatch(localization, "weird loc");
-    });
+            str = formatDate(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: false });
+            assert.strictEqual(str, "01/01/2017");
+            str = formatDate(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: false });
+            assert.strictEqual(str, "06/01/2017");
 
-    QUnit.test("parseDate without separator", async (assert) => {
-        const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
-        const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
-        patch(localization, "patch loc", {
-            dateFormat,
-            timeFormat,
-            dateTimeFormat: `${dateFormat} ${timeFormat}`,
+            str = formatDate(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: true });
+            assert.strictEqual(str, "01/01/2017");
+            str = formatDate(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: true });
+            assert.strictEqual(str, "06/01/2017");
+
+            unpatch(localization, "dateformat");
         });
 
-        const testDateFormat = "dd.MM/yyyy";
+        QUnit.test("formatDateTime", async (assert) => {
+            patch(localization, "datetimeformat", { dateTimeFormat: "MM/dd/yyyy HH:mm:ss" });
 
-        assert.throws(
-            function () {
-                parseDate("1137");
-            },
-            /is not a correct/,
-            "Wrongly formated dates should be invalid"
-        );
-        assert.throws(
-            function () {
-                parseDate("1197");
-            },
-            /is not a correct/,
-            "Wrongly formated dates should be invalid"
-        );
-        assert.throws(
-            function () {
-                parseDate("0131");
-            },
-            /is not a correct/,
-            "Wrongly formated dates should be invalid"
-        );
-        assert.throws(
-            function () {
-                parseDate("970131");
-            },
-            /is not a correct/,
-            "Wrongly formated dates should be invalid"
-        );
-        assert.equal(parseDate("2001").toFormat(testDateFormat), "20.01/" + DateTime.utc().year);
-        assert.equal(parseDate("3101").toFormat(testDateFormat), "31.01/" + DateTime.utc().year);
-        assert.equal(parseDate("31.01").toFormat(testDateFormat), "31.01/" + DateTime.utc().year);
-        assert.equal(parseDate("310197").toFormat(testDateFormat), "31.01/1997");
-        assert.equal(parseDate("310117").toFormat(testDateFormat), "31.01/2017");
-        assert.equal(parseDate("31011985").toFormat(testDateFormat), "31.01/1985");
-        unpatch(localization, "patch loc");
-    });
+            let formatted = formatDateTime(DateTime.utc(2009, 5, 4, 12, 34, 23));
+            let expected = "05/04/2009 12:34:23";
+            assert.strictEqual(formatted, expected);
 
-    QUnit.test("parseDateTime without separator", async (assert) => {
-        const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
-        const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
-        patch(localization, "patch loc", {
-            dateFormat,
-            timeFormat,
-            dateTimeFormat: `${dateFormat} ${timeFormat}`,
+            formatted = formatDateTime(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: false });
+            assert.strictEqual(formatted, expected);
+
+            formatted = formatDateTime(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: true });
+            expected = "05/04/2009 14:34:23";
+            assert.strictEqual(formatted, expected);
+
+            unpatch(localization, "datetimeformat");
         });
 
-        const dateTimeFormat = "dd.MM/yyyy HH:mm/ss";
-        assert.equal(parseDateTime("3101198508").toFormat(dateTimeFormat), "31.01/1985 08:00/00");
-        assert.equal(parseDateTime("310119850833").toFormat(dateTimeFormat), "31.01/1985 08:33/00");
-        assert.equal(
-            parseDateTime("31/01/1985 08").toFormat(dateTimeFormat),
-            "31.01/1985 08:00/00"
-        );
-        unpatch(localization, "patch loc");
-    });
+        QUnit.test("formatDateTime (with different timezone offset)", async (assert) => {
+            patch(localization, "datetimeformat", { dateTimeFormat: "MM/dd/yyyy HH:mm:ss" });
 
-    QUnit.test("parse smart date input", async (assert) => {
-        const format = "dd MM yyyy";
-        assert.strictEqual(
-            parseDate("+1d").toFormat(format),
-            DateTime.utc().plus({ days: 1 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDateTime("+2w").toFormat(format),
-            DateTime.utc().plus({ weeks: 2 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDate("+3m").toFormat(format),
-            DateTime.utc().plus({ months: 3 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDateTime("+4y").toFormat(format),
-            DateTime.utc().plus({ years: 4 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDate("+5").toFormat(format),
-            DateTime.utc().plus({ days: 5 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDateTime("-5").toFormat(format),
-            DateTime.utc().minus({ days: 5 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDate("-4y").toFormat(format),
-            DateTime.utc().minus({ years: 4 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDateTime("-3m").toFormat(format),
-            DateTime.utc().minus({ months: 3 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDate("-2w").toFormat(format),
-            DateTime.utc().minus({ weeks: 2 }).toFormat(format)
-        );
-        assert.strictEqual(
-            parseDateTime("-1d").toFormat(format),
-            DateTime.utc().minus({ days: 1 }).toFormat(format)
-        );
-    });
+            let str = formatDateTime(DateTime.utc(2017, 1, 1, 10, 0, 0, 0));
+            assert.strictEqual(str, "01/01/2017 10:00:00");
+            str = formatDateTime(DateTime.utc(2017, 6, 1, 10, 0, 0, 0));
+            assert.strictEqual(str, "06/01/2017 10:00:00");
 
-    QUnit.test("parseDateTime SQL Format", async (assert) => {
-        patch(localization, "default loc", defaultLocalization);
+            str = formatDateTime(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: false });
+            assert.strictEqual(str, "01/01/2017 10:00:00");
+            str = formatDateTime(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: false });
+            assert.strictEqual(str, "06/01/2017 10:00:00");
 
-        let dateStr = "2017-05-15 09:12:34";
-        let expected = "2017-05-15T09:12:34.000Z";
-        assert.equal(parseDateTime(dateStr).toISO(), expected, "Date with SQL format");
+            str = formatDateTime(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: true });
+            assert.strictEqual(str, "01/01/2017 11:00:00");
+            str = formatDateTime(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: true });
+            assert.strictEqual(str, "06/01/2017 12:00:00");
 
-        dateStr = "2017-05-08 09:12:34";
-        expected = "2017-05-08T09:12:34.000Z";
-        assert.equal(
-            parseDateTime(dateStr).toISO(),
-            expected,
-            "Date SQL format, check date is not confused with month"
-        );
+            unpatch(localization, "datetimeformat");
+        });
 
-        unpatch(localization, "default loc");
-    });
+        QUnit.test("parseDateTime", async (assert) => {
+            patch(localization, "default loc", defaultLocalization);
 
-    QUnit.module("dates utils compatibility with legacy", {
-        beforeEach() {
-            patchWithCleanup(localization, {
+            assert.throws(
+                function () {
+                    parseDateTime("13/01/2019 12:00:00");
+                },
+                /is not a correct/,
+                "Wrongly formated dates should be invalid"
+            );
+            assert.throws(
+                function () {
+                    parseDateTime("01/01/0999 12:00:00");
+                },
+                /is not a correct/,
+                "Dates before 1000 should be invalid"
+            );
+            assert.throws(
+                function () {
+                    parseDateTime("01/01/10000 12:00:00");
+                },
+                /is not a correct/,
+                "Dates after 9999 should be invalid"
+            );
+            assert.throws(function () {
+                parseDateTime("invalid value");
+            }, /is not a correct/);
+
+            let expected = "2019-01-13T10:05:45.000Z";
+            let dateStr = "01/13/2019 10:05:45";
+            assert.equal(parseDateTime(dateStr).toISO(), expected, "Date with leading 0");
+            dateStr = "1/13/2019 10:5:45";
+            assert.equal(parseDateTime(dateStr).toISO(), expected, "Date without leading 0");
+
+            unpatch(localization, "default loc");
+        });
+
+        QUnit.test("parseDateTime (norwegian locale)", async (assert) => {
+            const dateFormat = strftimeToLuxonFormat("%d. %b %Y");
+            const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
+            patch(localization, "weird loc", {
                 dateFormat,
                 timeFormat,
                 dateTimeFormat: `${dateFormat} ${timeFormat}`,
             });
 
-            patchWithCleanup(legacy.core._t.database.parameters, {
-                date_format: formats.date,
-                time_format: formats.time,
+            // Before WOWL we were using MomentJS.
+            // Now it has been replaced by luxon.js, we cannot declare customized locales.
+            // In legacy web, parseDateTime tests were originally defining custom locales
+            // for english and norwegian.
+            // Here, we simply use the vanilla Intl support to switch locale.
+            const originalLocale = Settings.defaultLocale;
+            Settings.defaultLocale = "no"; // Norwegian
+
+            const dateStr = "16. des 2019 10:05:45";
+            const expected = "2019-12-16T10:05:45.000Z";
+            assert.equal(
+                parseDateTime(dateStr).toISO(),
+                expected,
+                "Day/month inverted + month i18n"
+            );
+
+            Settings.defaultLocale = originalLocale;
+            unpatch(localization, "weird loc");
+        });
+
+        QUnit.test("parseDate without separator", async (assert) => {
+            const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
+            const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
+            patch(localization, "patch loc", {
+                dateFormat,
+                timeFormat,
+                dateTimeFormat: `${dateFormat} ${timeFormat}`,
             });
 
-            // Patch legacy session
-            const initialSession = Object.assign({}, legacy.session);
-            const sessionPatch = {
-                getTZOffset(date) {
-                    const offset = DateTime.local().zone.offset(date.valueOf());
-                    return offset;
+            const testDateFormat = "dd.MM/yyyy";
+
+            assert.throws(
+                function () {
+                    parseDate("1137");
                 },
-            };
-            Object.assign(legacy.session, sessionPatch);
-            registerCleanup(() => {
-                for (let key in sessionPatch) {
-                    delete legacy.session[key];
-                }
-                Object.assign(legacy.session, initialSession);
+                /is not a correct/,
+                "Wrongly formated dates should be invalid"
+            );
+            assert.throws(
+                function () {
+                    parseDate("1197");
+                },
+                /is not a correct/,
+                "Wrongly formated dates should be invalid"
+            );
+            assert.throws(
+                function () {
+                    parseDate("0131");
+                },
+                /is not a correct/,
+                "Wrongly formated dates should be invalid"
+            );
+            assert.throws(
+                function () {
+                    parseDate("970131");
+                },
+                /is not a correct/,
+                "Wrongly formated dates should be invalid"
+            );
+            assert.equal(
+                parseDate("2001").toFormat(testDateFormat),
+                "20.01/" + DateTime.utc().year
+            );
+            assert.equal(
+                parseDate("3101").toFormat(testDateFormat),
+                "31.01/" + DateTime.utc().year
+            );
+            assert.equal(
+                parseDate("31.01").toFormat(testDateFormat),
+                "31.01/" + DateTime.utc().year
+            );
+            assert.equal(parseDate("310197").toFormat(testDateFormat), "31.01/1997");
+            assert.equal(parseDate("310117").toFormat(testDateFormat), "31.01/2017");
+            assert.equal(parseDate("31011985").toFormat(testDateFormat), "31.01/1985");
+            unpatch(localization, "patch loc");
+        });
+
+        QUnit.test("parseDateTime without separator", async (assert) => {
+            const dateFormat = strftimeToLuxonFormat("%d.%m/%Y");
+            const timeFormat = strftimeToLuxonFormat("%H:%M:%S");
+            patch(localization, "patch loc", {
+                dateFormat,
+                timeFormat,
+                dateTimeFormat: `${dateFormat} ${timeFormat}`,
             });
 
-            // Patch legacy date (will also work for new dates utils): 15th July 2020 12h30
-            const unpatchDate = legacy.test_utils.mock.patchDate(2020, 6, 15, 12, 30, 0);
-            registerCleanup(() => {
-                if (unpatchDate) {
-                    unpatchDate();
-                }
+            const dateTimeFormat = "dd.MM/yyyy HH:mm/ss";
+            assert.equal(
+                parseDateTime("3101198508").toFormat(dateTimeFormat),
+                "31.01/1985 08:00/00"
+            );
+            assert.equal(
+                parseDateTime("310119850833").toFormat(dateTimeFormat),
+                "31.01/1985 08:33/00"
+            );
+            assert.equal(
+                parseDateTime("31/01/1985 08").toFormat(dateTimeFormat),
+                "31.01/1985 08:00/00"
+            );
+            unpatch(localization, "patch loc");
+        });
+
+        QUnit.test("parse smart date input", async (assert) => {
+            const format = "dd MM yyyy";
+            assert.strictEqual(
+                parseDate("+1d").toFormat(format),
+                DateTime.utc().plus({ days: 1 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDateTime("+2w").toFormat(format),
+                DateTime.utc().plus({ weeks: 2 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDate("+3m").toFormat(format),
+                DateTime.utc().plus({ months: 3 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDateTime("+4y").toFormat(format),
+                DateTime.utc().plus({ years: 4 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDate("+5").toFormat(format),
+                DateTime.utc().plus({ days: 5 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDateTime("-5").toFormat(format),
+                DateTime.utc().minus({ days: 5 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDate("-4y").toFormat(format),
+                DateTime.utc().minus({ years: 4 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDateTime("-3m").toFormat(format),
+                DateTime.utc().minus({ months: 3 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDate("-2w").toFormat(format),
+                DateTime.utc().minus({ weeks: 2 }).toFormat(format)
+            );
+            assert.strictEqual(
+                parseDateTime("-1d").toFormat(format),
+                DateTime.utc().minus({ days: 1 }).toFormat(format)
+            );
+        });
+
+        QUnit.test("parseDateTime SQL Format", async (assert) => {
+            patch(localization, "default loc", defaultLocalization);
+
+            let dateStr = "2017-05-15 09:12:34";
+            let expected = "2017-05-15T09:12:34.000Z";
+            assert.equal(parseDateTime(dateStr).toISO(), expected, "Date with SQL format");
+
+            dateStr = "2017-05-08 09:12:34";
+            expected = "2017-05-08T09:12:34.000Z";
+            assert.equal(
+                parseDateTime(dateStr).toISO(),
+                expected,
+                "Date SQL format, check date is not confused with month"
+            );
+
+            unpatch(localization, "default loc");
+        });
+
+        QUnit.module("dates utils compatibility with legacy", {
+            beforeEach() {
+                patchWithCleanup(localization, {
+                    dateFormat,
+                    timeFormat,
+                    dateTimeFormat: `${dateFormat} ${timeFormat}`,
+                });
+
+                patchWithCleanup(legacy.core._t.database.parameters, {
+                    date_format: formats.date,
+                    time_format: formats.time,
+                });
+
+                // Patch legacy session
+                const initialSession = Object.assign({}, legacy.session);
+                const sessionPatch = {
+                    getTZOffset(date) {
+                        const offset = DateTime.local().zone.offset(date.valueOf());
+                        return offset;
+                    },
+                };
+                Object.assign(legacy.session, sessionPatch);
+                registerCleanup(() => {
+                    for (let key in sessionPatch) {
+                        delete legacy.session[key];
+                    }
+                    Object.assign(legacy.session, initialSession);
+                });
+
+                // Patch legacy date (will also work for new dates utils): 15th July 2020 12h30
+                const unpatchDate = legacy.test_utils.mock.patchDate(2020, 6, 15, 12, 30, 0);
+                registerCleanup(() => {
+                    if (unpatchDate) {
+                        unpatchDate();
+                    }
+                });
+            },
+        });
+
+        QUnit.test("parseDate", async (assert) => {
+            /**
+             * Type of testSet key: string
+             * Type of testSet value: [newExpected: string, legacyExpected: string]
+             */
+            const testSet = new Map([
+                ["10101010101010", ["1010-10-10T00:00:00.000Z"]],
+                ["1191111", ["1191-04-21T00:00:00.000Z"]], // day 111 of year 1191
+                ["11911111", ["1191-11-11T00:00:00.000Z"]],
+                ["3101", ["2020-01-31T00:00:00.000Z"]],
+                ["310160", ["2060-01-31T00:00:00.000Z"]],
+                ["311260", ["2060-12-31T00:00:00.000Z"]],
+
+                ["310161", ["1961-01-31T00:00:00.000Z", "2061-01-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
+                ["310165", ["1965-01-31T00:00:00.000Z", "2065-01-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
+                ["310168", ["1968-01-31T00:00:00.000Z", "2068-01-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
+                ["311268", ["1968-12-31T00:00:00.000Z", "2068-12-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
+
+                ["310169", ["1969-01-31T00:00:00.000Z"]],
+                ["310170", ["1970-01-31T00:00:00.000Z"]],
+                ["310197", ["1997-01-31T00:00:00.000Z"]],
+                ["310117", ["2017-01-31T00:00:00.000Z"]],
+                ["31011985", ["1985-01-31T00:00:00.000Z"]],
+                ["3101198508", ["1985-01-31T00:00:00.000Z"]],
+                ["310119850833", ["1985-01-31T00:00:00.000Z"]],
+
+                ["1137", [undefined]],
+                ["1197", [undefined]],
+                ["0131", [undefined]],
+                ["0922", [undefined]],
+                ["2020", [undefined]],
+
+                ["199901", ["1999-01-01T00:00:00.000Z", undefined]],
+                ["30100210", ["3010-02-10T00:00:00.000Z", undefined]],
+                ["3010210", ["3010-07-29T00:00:00.000Z", undefined]],
+
+                ["970131", [undefined]],
+                ["31.01", ["2020-01-31T00:00:00.000Z"]],
+                ["31/01/1985 08", ["1985-01-31T00:00:00.000Z"]],
+
+                ["01121934", ["1934-12-01T00:00:00.000Z"]],
+                ["011234", ["2034-12-01T00:00:00.000Z"]],
+                ["011260", ["2060-12-01T00:00:00.000Z"]],
+                ["2", ["2020-07-02T00:00:00.000Z"]],
+                ["02", ["2020-07-02T00:00:00.000Z"]],
+                ["20", ["2020-07-20T00:00:00.000Z"]],
+                ["202", ["2020-02-20T00:00:00.000Z"]],
+                ["2002", ["2020-02-20T00:00:00.000Z"]],
+                ["0202", ["2020-02-02T00:00:00.000Z"]],
+                ["02/02", ["2020-02-02T00:00:00.000Z"]],
+                ["02/13", [undefined]],
+                ["02/1313", [undefined]],
+                ["09990101", [undefined]],
+                ["19990101", ["1999-01-01T00:00:00.000Z"]],
+                ["19990130", ["1999-01-30T00:00:00.000Z"]],
+                ["19991230", ["1999-12-30T00:00:00.000Z"]],
+                ["19993012", [undefined]],
+                ["2016-200", ["2016-07-18T00:00:00.000Z"]],
+                ["2016200", ["2016-07-18T00:00:00.000Z"]], // day 200 of year 2016
+                ["2020-", [undefined]],
+                ["2020-W2", [undefined]],
+                ["2020W23", ["2020-06-01T00:00:00.000Z"]],
+                ["2020-W02", ["2020-01-06T00:00:00.000Z"]],
+                ["2020-W32", ["2020-08-03T00:00:00.000Z"]],
+                ["2020-W32-3", ["2020-08-05T00:00:00.000Z"]],
+                ["2016-W21-3", ["2016-05-25T00:00:00.000Z"]],
+                ["2016W213", ["2016-05-25T00:00:00.000Z"]],
+                ["2209", ["2020-09-22T00:00:00.000Z"]],
+                ["22:09", ["2020-09-22T00:00:00.000Z"]],
+                ["2012", ["2020-12-20T00:00:00.000Z"]],
+
+                [
+                    "2016-01-03 09:24:15.123",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ],
+                [
+                    "2016-01-03T09:24:15.123",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ],
+                [
+                    "2016-01-03T09:24:15.123+06:00",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T03:24:15.123Z"],
+                ],
+                [
+                    "2016-01-03T09:24:15.123+16:00",
+                    ["2016-01-02T00:00:00.000Z", "2016-01-02T17:24:15.123Z"],
+                ],
+                [
+                    "2016-01-03T09:24:15.123Z",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ],
+                [
+                    "2016-W21-3T09:24:15.123",
+                    ["2016-05-25T00:00:00.000Z", "2016-05-25T09:24:15.123Z"],
+                ],
+                ["2016-W21-3 09:24:15.123", [undefined, "2016-05-25T09:24:15.123Z"]],
+
+                ["2016-03-27T02:00:00.000+02:00", ["2016-03-27T00:00:00.000Z"]],
+                [
+                    "2016-03-27T03:00:00.000+02:00",
+                    ["2016-03-27T00:00:00.000Z", "2016-03-27T01:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T02:00:00.000",
+                    ["2016-03-27T00:00:00.000Z", "2016-03-27T02:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T03:00:00.000",
+                    ["2016-03-27T00:00:00.000Z", "2016-03-27T03:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T02:00:00.000Z",
+                    ["2016-03-27T00:00:00.000Z", "2016-03-27T02:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T03:00:00.000Z",
+                    ["2016-03-27T00:00:00.000Z", "2016-03-27T03:00:00.000Z"],
+                ],
+
+                ["09:22", [undefined]],
+                ["2013", [undefined]],
+                ["011261", ["1961-12-01T00:00:00.000Z", "2061-12-01T00:00:00.000Z"]],
+            ]);
+
+            runTestSet(assert, testSet, {
+                newFn: (input) => parseDate(input).toISO(),
+                legacyFn: (input) => legacy.field_utils.parse.date(input).toISOString(),
             });
-        },
-    });
-
-    QUnit.test("parseDate", async (assert) => {
-        /**
-         * Type of testSet key: string
-         * Type of testSet value: [newExpected: string, legacyExpected: string]
-         */
-        const testSet = new Map([
-            ["10101010101010", ["1010-10-10T00:00:00.000Z"]],
-            ["1191111", ["1191-04-21T00:00:00.000Z"]], // day 111 of year 1191
-            ["11911111", ["1191-11-11T00:00:00.000Z"]],
-            ["3101", ["2020-01-31T00:00:00.000Z"]],
-            ["310160", ["2060-01-31T00:00:00.000Z"]],
-            ["311260", ["2060-12-31T00:00:00.000Z"]],
-
-            ["310161", ["1961-01-31T00:00:00.000Z", "2061-01-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
-            ["310165", ["1965-01-31T00:00:00.000Z", "2065-01-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
-            ["310168", ["1968-01-31T00:00:00.000Z", "2068-01-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
-            ["311268", ["1968-12-31T00:00:00.000Z", "2068-12-31T00:00:00.000Z"]], // moment vs luxon 19xx/20xx thresholds
-
-            ["310169", ["1969-01-31T00:00:00.000Z"]],
-            ["310170", ["1970-01-31T00:00:00.000Z"]],
-            ["310197", ["1997-01-31T00:00:00.000Z"]],
-            ["310117", ["2017-01-31T00:00:00.000Z"]],
-            ["31011985", ["1985-01-31T00:00:00.000Z"]],
-            ["3101198508", ["1985-01-31T00:00:00.000Z"]],
-            ["310119850833", ["1985-01-31T00:00:00.000Z"]],
-
-            ["1137", [undefined]],
-            ["1197", [undefined]],
-            ["0131", [undefined]],
-            ["0922", [undefined]],
-            ["2020", [undefined]],
-
-            ["199901", ["1999-01-01T00:00:00.000Z", undefined]],
-            ["30100210", ["3010-02-10T00:00:00.000Z", undefined]],
-            ["3010210", ["3010-07-29T00:00:00.000Z", undefined]],
-
-            ["970131", [undefined]],
-            ["31.01", ["2020-01-31T00:00:00.000Z"]],
-            ["31/01/1985 08", ["1985-01-31T00:00:00.000Z"]],
-
-            ["01121934", ["1934-12-01T00:00:00.000Z"]],
-            ["011234", ["2034-12-01T00:00:00.000Z"]],
-            ["011260", ["2060-12-01T00:00:00.000Z"]],
-            ["2", ["2020-07-02T00:00:00.000Z"]],
-            ["02", ["2020-07-02T00:00:00.000Z"]],
-            ["20", ["2020-07-20T00:00:00.000Z"]],
-            ["202", ["2020-02-20T00:00:00.000Z"]],
-            ["2002", ["2020-02-20T00:00:00.000Z"]],
-            ["0202", ["2020-02-02T00:00:00.000Z"]],
-            ["02/02", ["2020-02-02T00:00:00.000Z"]],
-            ["02/13", [undefined]],
-            ["02/1313", [undefined]],
-            ["09990101", [undefined]],
-            ["19990101", ["1999-01-01T00:00:00.000Z"]],
-            ["19990130", ["1999-01-30T00:00:00.000Z"]],
-            ["19991230", ["1999-12-30T00:00:00.000Z"]],
-            ["19993012", [undefined]],
-            ["2016-200", ["2016-07-18T00:00:00.000Z"]],
-            ["2016200", ["2016-07-18T00:00:00.000Z"]], // day 200 of year 2016
-            ["2020-", [undefined]],
-            ["2020-W2", [undefined]],
-            ["2020W23", ["2020-06-01T00:00:00.000Z"]],
-            ["2020-W02", ["2020-01-06T00:00:00.000Z"]],
-            ["2020-W32", ["2020-08-03T00:00:00.000Z"]],
-            ["2020-W32-3", ["2020-08-05T00:00:00.000Z"]],
-            ["2016-W21-3", ["2016-05-25T00:00:00.000Z"]],
-            ["2016W213", ["2016-05-25T00:00:00.000Z"]],
-            ["2209", ["2020-09-22T00:00:00.000Z"]],
-            ["22:09", ["2020-09-22T00:00:00.000Z"]],
-            ["2012", ["2020-12-20T00:00:00.000Z"]],
-
-            ["2016-01-03 09:24:15.123", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]],
-            [
-                "2016-01-03T09:24:15.123+06:00",
-                ["2016-01-03T00:00:00.000Z", "2016-01-03T03:24:15.123Z"],
-            ],
-            [
-                "2016-01-03T09:24:15.123+16:00",
-                ["2016-01-02T00:00:00.000Z", "2016-01-02T17:24:15.123Z"],
-            ],
-            ["2016-01-03T09:24:15.123Z", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]],
-            ["2016-W21-3T09:24:15.123", ["2016-05-25T00:00:00.000Z", "2016-05-25T09:24:15.123Z"]],
-            ["2016-W21-3 09:24:15.123", [undefined, "2016-05-25T09:24:15.123Z"]],
-
-            ["2016-03-27T02:00:00.000+02:00", ["2016-03-27T00:00:00.000Z"]],
-            [
-                "2016-03-27T03:00:00.000+02:00",
-                ["2016-03-27T00:00:00.000Z", "2016-03-27T01:00:00.000Z"],
-            ],
-            ["2016-03-27T02:00:00.000", ["2016-03-27T00:00:00.000Z", "2016-03-27T02:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000", ["2016-03-27T00:00:00.000Z", "2016-03-27T03:00:00.000Z"]],
-            ["2016-03-27T02:00:00.000Z", ["2016-03-27T00:00:00.000Z", "2016-03-27T02:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000Z", ["2016-03-27T00:00:00.000Z", "2016-03-27T03:00:00.000Z"]],
-
-            ["09:22", [undefined]],
-            ["2013", [undefined]],
-            ["011261", ["1961-12-01T00:00:00.000Z", "2061-12-01T00:00:00.000Z"]],
-        ]);
-
-        runTestSet(assert, testSet, {
-            newFn: (input) => parseDate(input).toISO(),
-            legacyFn: (input) => legacy.field_utils.parse.date(input).toISOString(),
         });
-    });
 
-    QUnit.test("parseDate (with legacy options.isUTC = true)", async (assert) => {
-        /**
-         * Type of testSet key: string
-         * Type of testSet value: [newExpected: string, legacyExpected: string]
-         */
-        const testSet = new Map([
-            ["932-10-10", [undefined]], // year < 1000 are not supported
-            ["1932-10-10", ["1932-10-10T00:00:00.000Z"]],
-            ["09990101", [undefined]], // year < 1000 are not supported
-            ["19993012", [undefined]], // there is no 30th month
-            ["19990101", ["1999-01-01T00:00:00.000Z", undefined]],
-            ["19990130", ["1999-01-30T00:00:00.000Z", undefined]],
-            ["19991230", ["1999-12-30T00:00:00.000Z", undefined]],
-            ["2016-01-03 09:24:15.123", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]], // Outputting the time was weird in legacy anyway
-            ["2016-01-03T09:24:15.123", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]], // Outputting the time was weird in legacy anyway
-            [
-                "2016-01-03 09:24:15.123+06:00",
-                ["2016-01-03T00:00:00.000Z", "2016-01-03T03:24:15.123Z"],
-            ], // Outputting the time was weird in legacy anyway
-            [
-                "2016-01-03T09:24:15.123+06:00",
-                ["2016-01-03T00:00:00.000Z", "2016-01-03T03:24:15.123Z"],
-            ], // Outputting the time was weird in legacy anyway
-            [
-                "2016-01-03 09:24:15.123+16:00",
-                ["2016-01-02T00:00:00.000Z", "2016-01-02T17:24:15.123Z"],
-            ], // Outputting the time was weird in legacy anyway
-            [
-                "2016-01-03T09:24:15.123+16:00",
-                ["2016-01-02T00:00:00.000Z", "2016-01-02T17:24:15.123Z"],
-            ], // Outputting the time was weird in legacy anyway
-            ["2016-01-03 09:24:15.123Z", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]], // Outputting the time was weird in legacy anyway
-            ["2016-01-03T09:24:15.123Z", ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"]], // Outputting the time was weird in legacy anyway
-        ]);
+        QUnit.test("parseDate (with legacy options.isUTC = true)", async (assert) => {
+            /**
+             * Type of testSet key: string
+             * Type of testSet value: [newExpected: string, legacyExpected: string]
+             */
+            const testSet = new Map([
+                ["932-10-10", [undefined]], // year < 1000 are not supported
+                ["1932-10-10", ["1932-10-10T00:00:00.000Z"]],
+                ["09990101", [undefined]], // year < 1000 are not supported
+                ["19993012", [undefined]], // there is no 30th month
+                ["19990101", ["1999-01-01T00:00:00.000Z", undefined]],
+                ["19990130", ["1999-01-30T00:00:00.000Z", undefined]],
+                ["19991230", ["1999-12-30T00:00:00.000Z", undefined]],
+                [
+                    "2016-01-03 09:24:15.123",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03T09:24:15.123",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03 09:24:15.123+06:00",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T03:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03T09:24:15.123+06:00",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T03:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03 09:24:15.123+16:00",
+                    ["2016-01-02T00:00:00.000Z", "2016-01-02T17:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03T09:24:15.123+16:00",
+                    ["2016-01-02T00:00:00.000Z", "2016-01-02T17:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03 09:24:15.123Z",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+                [
+                    "2016-01-03T09:24:15.123Z",
+                    ["2016-01-03T00:00:00.000Z", "2016-01-03T09:24:15.123Z"],
+                ], // Outputting the time was weird in legacy anyway
+            ]);
 
-        runTestSet(assert, testSet, {
-            newFn: (input) => parseDate(input, { format: "YYYY-MM-DD" }).toISO(),
-            legacyFn: (input) =>
-                legacy.field_utils.parse.date(input, null, { isUTC: true }).toISOString(),
+            runTestSet(assert, testSet, {
+                newFn: (input) => parseDate(input, { format: "YYYY-MM-DD" }).toISO(),
+                legacyFn: (input) =>
+                    legacy.field_utils.parse.date(input, null, { isUTC: true }).toISOString(),
+            });
         });
-    });
 
-    QUnit.test("parseDateTime", async (assert) => {
-        /**
-         * Type of testSet key: string
-         * Type of testSet value: [newExpected: string, legacyExpected: string]
-         */
-        const testSet = new Map([
-            ["10101010101010", ["1010-10-10T10:10:10.000Z"]],
-            ["1191111", ["1191-04-21T00:00:00.000Z"]], // day 111 of year 1191
-            ["11911111", ["1191-11-11T00:00:00.000Z"]],
-            ["3101", ["2020-01-31T00:00:00.000Z"]],
-            ["310160", ["2060-01-31T00:00:00.000Z"]],
-            ["311260", ["2060-12-31T00:00:00.000Z"]],
-            ["310161", ["1961-01-31T00:00:00.000Z", "2061-01-31T00:00:00.000Z"]],
-            ["310165", ["1965-01-31T00:00:00.000Z", "2065-01-31T00:00:00.000Z"]],
-            ["310168", ["1968-01-31T00:00:00.000Z", "2068-01-31T00:00:00.000Z"]],
-            ["311268", ["1968-12-31T00:00:00.000Z", "2068-12-31T00:00:00.000Z"]],
-            ["310169", ["1969-01-31T00:00:00.000Z"]],
-            ["310170", ["1970-01-31T00:00:00.000Z"]],
-            ["310197", ["1997-01-31T00:00:00.000Z"]],
-            ["310117", ["2017-01-31T00:00:00.000Z"]],
-            ["31011985", ["1985-01-31T00:00:00.000Z"]],
-            ["3101198508", ["1985-01-31T08:00:00.000Z"]],
-            ["310119850833", ["1985-01-31T08:33:00.000Z"]],
-            ["1137", [undefined]],
-            ["1197", [undefined]],
-            ["0131", [undefined]],
-            ["0922", [undefined]],
-            ["2020", [undefined]],
-            ["199901", ["1999-01-01T00:00:00.000Z", undefined]],
-            ["30100210", ["3010-02-10T00:00:00.000Z"]],
-            ["3010210", ["3010-07-29T00:00:00.000Z"]],
-            ["970131", [undefined]],
-            ["31.01", ["2020-01-31T00:00:00.000Z"]],
-            ["31/01/1985 08", ["1985-01-31T08:00:00.000Z"]],
+        QUnit.test("parseDateTime", async (assert) => {
+            /**
+             * Type of testSet key: string
+             * Type of testSet value: [newExpected: string, legacyExpected: string]
+             */
+            const testSet = new Map([
+                ["10101010101010", ["1010-10-10T10:10:10.000Z"]],
+                ["1191111", ["1191-04-21T00:00:00.000Z"]], // day 111 of year 1191
+                ["11911111", ["1191-11-11T00:00:00.000Z"]],
+                ["3101", ["2020-01-31T00:00:00.000Z"]],
+                ["310160", ["2060-01-31T00:00:00.000Z"]],
+                ["311260", ["2060-12-31T00:00:00.000Z"]],
+                ["310161", ["1961-01-31T00:00:00.000Z", "2061-01-31T00:00:00.000Z"]],
+                ["310165", ["1965-01-31T00:00:00.000Z", "2065-01-31T00:00:00.000Z"]],
+                ["310168", ["1968-01-31T00:00:00.000Z", "2068-01-31T00:00:00.000Z"]],
+                ["311268", ["1968-12-31T00:00:00.000Z", "2068-12-31T00:00:00.000Z"]],
+                ["310169", ["1969-01-31T00:00:00.000Z"]],
+                ["310170", ["1970-01-31T00:00:00.000Z"]],
+                ["310197", ["1997-01-31T00:00:00.000Z"]],
+                ["310117", ["2017-01-31T00:00:00.000Z"]],
+                ["31011985", ["1985-01-31T00:00:00.000Z"]],
+                ["3101198508", ["1985-01-31T08:00:00.000Z"]],
+                ["310119850833", ["1985-01-31T08:33:00.000Z"]],
+                ["1137", [undefined]],
+                ["1197", [undefined]],
+                ["0131", [undefined]],
+                ["0922", [undefined]],
+                ["2020", [undefined]],
+                ["199901", ["1999-01-01T00:00:00.000Z", undefined]],
+                ["30100210", ["3010-02-10T00:00:00.000Z"]],
+                ["3010210", ["3010-07-29T00:00:00.000Z"]],
+                ["970131", [undefined]],
+                ["31.01", ["2020-01-31T00:00:00.000Z"]],
+                ["31/01/1985 08", ["1985-01-31T08:00:00.000Z"]],
 
-            ["01121934", ["1934-12-01T00:00:00.000Z"]],
-            ["011234", ["2034-12-01T00:00:00.000Z"]],
-            ["011260", ["2060-12-01T00:00:00.000Z"]],
-            ["2", ["2020-07-02T00:00:00.000Z"]],
-            ["02", ["2020-07-02T00:00:00.000Z"]],
-            ["20", ["2020-07-20T00:00:00.000Z"]],
-            ["202", ["2020-02-20T00:00:00.000Z"]],
-            ["2002", ["2020-02-20T00:00:00.000Z"]],
-            ["0202", ["2020-02-02T00:00:00.000Z"]],
-            ["02/02", ["2020-02-02T00:00:00.000Z"]],
-            ["02/13", [undefined]],
-            ["02/1313", [undefined]],
-            ["09990101", [undefined]],
-            ["19990101", ["1999-01-01T00:00:00.000Z"]],
-            ["19990130", ["1999-01-30T00:00:00.000Z"]],
-            ["19991230", ["1999-12-30T00:00:00.000Z"]],
-            ["19993012", [undefined]],
-            ["2016-200", ["2016-07-18T00:00:00.000Z"]],
-            ["2016200", ["2016-07-18T00:00:00.000Z"]], // day 200 of year 2016
-            ["2020-", [undefined]],
-            ["2020-W2", [undefined]],
-            ["2020W23", ["2020-06-01T00:00:00.000Z"]],
-            ["2020-W02", ["2020-01-06T00:00:00.000Z"]],
-            ["2020-W32", ["2020-08-03T00:00:00.000Z"]],
-            ["2020-W32-3", ["2020-08-05T00:00:00.000Z"]],
-            ["2016-W21-3", ["2016-05-25T00:00:00.000Z"]],
-            ["2016W213", ["2016-05-25T00:00:00.000Z"]],
-            ["2209", ["2020-09-22T00:00:00.000Z"]],
-            ["22:09", ["2020-09-22T00:00:00.000Z"]],
-            ["2012", ["2020-12-20T00:00:00.000Z"]],
+                ["01121934", ["1934-12-01T00:00:00.000Z"]],
+                ["011234", ["2034-12-01T00:00:00.000Z"]],
+                ["011260", ["2060-12-01T00:00:00.000Z"]],
+                ["2", ["2020-07-02T00:00:00.000Z"]],
+                ["02", ["2020-07-02T00:00:00.000Z"]],
+                ["20", ["2020-07-20T00:00:00.000Z"]],
+                ["202", ["2020-02-20T00:00:00.000Z"]],
+                ["2002", ["2020-02-20T00:00:00.000Z"]],
+                ["0202", ["2020-02-02T00:00:00.000Z"]],
+                ["02/02", ["2020-02-02T00:00:00.000Z"]],
+                ["02/13", [undefined]],
+                ["02/1313", [undefined]],
+                ["09990101", [undefined]],
+                ["19990101", ["1999-01-01T00:00:00.000Z"]],
+                ["19990130", ["1999-01-30T00:00:00.000Z"]],
+                ["19991230", ["1999-12-30T00:00:00.000Z"]],
+                ["19993012", [undefined]],
+                ["2016-200", ["2016-07-18T00:00:00.000Z"]],
+                ["2016200", ["2016-07-18T00:00:00.000Z"]], // day 200 of year 2016
+                ["2020-", [undefined]],
+                ["2020-W2", [undefined]],
+                ["2020W23", ["2020-06-01T00:00:00.000Z"]],
+                ["2020-W02", ["2020-01-06T00:00:00.000Z"]],
+                ["2020-W32", ["2020-08-03T00:00:00.000Z"]],
+                ["2020-W32-3", ["2020-08-05T00:00:00.000Z"]],
+                ["2016-W21-3", ["2016-05-25T00:00:00.000Z"]],
+                ["2016W213", ["2016-05-25T00:00:00.000Z"]],
+                ["2209", ["2020-09-22T00:00:00.000Z"]],
+                ["22:09", ["2020-09-22T00:00:00.000Z"]],
+                ["2012", ["2020-12-20T00:00:00.000Z"]],
 
-            ["2016-01-03 09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123+06:00", ["2016-01-03T03:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123+16:00", ["2016-01-02T17:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123Z", ["2016-01-03T09:24:15.123Z"]],
-            ["2016-W21-3T09:24:15.123", ["2016-05-25T09:24:15.123Z"]],
-            ["2016-W21-3 09:24:15.123", [undefined, "2016-05-25T09:24:15.123Z"]],
+                ["2016-01-03 09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
+                ["2016-01-03T09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
+                ["2016-01-03T09:24:15.123+06:00", ["2016-01-03T03:24:15.123Z"]],
+                ["2016-01-03T09:24:15.123+16:00", ["2016-01-02T17:24:15.123Z"]],
+                ["2016-01-03T09:24:15.123Z", ["2016-01-03T09:24:15.123Z"]],
+                ["2016-W21-3T09:24:15.123", ["2016-05-25T09:24:15.123Z"]],
+                ["2016-W21-3 09:24:15.123", [undefined, "2016-05-25T09:24:15.123Z"]],
 
-            ["2016-03-27T02:00:00.000+02:00", ["2016-03-27T00:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000+02:00", ["2016-03-27T01:00:00.000Z"]],
-            ["2016-03-27T02:00:00.000", ["2016-03-27T02:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000", ["2016-03-27T03:00:00.000Z"]],
-            ["2016-03-27T02:00:00.000Z", ["2016-03-27T02:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000Z", ["2016-03-27T03:00:00.000Z"]],
+                ["2016-03-27T02:00:00.000+02:00", ["2016-03-27T00:00:00.000Z"]],
+                ["2016-03-27T03:00:00.000+02:00", ["2016-03-27T01:00:00.000Z"]],
+                ["2016-03-27T02:00:00.000", ["2016-03-27T02:00:00.000Z"]],
+                ["2016-03-27T03:00:00.000", ["2016-03-27T03:00:00.000Z"]],
+                ["2016-03-27T02:00:00.000Z", ["2016-03-27T02:00:00.000Z"]],
+                ["2016-03-27T03:00:00.000Z", ["2016-03-27T03:00:00.000Z"]],
 
-            ["09:22", [undefined]],
-            ["2013", [undefined]],
-            ["011261", ["1961-12-01T00:00:00.000Z", "2061-12-01T00:00:00.000Z"]],
-        ]);
+                ["09:22", [undefined]],
+                ["2013", [undefined]],
+                ["011261", ["1961-12-01T00:00:00.000Z", "2061-12-01T00:00:00.000Z"]],
+            ]);
 
-        runTestSet(assert, testSet, {
-            newFn: (input) => parseDateTime(input).toISO(),
-            legacyFn: (input) => legacy.field_utils.parse.datetime(input).toISOString(),
+            runTestSet(assert, testSet, {
+                newFn: (input) => parseDateTime(input).toISO(),
+                legacyFn: (input) => legacy.field_utils.parse.datetime(input).toISOString(),
+            });
         });
-    });
 
-    QUnit.test("parseDateTime (with legacy options.isUTC = true)", async (assert) => {
-        /**
-         * Type of testSet key: string
-         * Type of testSet value: [newExpected: string, legacyExpected: string]
-         */
-        const testSet = new Map([
-            ["932-10-10", [undefined, "2000-10-10T00:00:00.000Z"]], // weird behaviour in legacy
-            ["1932-10-10", ["1932-10-10T00:00:00.000Z", "2000-10-10T00:00:00.000Z"]], // weird behaviour in legacy
-            ["09990101", [undefined, "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
-            ["19993012", [undefined, "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
+        QUnit.test("parseDateTime (with legacy options.isUTC = true)", async (assert) => {
+            /**
+             * Type of testSet key: string
+             * Type of testSet value: [newExpected: string, legacyExpected: string]
+             */
+            const testSet = new Map([
+                ["932-10-10", [undefined, "2000-10-10T00:00:00.000Z"]], // weird behaviour in legacy
+                ["1932-10-10", ["1932-10-10T00:00:00.000Z", "2000-10-10T00:00:00.000Z"]], // weird behaviour in legacy
+                ["09990101", [undefined, "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
+                ["19993012", [undefined, "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
 
-            ["19990101", ["1999-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
-            ["19990130", ["1999-01-30T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
-            ["19991230", ["1999-12-30T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
-            ["2016-01-03 09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
-            ["2016-01-03 09:24:15.123+06:00", ["2016-01-03T03:24:15.123Z", undefined]],
-            ["2016-01-03T09:24:15.123+06:00", ["2016-01-03T03:24:15.123Z", undefined]],
-            ["2016-01-03 09:24:15.123+16:00", ["2016-01-02T17:24:15.123Z", undefined]],
-            ["2016-01-03T09:24:15.123+16:00", ["2016-01-02T17:24:15.123Z", undefined]],
-            ["2016-01-03 09:24:15.123Z", ["2016-01-03T09:24:15.123Z", undefined]], // weird behaviour in legacy
-            ["2016-01-03T09:24:15.123Z", ["2016-01-03T09:24:15.123Z", undefined]], // weird behaviour in legacy
-        ]);
+                ["19990101", ["1999-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
+                ["19990130", ["1999-01-30T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
+                ["19991230", ["1999-12-30T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]], // weird behaviour in legacy
+                ["2016-01-03 09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
+                ["2016-01-03T09:24:15.123", ["2016-01-03T09:24:15.123Z"]],
+                ["2016-01-03 09:24:15.123+06:00", ["2016-01-03T03:24:15.123Z", undefined]],
+                ["2016-01-03T09:24:15.123+06:00", ["2016-01-03T03:24:15.123Z", undefined]],
+                ["2016-01-03 09:24:15.123+16:00", ["2016-01-02T17:24:15.123Z", undefined]],
+                ["2016-01-03T09:24:15.123+16:00", ["2016-01-02T17:24:15.123Z", undefined]],
+                ["2016-01-03 09:24:15.123Z", ["2016-01-03T09:24:15.123Z", undefined]], // weird behaviour in legacy
+                ["2016-01-03T09:24:15.123Z", ["2016-01-03T09:24:15.123Z", undefined]], // weird behaviour in legacy
+            ]);
 
-        runTestSet(assert, testSet, {
-            newFn: (input) => parseDateTime(input, { format: "YYYY-MM-DD HH:mm:ss" }).toISO(),
-            legacyFn: (input) =>
-                legacy.field_utils.parse.datetime(input, null, { isUTC: true }).toISOString(),
+            runTestSet(assert, testSet, {
+                newFn: (input) => parseDateTime(input, { format: "YYYY-MM-DD HH:mm:ss" }).toISO(),
+                legacyFn: (input) =>
+                    legacy.field_utils.parse.datetime(input, null, { isUTC: true }).toISOString(),
+            });
         });
-    });
 
-    QUnit.test("parseDateTime (with options.timezone = true)", async (assert) => {
-        /**
-         * Type of testSet key: string
-         * Type of testSet value: [newExpected: string, legacyExpected: string]
-         */
-        const testSet = new Map([
-            ["10101010101010", ["1010-10-10T09:52:40.000Z"]],
-            ["1191111", ["1191-04-20T23:42:30.000Z"]], // day 111 of year 1191
-            ["11911111", ["1191-11-10T23:42:30.000Z"]],
-            ["3101", ["2020-01-30T23:00:00.000Z"]],
-            ["310160", ["2060-01-30T23:00:00.000Z"]],
-            ["311260", ["2060-12-30T23:00:00.000Z"]],
-            ["310161", ["1961-01-30T23:00:00.000Z", "2061-01-30T23:00:00.000Z"]],
-            ["310165", ["1965-01-30T23:00:00.000Z", "2065-01-30T23:00:00.000Z"]],
-            ["310168", ["1968-01-30T23:00:00.000Z", "2068-01-30T23:00:00.000Z"]],
-            ["311268", ["1968-12-30T23:00:00.000Z", "2068-12-30T23:00:00.000Z"]],
-            ["310169", ["1969-01-30T23:00:00.000Z"]],
-            ["310170", ["1970-01-30T23:00:00.000Z"]],
-            ["310197", ["1997-01-30T23:00:00.000Z"]],
-            ["310117", ["2017-01-30T23:00:00.000Z"]],
-            ["31011985", ["1985-01-30T23:00:00.000Z"]],
-            ["3101198508", ["1985-01-31T07:00:00.000Z"]],
-            ["310119850833", ["1985-01-31T07:33:00.000Z"]],
-            ["1137", [undefined]],
-            ["1197", [undefined]],
-            ["0131", [undefined]],
-            ["0922", [undefined]],
-            ["2020", [undefined]],
-            ["199901", ["1998-12-31T23:00:00.000Z", undefined]],
-            ["30100210", ["3010-02-09T23:00:00.000Z"]],
-            ["3010210", ["3010-07-28T22:00:00.000Z"]],
-            ["970131", [undefined]],
-            ["31.01", ["2020-01-30T23:00:00.000Z"]],
-            ["31/01/1985 08", ["1985-01-31T07:00:00.000Z"]],
+        QUnit.test("parseDateTime (with options.timezone = true)", async (assert) => {
+            /**
+             * Type of testSet key: string
+             * Type of testSet value: [newExpected: string, legacyExpected: string]
+             */
+            const testSet = new Map([
+                ["10101010101010", ["1010-10-10T09:52:40.000Z"]],
+                ["1191111", ["1191-04-20T23:42:30.000Z"]], // day 111 of year 1191
+                ["11911111", ["1191-11-10T23:42:30.000Z"]],
+                ["3101", ["2020-01-30T23:00:00.000Z"]],
+                ["310160", ["2060-01-30T23:00:00.000Z"]],
+                ["311260", ["2060-12-30T23:00:00.000Z"]],
+                ["310161", ["1961-01-30T23:00:00.000Z", "2061-01-30T23:00:00.000Z"]],
+                ["310165", ["1965-01-30T23:00:00.000Z", "2065-01-30T23:00:00.000Z"]],
+                ["310168", ["1968-01-30T23:00:00.000Z", "2068-01-30T23:00:00.000Z"]],
+                ["311268", ["1968-12-30T23:00:00.000Z", "2068-12-30T23:00:00.000Z"]],
+                ["310169", ["1969-01-30T23:00:00.000Z"]],
+                ["310170", ["1970-01-30T23:00:00.000Z"]],
+                ["310197", ["1997-01-30T23:00:00.000Z"]],
+                ["310117", ["2017-01-30T23:00:00.000Z"]],
+                ["31011985", ["1985-01-30T23:00:00.000Z"]],
+                ["3101198508", ["1985-01-31T07:00:00.000Z"]],
+                ["310119850833", ["1985-01-31T07:33:00.000Z"]],
+                ["1137", [undefined]],
+                ["1197", [undefined]],
+                ["0131", [undefined]],
+                ["0922", [undefined]],
+                ["2020", [undefined]],
+                ["199901", ["1998-12-31T23:00:00.000Z", undefined]],
+                ["30100210", ["3010-02-09T23:00:00.000Z"]],
+                ["3010210", ["3010-07-28T22:00:00.000Z"]],
+                ["970131", [undefined]],
+                ["31.01", ["2020-01-30T23:00:00.000Z"]],
+                ["31/01/1985 08", ["1985-01-31T07:00:00.000Z"]],
 
-            ["01121934", ["1934-12-01T00:00:00.000Z"]],
-            ["011234", ["2034-11-30T23:00:00.000Z"]],
-            ["011260", ["2060-11-30T23:00:00.000Z"]],
-            ["2", ["2020-07-01T22:00:00.000Z"]],
-            ["02", ["2020-07-01T22:00:00.000Z"]],
-            ["20", ["2020-07-19T22:00:00.000Z"]],
-            ["202", ["2020-02-19T23:00:00.000Z"]],
-            ["2002", ["2020-02-19T23:00:00.000Z"]],
-            ["0202", ["2020-02-01T23:00:00.000Z"]],
-            ["02/02", ["2020-02-01T23:00:00.000Z"]],
-            ["02/13", [undefined]],
-            ["02/1313", [undefined]],
-            ["09990101", [undefined]],
-            ["19990101", ["1998-12-31T23:00:00.000Z"]],
-            ["19990130", ["1999-01-29T23:00:00.000Z"]],
-            ["19991230", ["1999-12-29T23:00:00.000Z"]],
-            ["19993012", [undefined]],
-            ["2016-200", ["2016-07-17T22:00:00.000Z"]],
-            ["2016200", ["2016-07-17T22:00:00.000Z"]], // day 200 of year 2016
-            ["2020-", [undefined]],
-            ["2020-W2", [undefined]],
-            ["2020W23", ["2020-05-31T22:00:00.000Z"]],
-            ["2020-W02", ["2020-01-05T23:00:00.000Z"]],
-            ["2020-W32", ["2020-08-02T22:00:00.000Z"]],
-            ["2020-W32-3", ["2020-08-04T22:00:00.000Z"]],
-            ["2016-W21-3", ["2016-05-24T22:00:00.000Z"]],
-            ["2016W213", ["2016-05-24T22:00:00.000Z"]],
-            ["2209", ["2020-09-21T22:00:00.000Z"]],
-            ["22:09", ["2020-09-21T22:00:00.000Z"]], // FIXME ? Is this weird ?
-            ["2012", ["2020-12-19T23:00:00.000Z"]],
+                ["01121934", ["1934-12-01T00:00:00.000Z"]],
+                ["011234", ["2034-11-30T23:00:00.000Z"]],
+                ["011260", ["2060-11-30T23:00:00.000Z"]],
+                ["2", ["2020-07-01T22:00:00.000Z"]],
+                ["02", ["2020-07-01T22:00:00.000Z"]],
+                ["20", ["2020-07-19T22:00:00.000Z"]],
+                ["202", ["2020-02-19T23:00:00.000Z"]],
+                ["2002", ["2020-02-19T23:00:00.000Z"]],
+                ["0202", ["2020-02-01T23:00:00.000Z"]],
+                ["02/02", ["2020-02-01T23:00:00.000Z"]],
+                ["02/13", [undefined]],
+                ["02/1313", [undefined]],
+                ["09990101", [undefined]],
+                ["19990101", ["1998-12-31T23:00:00.000Z"]],
+                ["19990130", ["1999-01-29T23:00:00.000Z"]],
+                ["19991230", ["1999-12-29T23:00:00.000Z"]],
+                ["19993012", [undefined]],
+                ["2016-200", ["2016-07-17T22:00:00.000Z"]],
+                ["2016200", ["2016-07-17T22:00:00.000Z"]], // day 200 of year 2016
+                ["2020-", [undefined]],
+                ["2020-W2", [undefined]],
+                ["2020W23", ["2020-05-31T22:00:00.000Z"]],
+                ["2020-W02", ["2020-01-05T23:00:00.000Z"]],
+                ["2020-W32", ["2020-08-02T22:00:00.000Z"]],
+                ["2020-W32-3", ["2020-08-04T22:00:00.000Z"]],
+                ["2016-W21-3", ["2016-05-24T22:00:00.000Z"]],
+                ["2016W213", ["2016-05-24T22:00:00.000Z"]],
+                ["2209", ["2020-09-21T22:00:00.000Z"]],
+                ["22:09", ["2020-09-21T22:00:00.000Z"]], // FIXME ? Is this weird ?
+                ["2012", ["2020-12-19T23:00:00.000Z"]],
 
-            ["2016-01-03 09:24:15.123", ["2016-01-03T08:24:15.123Z"]],
-            ["2016-01-03T09:24:15.123", ["2016-01-03T08:24:15.123Z"]],
-            [
-                "2016-01-03T09:24:15.123+06:00",
-                ["2016-01-03T03:24:15.123Z", "2016-01-03T02:24:15.123Z"],
-            ],
-            [
-                "2016-01-03T09:24:15.123+16:00",
-                ["2016-01-02T17:24:15.123Z", "2016-01-02T16:24:15.123Z"],
-            ],
-            ["2016-01-03T09:24:15.123Z", ["2016-01-03T09:24:15.123Z", "2016-01-03T08:24:15.123Z"]],
-            ["2016-W21-3T09:24:15.123", ["2016-05-25T07:24:15.123Z"]],
-            ["2016-W21-3 09:24:15.123", [undefined, "2016-05-25T07:24:15.123Z"]],
+                ["2016-01-03 09:24:15.123", ["2016-01-03T08:24:15.123Z"]],
+                ["2016-01-03T09:24:15.123", ["2016-01-03T08:24:15.123Z"]],
+                [
+                    "2016-01-03T09:24:15.123+06:00",
+                    ["2016-01-03T03:24:15.123Z", "2016-01-03T02:24:15.123Z"],
+                ],
+                [
+                    "2016-01-03T09:24:15.123+16:00",
+                    ["2016-01-02T17:24:15.123Z", "2016-01-02T16:24:15.123Z"],
+                ],
+                [
+                    "2016-01-03T09:24:15.123Z",
+                    ["2016-01-03T09:24:15.123Z", "2016-01-03T08:24:15.123Z"],
+                ],
+                ["2016-W21-3T09:24:15.123", ["2016-05-25T07:24:15.123Z"]],
+                ["2016-W21-3 09:24:15.123", [undefined, "2016-05-25T07:24:15.123Z"]],
 
-            [
-                "2016-03-27T02:00:00.000+02:00",
-                ["2016-03-27T00:00:00.000Z", "2016-03-26T23:00:00.000Z"],
-            ],
-            [
-                "2016-03-27T03:00:00.000+02:00",
-                ["2016-03-27T01:00:00.000Z", "2016-03-26T23:00:00.000Z"],
-            ],
-            ["2016-03-27T02:00:00.000", ["2016-03-27T01:00:00.000Z", "2016-03-27T00:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000", ["2016-03-27T01:00:00.000Z"]],
-            ["2016-03-27T02:00:00.000Z", ["2016-03-27T02:00:00.000Z", "2016-03-27T00:00:00.000Z"]],
-            ["2016-03-27T03:00:00.000Z", ["2016-03-27T03:00:00.000Z", "2016-03-27T01:00:00.000Z"]],
+                [
+                    "2016-03-27T02:00:00.000+02:00",
+                    ["2016-03-27T00:00:00.000Z", "2016-03-26T23:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T03:00:00.000+02:00",
+                    ["2016-03-27T01:00:00.000Z", "2016-03-26T23:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T02:00:00.000",
+                    ["2016-03-27T01:00:00.000Z", "2016-03-27T00:00:00.000Z"],
+                ],
+                ["2016-03-27T03:00:00.000", ["2016-03-27T01:00:00.000Z"]],
+                [
+                    "2016-03-27T02:00:00.000Z",
+                    ["2016-03-27T02:00:00.000Z", "2016-03-27T00:00:00.000Z"],
+                ],
+                [
+                    "2016-03-27T03:00:00.000Z",
+                    ["2016-03-27T03:00:00.000Z", "2016-03-27T01:00:00.000Z"],
+                ],
 
-            ["09:22", [undefined]],
-            ["2013", [undefined]],
-            ["011261", ["1961-11-30T23:00:00.000Z", "2061-11-30T23:00:00.000Z"]],
-        ]);
+                ["09:22", [undefined]],
+                ["2013", [undefined]],
+                ["011261", ["1961-11-30T23:00:00.000Z", "2061-11-30T23:00:00.000Z"]],
+            ]);
 
-        runTestSet(assert, testSet, {
-            newFn: (input) => parseDateTime(input, { timezone: true }).toISO(),
-            legacyFn: (input) =>
-                legacy.field_utils.parse.datetime(input, null, { timezone: true }).toISOString(),
+            runTestSet(assert, testSet, {
+                newFn: (input) => parseDateTime(input, { timezone: true }).toUTC().toISO(),
+                legacyFn: (input) =>
+                    legacy.field_utils.parse
+                        .datetime(input, null, { timezone: true })
+                        .toISOString(),
+            });
         });
-    });
-});
+    }
+);

From a19ad0fe24b37fbd4414fcdde960a8881d1160bf Mon Sep 17 00:00:00 2001
From: "Lucas Perais (lpe)" 
Date: Tue, 7 Sep 2021 06:22:31 +0000
Subject: [PATCH 13/31] [FIX] web: formatter: monetary with human number

HumanNumber now correctly rounds the number according to decimals (bug with value < 1000 eg: 0.02)
It also correctly keeps `decimals` significant figures whatever powers of 10^3 it should keep

The monetary formatter now passes the currency's digits to human number

Part-of: odoo/odoo#73311
---
 addons/web/static/src/fields/formatters.js    | 19 ++++++++---
 .../static/tests/fields/formatters_tests.js   | 34 ++++++++++++++-----
 2 files changed, 40 insertions(+), 13 deletions(-)

diff --git a/addons/web/static/src/fields/formatters.js b/addons/web/static/src/fields/formatters.js
index e8d6395aa4c..e7f98f9b611 100644
--- a/addons/web/static/src/fields/formatters.js
+++ b/addons/web/static/src/fields/formatters.js
@@ -36,7 +36,9 @@ function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) {
  * @private
  * @param {number} number to format
  * @param {Object} [options] Options to format
- * @param {number} [options.decimals=0] maximum number of decimals to use
+ * @param {number} [options.decimals=0] number of decimals to use
+ *    if minDigits > 1 is used and effective on the number then decimals
+ *    will be shrunk to zero, to avoid displaying irrelevant figures ( 0.01 compared to 1000 )
  * @param {number} [options.minDigits=1]
  *    the minimum number of digits to preserve when switching to another
  *    level of thousands (e.g. with a value of '2', 4321 will still be
@@ -44,11 +46,11 @@ function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) {
  * @returns {string}
  */
 function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
-    number = Math.round(number);
     const decimals = options.decimals || 0;
     const minDigits = options.minDigits || 1;
     const d2 = Math.pow(10, decimals);
     const numberMagnitude = +number.toExponential().split("e+")[1];
+    number = Math.round(number * d2) / d2;
     // the case numberMagnitude >= 21 corresponds to a number
     // better expressed in the scientific format.
     if (numberMagnitude >= 21) {
@@ -72,7 +74,10 @@ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
         }
     }
     const { decimalPoint, grouping, thousandsSep } = l10n;
-    const [integerPart, decimalPart] = String(number).split(".");
+
+    // determine if we should keep the decimals (we don't want to display 1,020.02k for 1020020)
+    const decimalsToKeep = number >= 1000 ? 0 : decimals;
+    const [integerPart, decimalPart] = number.toFixed(decimalsToKeep).split(".");
     const int = insertThousandsSep(sign * Number(integerPart), thousandsSep, grouping);
     if (!decimalPart) {
         return int + symbol;
@@ -282,7 +287,13 @@ export function formatMonetary(value, options = {}) {
     const currency = session.currencies[currencyId];
     const digits = (currency && currency.digits) || options.digits;
 
-    const formatted = options.humanReadable ? humanNumber(value) : formatFloat(value, { digits });
+    let formatted;
+    if (options.humanReadable) {
+        formatted = humanNumber(value, { decimals: digits ? digits[1] : 2 });
+    } else {
+        formatted = formatFloat(value, { digits });
+    }
+
     if (!currency || options.noSymbol) {
         return formatted;
     }
diff --git a/addons/web/static/tests/fields/formatters_tests.js b/addons/web/static/tests/fields/formatters_tests.js
index 86bd0391164..152052b9453 100644
--- a/addons/web/static/tests/fields/formatters_tests.js
+++ b/addons/web/static/tests/fields/formatters_tests.js
@@ -61,7 +61,7 @@ QUnit.module("Fields", (hooks) => {
         );
         assert.strictEqual(
             formatFloat(10200000, { humanReadable: true, decimals: 2, minDigits: 2 }),
-            "10.2M"
+            "10.20M"
         );
         assert.strictEqual(
             formatFloat(1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
@@ -69,15 +69,15 @@ QUnit.module("Fields", (hooks) => {
         );
         assert.strictEqual(
             formatFloat(1002, { humanReadable: true, decimals: 2, minDigits: 1 }),
-            "1k"
+            "1.00k"
         );
         assert.strictEqual(
             formatFloat(101, { humanReadable: true, decimals: 2, minDigits: 1 }),
-            "101"
+            "101.00"
         );
         assert.strictEqual(
             formatFloat(64.2, { humanReadable: true, decimals: 2, minDigits: 1 }),
-            "64"
+            "64.20"
         );
         assert.strictEqual(formatFloat(1e18, { humanReadable: true }), "1E");
         assert.strictEqual(
@@ -110,7 +110,7 @@ QUnit.module("Fields", (hooks) => {
         );
         assert.strictEqual(
             formatFloat(-10200000, { humanReadable: true, decimals: 2, minDigits: 2 }),
-            "-10.2M"
+            "-10.20M"
         );
         assert.strictEqual(
             formatFloat(-1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
@@ -118,15 +118,15 @@ QUnit.module("Fields", (hooks) => {
         );
         assert.strictEqual(
             formatFloat(-1002, { humanReadable: true, decimals: 2, minDigits: 1 }),
-            "-1k"
+            "-1.00k"
         );
         assert.strictEqual(
             formatFloat(-101, { humanReadable: true, decimals: 2, minDigits: 1 }),
-            "-101"
+            "-101.00"
         );
         assert.strictEqual(
             formatFloat(-64.2, { humanReadable: true, decimals: 2, minDigits: 1 }),
-            "-64"
+            "-64.20"
         );
         assert.strictEqual(formatFloat(-1e18, { humanReadable: true }), "-1E");
         assert.strictEqual(
@@ -235,9 +235,14 @@ QUnit.module("Fields", (hooks) => {
             formatMonetary(1234567.654, { currencyId: 10, noSymbol: true }),
             "1,234,567.65"
         );
+        assert.deepEqual(formatMonetary(8.0, { currencyId: 10, humanReadable: true }), "8.00 €");
         assert.deepEqual(
             formatMonetary(1234567.654, { currencyId: 10, humanReadable: true }),
-            "1M €"
+            "1.23M €"
+        );
+        assert.deepEqual(
+            formatMonetary(1990000.001, { currencyId: 10, humanReadable: true }),
+            "1.99M €"
         );
         assert.deepEqual(
             formatMonetary(1234567.654, { currencyId: 44, digits: [69, 1] }),
@@ -269,6 +274,17 @@ QUnit.module("Fields", (hooks) => {
         assert.strictEqual(formatMonetary(200, { field: floatField, data }), "$ 200.00");
     });
 
+    QUnit.test("formatMonetary without currency", function (assert) {
+        patchWithCleanup(session, {
+            currencies: {},
+        });
+        assert.deepEqual(
+            formatMonetary(1234567.654, { currencyId: 10, humanReadable: true }),
+            "1.23M"
+        );
+        assert.deepEqual(formatMonetary(1234567.654, { currencyId: 10 }), "1,234,567.65");
+    });
+
     QUnit.test("formatPercentage", function (assert) {
         assert.strictEqual(formatPercentage(false), "0%");
         assert.strictEqual(formatPercentage(0), "0%");

From 42cbcad9c8173c1b8d23b98f2c3fb8515b0c3992 Mon Sep 17 00:00:00 2001
From: Julien Mougenot 
Date: Tue, 7 Sep 2021 06:22:33 +0000
Subject: [PATCH 14/31] [IMP] web: datepicker WOWL

This commit adapts the "old" Owl Datepicker to work with the new Owl
web client. The behaviors have been mostly preserved (i.e. working
with the bootstrap datepicker) and simply adapted to make use of the
new services and registries.

The previous datepicker template and tests have been marked as "legacy".

Part-of: odoo/odoo#73311
---
 .../static/src/core/datepicker/datepicker.js  | 287 ++++++++++++
 .../static/src/core/datepicker/datepicker.xml |  25 ++
 .../src/legacy/js/components/datepicker.js    |   2 +-
 addons/web/static/src/legacy/xml/base.xml     |   2 +-
 .../web/static/tests/core/datepicker_tests.js | 408 ++++++++++++++++++
 .../legacy/components/datepicker_tests.js     |   4 +-
 6 files changed, 724 insertions(+), 4 deletions(-)
 create mode 100644 addons/web/static/src/core/datepicker/datepicker.js
 create mode 100644 addons/web/static/src/core/datepicker/datepicker.xml
 create mode 100644 addons/web/static/tests/core/datepicker_tests.js

diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js
new file mode 100644
index 00000000000..82d96f5c615
--- /dev/null
+++ b/addons/web/static/src/core/datepicker/datepicker.js
@@ -0,0 +1,287 @@
+/** @odoo-module **/
+
+import { localization } from "@web/core/l10n/localization";
+import { registry } from "@web/core/registry";
+import { useAutofocus } from "@web/core/utils/hooks";
+
+const { Component, hooks } = owl;
+const { DateTime } = luxon;
+const { useExternalListener, useRef, useState } = hooks;
+
+const formatters = registry.category("formatters");
+const parsers = registry.category("parsers");
+
+let datePickerId = 0;
+
+/**
+ * @param {DateTime} date
+ * @returns {moment}
+ */
+const luxonDateToMomentDate = (date) => {
+    return window.moment(String(date));
+};
+
+/**
+ * @param {string} format
+ * @returns {string}
+ */
+const luxonFormatToMomentFormat = (format) => {
+    return format.replace(/[dy]/g, (x) => x.toUpperCase());
+};
+
+/**
+ * Date picker
+ *
+ * This component exposes the API of the tempusdominus datepicker library.
+ * As such, its template is a simple input that will open the TD datepicker
+ * when clicked on. The component will also synchronize any user-input value
+ * with the library widget and vice-versa.
+ *
+ * Note that all props given to this component will be passed as arguments to
+ * instantiate the picker widget. Also any luxon date is automatically
+ * stringified since tempusdominus only works with moment objects.
+ * @extends Component
+ */
+export class DatePicker extends Component {
+    setup() {
+        this.inputRef = useRef("input");
+        this.state = useState({ warning: false });
+
+        this.datePickerId = `o_datepicker_${datePickerId++}`;
+
+        this.initFormat();
+        this.setDate(this.props);
+
+        useAutofocus();
+        useExternalListener(window, "scroll", this.onWindowScroll);
+    }
+
+    mounted() {
+        window.$(this.el).on("show.datetimepicker", () => this.inputRef.el.select());
+        window.$(this.el).on("hide.datetimepicker", () => this.onDateChange());
+        window.$(this.el).on("error.datetimepicker", () => false); // Ignores datepicker errors
+
+        this.bootstrapDateTimePicker(this.props);
+        this.updateInput();
+    }
+
+    willUpdateProps(nextProps) {
+        const pickerParams = {};
+        for (const prop in nextProps) {
+            if (this.props[prop] !== nextProps[prop]) {
+                pickerParams[prop] = nextProps[prop];
+                if (prop === "date") {
+                    this.setDate(nextProps);
+                    this.updateInput();
+                }
+            }
+        }
+        this.bootstrapDateTimePicker(pickerParams);
+    }
+
+    willUnmount() {
+        window.$(this.el).off(); // Removes all jQuery events
+
+        this.bootstrapDateTimePicker("destroy");
+    }
+
+    //---------------------------------------------------------------------
+    // Getters
+    //---------------------------------------------------------------------
+
+    get options() {
+        return {
+            // Fallback to default localization format in `core/l10n/dates.js`.
+            format: this.props.format,
+            locale: this.props.locale || this.date.locale,
+            timezone: this.isLocal,
+        };
+    }
+
+    //---------------------------------------------------------------------
+    // Protected
+    //---------------------------------------------------------------------
+
+    /**
+     * Initialises formatting and parsing parameters
+     */
+    initFormat() {
+        this.defaultFormat = localization.dateFormat;
+        this.format = formatters.get("date");
+        this.parse = parsers.get("date");
+        this.isLocal = false;
+    }
+
+    /**
+     * Sets the current date value. If a locale is provided, the given date
+     * will first be set in that locale.
+     * @param {object} params
+     * @param {DateTime} params.date
+     * @param {string} [params.locale]
+     */
+    setDate({ date, locale }) {
+        this.date = locale ? date.setLocale(locale) : date;
+    }
+
+    /**
+     * Updates the input element with the current formatted date value.
+     */
+    updateInput() {
+        try {
+            this.inputRef.el.value = this.format(this.date, this.options);
+        } catch (err) {
+            // Do nothing
+        }
+    }
+
+    //---------------------------------------------------------------------
+    // Bootstrap datepicker
+    //---------------------------------------------------------------------
+
+    /**
+     * Handles bootstrap datetimepicker calls.
+     * @param {string | Object} commandOrParams
+     */
+    bootstrapDateTimePicker(commandOrParams) {
+        if (typeof commandOrParams === "object") {
+            const format = luxonFormatToMomentFormat(this.props.format || this.defaultFormat);
+            const params = { ...commandOrParams, format };
+            for (const prop in params) {
+                if (params[prop] instanceof DateTime) {
+                    const luxonDate = params[prop];
+                    const momentDate = luxonDateToMomentDate(luxonDate);
+                    if (momentDate.locale() !== luxonDate.locale) {
+                        momentDate.locale(params.locale);
+                    }
+                    params[prop] = momentDate;
+                }
+            }
+            commandOrParams = params;
+        }
+        window.$(this.el).datetimepicker(commandOrParams);
+    }
+
+    //---------------------------------------------------------------------
+    // Handlers
+    //---------------------------------------------------------------------
+
+    onInputClick() {
+        this.bootstrapDateTimePicker("toggle");
+    }
+
+    /**
+     * Called either when the input value has changed or when the boostrap
+     * datepicker is closed. The actual "datetime-changed" emitted by the
+     * component is only triggered if the date value has changed.
+     */
+    onDateChange() {
+        try {
+            const date = this.parse(this.inputRef.el.value, this.options);
+            if (!date.equals(this.props.date)) {
+                this.state.warning = date > DateTime.local();
+                this.trigger("datetime-changed", { date });
+            }
+        } catch (err) {
+            // Reset to default (= given) date.
+            this.updateInput();
+        }
+    }
+
+    /**
+     * @param {Event} ev
+     */
+    onWindowScroll(ev) {
+        if (ev.target !== this.inputRef.el) {
+            this.bootstrapDateTimePicker("hide");
+        }
+    }
+}
+
+DatePicker.defaultProps = {
+    calendarWeeks: true,
+    icons: {
+        clear: "fa fa-delete",
+        close: "fa fa-check primary",
+        date: "fa fa-calendar",
+        down: "fa fa-chevron-down",
+        next: "fa fa-chevron-right",
+        previous: "fa fa-chevron-left",
+        time: "fa fa-clock-o",
+        today: "fa fa-calendar-check-o",
+        up: "fa fa-chevron-up",
+    },
+    maxDate: DateTime.fromObject({ year: 9999, month: 12, day: 31 }),
+    minDate: DateTime.fromObject({ year: 1000 }),
+    useCurrent: false,
+    widgetParent: "body",
+};
+DatePicker.props = {
+    // Components props
+    date: DateTime,
+    warn_future: { type: Boolean, optional: true },
+    // Bootstrap datepicker options
+    buttons: {
+        type: Object,
+        shape: {
+            showClear: Boolean,
+            showClose: Boolean,
+            showToday: Boolean,
+        },
+        optional: true,
+    },
+    calendarWeeks: Boolean,
+    format: { type: String, optional: true },
+    icons: {
+        type: Object,
+        shape: {
+            clear: String,
+            close: String,
+            date: String,
+            down: String,
+            next: String,
+            previous: String,
+            time: String,
+            today: String,
+            up: String,
+        },
+    },
+    keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true },
+    locale: { type: String, optional: true },
+    maxDate: DateTime,
+    minDate: DateTime,
+    readonly: { type: Boolean, optional: true },
+    useCurrent: Boolean,
+    widgetParent: String,
+};
+DatePicker.template = "web.DatePicker";
+
+/**
+ * Date/time picker
+ *
+ * Similar to the DatePicker component, adding the handling of more specific
+ * time values: hour-minute-second.
+ *
+ * Once again, refer to the tempusdominus documentation for implementation
+ * details.
+ * @extends DatePicker
+ */
+export class DateTimePicker extends DatePicker {
+    /**
+     * @override
+     */
+    initFormat() {
+        this.defaultFormat = localization.dateTimeFormat;
+        this.format = formatters.get("datetime");
+        this.parse = parsers.get("datetime");
+        this.isLocal = true;
+    }
+}
+
+DateTimePicker.defaultProps = {
+    ...DatePicker.defaultProps,
+    buttons: {
+        showClear: false,
+        showClose: true,
+        showToday: false,
+    },
+};
diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml
new file mode 100644
index 00000000000..b72e3a216b0
--- /dev/null
+++ b/addons/web/static/src/core/datepicker/datepicker.xml
@@ -0,0 +1,25 @@
+
+
+
+
+    
+        
+ + + This date is on the future. Make sure it is what you expected. + + +
+
+ +
diff --git a/addons/web/static/src/legacy/js/components/datepicker.js b/addons/web/static/src/legacy/js/components/datepicker.js index 94ce7ece4fe..2c27a55d5f4 100644 --- a/addons/web/static/src/legacy/js/components/datepicker.js +++ b/addons/web/static/src/legacy/js/components/datepicker.js @@ -221,7 +221,7 @@ odoo.define('web.DatePickerOwl', function (require) { useCurrent: Boolean, widgetParent: String, }; - DatePicker.template = 'web.DatePicker'; + DatePicker.template = "web.Legacy.DatePicker"; /** * Date/time picker diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index ed071fc1583..aa1e34787d0 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -125,7 +125,7 @@ - +
any }} [params={}] + * @returns {Promise} + */ +const mountPicker = async (Picker, { props, onDateChange } = {}) => { + serviceRegistry + .add( + "localization", + makeFakeLocalizationService({ + dateFormat: "dd/MM/yyyy", + dateTimeFormat: "dd/MM/yyyy HH:mm:ss", + }) + ) + .add("ui", uiService); + + class Parent extends Component {} + Parent.template = xml/* xml */ ` + + `; + + const env = await makeTestEnv(); + const target = getFixture(); + const parent = await mount(Parent, { env, props: { Picker, props, onDateChange }, target }); + registerCleanup(() => parent.destroy()); + return parent; +}; + +const useFRLocale = () => { + if (!window.moment.locales().includes("fr")) { + // Mocks the FR locale if not loaded + const originalLocale = window.moment.locale(); + window.moment.defineLocale("fr", { + months: "janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split( + "_" + ), + monthsShort: "janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split( + "_" + ), + code: "fr", + monthsParseExact: true, + week: { dow: 1, doy: 4 }, + }); + // Moment automatically assigns newly defined locales. + window.moment.locale(originalLocale); + registerCleanup(() => window.moment.updateLocale("fr", null)); + } + return "fr"; +}; + +QUnit.module("Components", () => { + QUnit.module("DatePicker"); + + QUnit.test("basic rendering", async function (assert) { + assert.expect(8); + + const picker = await mountPicker(DatePicker, { + props: { + date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), + }, + }); + + assert.containsOnce(picker, "input.o_input.o_datepicker_input"); + assert.containsOnce(picker, "span.o_datepicker_button"); + assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget"); + + const input = picker.el.querySelector("input.o_input.o_datepicker_input"); + assert.strictEqual(input.value, "09/01/1997", "Value should be the one given"); + assert.strictEqual( + input.dataset.target, + `#${picker.el.id}`, + "DatePicker id should match its input target" + ); + + await click(input); + + assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker"); + assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget .timepicker"); + assert.strictEqual( + document.querySelector(".datepicker .day.active").dataset.day, + "01/09/1997", + "Datepicker should have set the correct day" + ); + }); + + QUnit.test("pick a date", async function (assert) { + assert.expect(5); + + const picker = await mountPicker(DatePicker, { + props: { + date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), + }, + onDateChange: (ev) => { + assert.step("datetime-changed"); + assert.strictEqual( + ev.detail.date.toFormat("dd/MM/yyyy"), + "08/02/1997", + "Event should transmit the correct date" + ); + }, + }); + const input = picker.el.querySelector(".o_datepicker_input"); + + await click(input); + await click(document.querySelector(".datepicker th.next")); // next month + + assert.verifySteps([]); + + await click(document.querySelectorAll(".datepicker table td")[15]); // previous day + + assert.strictEqual(input.value, "08/02/1997"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("pick a date with locale", async function (assert) { + assert.expect(5); + + const picker = await mountPicker(DatePicker, { + props: { + date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), + format: "dd MMM, yyyy", + locale: useFRLocale(), + }, + onDateChange: (ev) => { + assert.step("datetime-changed"); + assert.strictEqual( + ev.detail.date.toFormat("dd/MM/yyyy"), + "01/09/1997", + "Event should transmit the correct date" + ); + }, + }); + const input = picker.el.querySelector(".o_datepicker_input"); + + assert.strictEqual(input.value, "09 janv., 1997"); + + await click(input); + await click(document.querySelector(".datepicker .picker-switch")); // month picker + await click(document.querySelectorAll(".datepicker .month")[8]); // september + await click(document.querySelector(".datepicker .day")); // first day + + assert.strictEqual(input.value, "01 sept., 1997"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("enter a date value", async function (assert) { + assert.expect(5); + + const picker = await mountPicker(DatePicker, { + props: { + date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), + }, + onDateChange: (ev) => { + assert.step("datetime-changed"); + assert.strictEqual( + ev.detail.date.toFormat("dd/MM/yyyy"), + "08/02/1997", + "Event should transmit the correct date" + ); + }, + }); + const input = picker.el.querySelector(".o_datepicker_input"); + + assert.verifySteps([]); + + input.value = "08/02/1997"; + await triggerEvent(picker.el, ".o_datepicker_input", "change"); + + assert.verifySteps(["datetime-changed"]); + + await click(input); + + assert.strictEqual( + document.querySelector(".datepicker .day.active").dataset.day, + "02/08/1997", + "Datepicker should have set the correct day" + ); + }); + + QUnit.test("Date format is correctly set", async function (assert) { + assert.expect(2); + + const picker = await mountPicker(DatePicker, { + props: { + date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), + format: "yyyy/MM/dd", + }, + }); + const input = picker.el.querySelector(".o_datepicker_input"); + + assert.strictEqual(input.value, "1997/01/09"); + + // Forces an update to assert that the registered format is the correct one + await click(input); + + assert.strictEqual(input.value, "1997/01/09"); + }); + + QUnit.module("DateTimePicker"); + + QUnit.test("basic rendering", async function (assert) { + assert.expect(11); + + const picker = await mountPicker(DateTimePicker, { + props: { + date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + }, + }); + + assert.containsOnce(picker, "input.o_input.o_datepicker_input"); + assert.containsOnce(picker, "span.o_datepicker_button"); + assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget"); + + const input = picker.el.querySelector("input.o_input.o_datepicker_input"); + assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given"); + assert.strictEqual( + input.dataset.target, + `#${picker.el.id}`, + "DateTimePicker id should match its input target" + ); + + await click(input); + + assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker"); + assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .timepicker"); + assert.strictEqual( + document.querySelector(".datepicker .day.active").dataset.day, + "01/09/1997", + "Datepicker should have set the correct day" + ); + + assert.strictEqual( + document.querySelector(".timepicker .timepicker-hour").innerText.trim(), + "12", + "Datepicker should have set the correct hour" + ); + assert.strictEqual( + document.querySelector(".timepicker .timepicker-minute").innerText.trim(), + "30", + "Datepicker should have set the correct minute" + ); + assert.strictEqual( + document.querySelector(".timepicker .timepicker-second").innerText.trim(), + "01", + "Datepicker should have set the correct second" + ); + }); + + QUnit.test("pick a date and time", async function (assert) { + assert.expect(5); + + const picker = await mountPicker(DateTimePicker, { + props: { date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss") }, + onDateChange: (ev) => { + assert.step("datetime-changed"); + assert.strictEqual( + ev.detail.date.toFormat("dd/MM/yyyy HH:mm:ss"), + "08/02/1997 15:45:05", + "Event should transmit the correct date" + ); + }, + }); + const input = picker.el.querySelector("input.o_input.o_datepicker_input"); + + await click(input); + await click(document.querySelector(".datepicker th.next")); // February + await click(document.querySelectorAll(".datepicker table td")[15]); // 08 + await click(document.querySelector('a[title="Select Time"]')); + await click(document.querySelector(".timepicker .timepicker-hour")); + await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h + await click(document.querySelector(".timepicker .timepicker-minute")); + await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m + await click(document.querySelector(".timepicker .timepicker-second")); + + assert.verifySteps([]); + + await click(document.querySelectorAll(".timepicker .second")[1]); // 05s + + assert.strictEqual(input.value, "08/02/1997 15:45:05"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("pick a date and time with locale", async function (assert) { + assert.expect(6); + + const picker = await mountPicker(DateTimePicker, { + props: { + date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + format: "dd MMM, yyyy HH:mm:ss", + locale: useFRLocale(), + }, + onDateChange: (ev) => { + assert.step("datetime-changed"); + assert.strictEqual( + ev.detail.date.toFormat("dd/MM/yyyy HH:mm:ss"), + "01/09/1997 15:45:05", + "Event should transmit the correct date" + ); + }, + }); + + const input = picker.el.querySelector("input.o_input.o_datepicker_input"); + + assert.strictEqual(input.value, "09 janv., 1997 12:30:01"); + + await click(input); + + await click(document.querySelector(".datepicker .picker-switch")); // month picker + await click(document.querySelectorAll(".datepicker .month")[8]); // september + await click(document.querySelector(".datepicker .day")); // first day + + await click(document.querySelector('a[title="Select Time"]')); + await click(document.querySelector(".timepicker .timepicker-hour")); + await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h + await click(document.querySelector(".timepicker .timepicker-minute")); + await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m + await click(document.querySelector(".timepicker .timepicker-second")); + + assert.verifySteps([]); + + await click(document.querySelectorAll(".timepicker .second")[1]); // 05s + + assert.strictEqual(input.value, "01 sept., 1997 15:45:05"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("enter a datetime value", async function (assert) { + assert.expect(9); + + const picker = await mountPicker(DateTimePicker, { + props: { date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss") }, + onDateChange: (ev) => { + assert.step("datetime-changed"); + assert.strictEqual( + ev.detail.date.toFormat("dd/MM/yyyy HH:mm:ss"), + "08/02/1997 15:45:05", + "Event should transmit the correct date" + ); + }, + }); + const input = picker.el.querySelector(".o_datepicker_input"); + + assert.verifySteps([]); + + input.value = "08/02/1997 15:45:05"; + await triggerEvent(picker.el, ".o_datepicker_input", "change"); + + assert.verifySteps(["datetime-changed"]); + + await click(input); + + assert.strictEqual(input.value, "08/02/1997 15:45:05"); + assert.strictEqual( + document.querySelector(".datepicker .day.active").dataset.day, + "02/08/1997", + "Datepicker should have set the correct day" + ); + assert.strictEqual( + document.querySelector(".timepicker .timepicker-hour").innerText.trim(), + "15", + "Datepicker should have set the correct hour" + ); + assert.strictEqual( + document.querySelector(".timepicker .timepicker-minute").innerText.trim(), + "45", + "Datepicker should have set the correct minute" + ); + assert.strictEqual( + document.querySelector(".timepicker .timepicker-second").innerText.trim(), + "05", + "Datepicker should have set the correct second" + ); + }); + + QUnit.test("Date time format is correctly set", async function (assert) { + assert.expect(2); + + const picker = await mountPicker(DateTimePicker, { + props: { + date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + format: "HH:mm:ss yyyy/MM/dd", + }, + }); + const input = picker.el.querySelector(".o_datepicker_input"); + + assert.strictEqual(input.value, "12:30:01 1997/01/09"); + + // Forces an update to assert that the registered format is the correct one + await click(input); + + assert.strictEqual(input.value, "12:30:01 1997/01/09"); + }); +}); diff --git a/addons/web/static/tests/legacy/components/datepicker_tests.js b/addons/web/static/tests/legacy/components/datepicker_tests.js index 78908bd911f..b55d66ec3e1 100644 --- a/addons/web/static/tests/legacy/components/datepicker_tests.js +++ b/addons/web/static/tests/legacy/components/datepicker_tests.js @@ -9,7 +9,7 @@ odoo.define('web.datepicker_tests', function (require) { QUnit.module('Components', {}, function () { - QUnit.module('DatePicker'); + QUnit.module('DatePicker (legacy)'); QUnit.test("basic rendering", async function (assert) { assert.expect(8); @@ -164,7 +164,7 @@ odoo.define('web.datepicker_tests', function (require) { testUtils.mock.unpatch(time); }); - QUnit.module('DateTimePicker'); + QUnit.module('DateTimePicker (legacy)'); QUnit.test("basic rendering", async function (assert) { assert.expect(11); From b98637e19c49b2ea7f20130df0c7d233c4b0d73b Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 7 Sep 2021 06:22:35 +0000 Subject: [PATCH 15/31] [REF] board: Move legacy files This commit moves the content of the static board files to a dedicated `legacy` folder to mark the transition with the new Owl environment. Part-of: odoo/odoo#73311 --- addons/board/__manifest__.py | 7 +++---- .../static/src/{ => legacy}/img/layout_1-1-1.png | Bin .../static/src/{ => legacy}/img/layout_1-1.png | Bin .../static/src/{ => legacy}/img/layout_1-2.png | Bin .../board/static/src/{ => legacy}/img/layout_1.png | Bin .../static/src/{ => legacy}/img/layout_2-1.png | Bin .../static/src/{ => legacy}/img/view_todo_arrow.png | Bin .../static/src/{ => legacy}/js/add_to_board_menu.js | 0 .../board/static/src/{ => legacy}/js/board_view.js | 0 .../static/src/{ => legacy}/scss/dashboard.scss | 0 addons/board/static/src/{ => legacy}/xml/board.xml | 4 ++-- 11 files changed, 5 insertions(+), 6 deletions(-) rename addons/board/static/src/{ => legacy}/img/layout_1-1-1.png (100%) rename addons/board/static/src/{ => legacy}/img/layout_1-1.png (100%) rename addons/board/static/src/{ => legacy}/img/layout_1-2.png (100%) rename addons/board/static/src/{ => legacy}/img/layout_1.png (100%) rename addons/board/static/src/{ => legacy}/img/layout_2-1.png (100%) rename addons/board/static/src/{ => legacy}/img/view_todo_arrow.png (100%) rename addons/board/static/src/{ => legacy}/js/add_to_board_menu.js (100%) rename addons/board/static/src/{ => legacy}/js/board_view.js (100%) rename addons/board/static/src/{ => legacy}/scss/dashboard.scss (100%) rename addons/board/static/src/{ => legacy}/xml/board.xml (95%) diff --git a/addons/board/__manifest__.py b/addons/board/__manifest__.py index 8346386c692..c9b8dae28d5 100644 --- a/addons/board/__manifest__.py +++ b/addons/board/__manifest__.py @@ -21,15 +21,14 @@ Allows users to create custom dashboard. 'application': True, 'assets': { 'web.assets_backend': [ - 'board/static/src/scss/dashboard.scss', - 'board/static/src/js/board_view.js', - 'board/static/src/js/add_to_board_menu.js', + 'board/static/src/**/*.scss', + 'board/static/src/**/*.js', ], 'web.qunit_suite_tests': [ 'board/static/tests/**/*', ], 'web.assets_qweb': [ - 'board/static/src/xml/**/*', + 'board/static/src/**/*.xml', ], }, 'license': 'LGPL-3', diff --git a/addons/board/static/src/img/layout_1-1-1.png b/addons/board/static/src/legacy/img/layout_1-1-1.png similarity index 100% rename from addons/board/static/src/img/layout_1-1-1.png rename to addons/board/static/src/legacy/img/layout_1-1-1.png diff --git a/addons/board/static/src/img/layout_1-1.png b/addons/board/static/src/legacy/img/layout_1-1.png similarity index 100% rename from addons/board/static/src/img/layout_1-1.png rename to addons/board/static/src/legacy/img/layout_1-1.png diff --git a/addons/board/static/src/img/layout_1-2.png b/addons/board/static/src/legacy/img/layout_1-2.png similarity index 100% rename from addons/board/static/src/img/layout_1-2.png rename to addons/board/static/src/legacy/img/layout_1-2.png diff --git a/addons/board/static/src/img/layout_1.png b/addons/board/static/src/legacy/img/layout_1.png similarity index 100% rename from addons/board/static/src/img/layout_1.png rename to addons/board/static/src/legacy/img/layout_1.png diff --git a/addons/board/static/src/img/layout_2-1.png b/addons/board/static/src/legacy/img/layout_2-1.png similarity index 100% rename from addons/board/static/src/img/layout_2-1.png rename to addons/board/static/src/legacy/img/layout_2-1.png diff --git a/addons/board/static/src/img/view_todo_arrow.png b/addons/board/static/src/legacy/img/view_todo_arrow.png similarity index 100% rename from addons/board/static/src/img/view_todo_arrow.png rename to addons/board/static/src/legacy/img/view_todo_arrow.png diff --git a/addons/board/static/src/js/add_to_board_menu.js b/addons/board/static/src/legacy/js/add_to_board_menu.js similarity index 100% rename from addons/board/static/src/js/add_to_board_menu.js rename to addons/board/static/src/legacy/js/add_to_board_menu.js diff --git a/addons/board/static/src/js/board_view.js b/addons/board/static/src/legacy/js/board_view.js similarity index 100% rename from addons/board/static/src/js/board_view.js rename to addons/board/static/src/legacy/js/board_view.js diff --git a/addons/board/static/src/scss/dashboard.scss b/addons/board/static/src/legacy/scss/dashboard.scss similarity index 100% rename from addons/board/static/src/scss/dashboard.scss rename to addons/board/static/src/legacy/scss/dashboard.scss diff --git a/addons/board/static/src/xml/board.xml b/addons/board/static/src/legacy/xml/board.xml similarity index 95% rename from addons/board/static/src/xml/board.xml rename to addons/board/static/src/legacy/xml/board.xml index 127c9b3a2f4..306bf590ec0 100644 --- a/addons/board/static/src/xml/board.xml +++ b/addons/board/static/src/legacy/xml/board.xml @@ -8,7 +8,7 @@ @@ -43,7 +43,7 @@

  • - +
From 9b9d3c8c36a4b8274141c97e9863970e7ae6e6d3 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 7 Sep 2021 06:22:37 +0000 Subject: [PATCH 16/31] [REF] google_spreadsheet: Move legacy files This commit moves the content of the static google_spreadsheet files to a dedicated `legacy` folder to mark the transition with the new Owl environment. Part-of: odoo/odoo#73311 --- addons/google_spreadsheet/__manifest__.py | 4 ++-- .../src/{ => legacy}/js/add_to_google_spreadsheet_menu.js | 0 .../static/src/{ => legacy}/xml/addtospreadsheet.xml | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename addons/google_spreadsheet/static/src/{ => legacy}/js/add_to_google_spreadsheet_menu.js (100%) rename addons/google_spreadsheet/static/src/{ => legacy}/xml/addtospreadsheet.xml (100%) diff --git a/addons/google_spreadsheet/__manifest__.py b/addons/google_spreadsheet/__manifest__.py index 4a1540897b5..b15df26d6a8 100644 --- a/addons/google_spreadsheet/__manifest__.py +++ b/addons/google_spreadsheet/__manifest__.py @@ -20,10 +20,10 @@ The module adds the possibility to display data from Odoo in Google Spreadsheets 'auto_install': False, 'assets': { 'web.assets_backend': [ - 'google_spreadsheet/static/src/js/**/*', + 'google_spreadsheet/static/src/**/*.js', ], 'web.assets_qweb': [ - 'google_spreadsheet/static/src/xml/*.xml', + 'google_spreadsheet/static/src/**/*.xml', ], }, 'license': 'LGPL-3', diff --git a/addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js b/addons/google_spreadsheet/static/src/legacy/js/add_to_google_spreadsheet_menu.js similarity index 100% rename from addons/google_spreadsheet/static/src/js/add_to_google_spreadsheet_menu.js rename to addons/google_spreadsheet/static/src/legacy/js/add_to_google_spreadsheet_menu.js diff --git a/addons/google_spreadsheet/static/src/xml/addtospreadsheet.xml b/addons/google_spreadsheet/static/src/legacy/xml/addtospreadsheet.xml similarity index 100% rename from addons/google_spreadsheet/static/src/xml/addtospreadsheet.xml rename to addons/google_spreadsheet/static/src/legacy/xml/addtospreadsheet.xml From add86e4abdc33717b7215359c70834c9771b3a18 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 7 Sep 2021 06:22:39 +0000 Subject: [PATCH 17/31] [REF] base_import: Move legacy files This commit moves the content of the static base_import files to a dedicated `legacy` folder to mark the transition with the new Owl environment. Part-of: odoo/odoo#73311 --- addons/base_import/__manifest__.py | 8 +++----- .../{ => legacy}/img/ui/dropdown_arrow_small.png | Bin .../static/src/{ => legacy}/img/ui/studio_icons.png | Bin .../static/src/{ => legacy}/js/import_action.js | 0 .../static/src/{ => legacy}/js/import_menu.js | 0 .../static/src/{ => legacy}/scss/base_import.scss | 2 +- .../static/src/{ => legacy}/scss/icons.scss | 2 +- .../static/src/{ => legacy}/xml/base_import.xml | 0 8 files changed, 5 insertions(+), 7 deletions(-) rename addons/base_import/static/src/{ => legacy}/img/ui/dropdown_arrow_small.png (100%) rename addons/base_import/static/src/{ => legacy}/img/ui/studio_icons.png (100%) rename addons/base_import/static/src/{ => legacy}/js/import_action.js (100%) rename addons/base_import/static/src/{ => legacy}/js/import_menu.js (100%) rename addons/base_import/static/src/{ => legacy}/scss/base_import.scss (98%) rename addons/base_import/static/src/{ => legacy}/scss/icons.scss (95%) rename addons/base_import/static/src/{ => legacy}/xml/base_import.xml (100%) diff --git a/addons/base_import/__manifest__.py b/addons/base_import/__manifest__.py index aea0774bac5..5a0f33b46ca 100644 --- a/addons/base_import/__manifest__.py +++ b/addons/base_import/__manifest__.py @@ -31,14 +31,12 @@ Re-implement Odoo's file import system: ], 'assets': { 'web.assets_qweb': [ - 'base_import/static/src/xml/**/*', + 'base_import/static/src/**/*.xml', ], 'web.assets_backend': [ - 'base_import/static/src/scss/base_import.scss', - 'base_import/static/src/scss/icons.scss', 'base_import/static/lib/javascript-state-machine/state-machine.js', - 'base_import/static/src/js/import_action.js', - 'base_import/static/src/js/import_menu.js', + 'base_import/static/src/**/*.scss', + 'base_import/static/src/**/*.js', ], 'web.qunit_suite_tests': [ 'base_import/static/tests/**/*', diff --git a/addons/base_import/static/src/img/ui/dropdown_arrow_small.png b/addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png similarity index 100% rename from addons/base_import/static/src/img/ui/dropdown_arrow_small.png rename to addons/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png diff --git a/addons/base_import/static/src/img/ui/studio_icons.png b/addons/base_import/static/src/legacy/img/ui/studio_icons.png similarity index 100% rename from addons/base_import/static/src/img/ui/studio_icons.png rename to addons/base_import/static/src/legacy/img/ui/studio_icons.png diff --git a/addons/base_import/static/src/js/import_action.js b/addons/base_import/static/src/legacy/js/import_action.js similarity index 100% rename from addons/base_import/static/src/js/import_action.js rename to addons/base_import/static/src/legacy/js/import_action.js diff --git a/addons/base_import/static/src/js/import_menu.js b/addons/base_import/static/src/legacy/js/import_menu.js similarity index 100% rename from addons/base_import/static/src/js/import_menu.js rename to addons/base_import/static/src/legacy/js/import_menu.js diff --git a/addons/base_import/static/src/scss/base_import.scss b/addons/base_import/static/src/legacy/scss/base_import.scss similarity index 98% rename from addons/base_import/static/src/scss/base_import.scss rename to addons/base_import/static/src/legacy/scss/base_import.scss index 46369769d07..5fddbbbaab2 100644 --- a/addons/base_import/static/src/scss/base_import.scss +++ b/addons/base_import/static/src/legacy/scss/base_import.scss @@ -186,7 +186,7 @@ padding-right: 24px; border-bottom-right-radius: 4px; border-top-right-radius: 4px; - background-image: url('/base_import/static/src/img/ui/dropdown_arrow_small.png'); + background-image: url('/base_import/static/src/legacy/img/ui/dropdown_arrow_small.png'); background-position: center right; background-repeat: no-repeat; appearance: none; diff --git a/addons/base_import/static/src/scss/icons.scss b/addons/base_import/static/src/legacy/scss/icons.scss similarity index 95% rename from addons/base_import/static/src/scss/icons.scss rename to addons/base_import/static/src/legacy/scss/icons.scss index 09e40c6b3cd..12e23fd7e9f 100644 --- a/addons/base_import/static/src/scss/icons.scss +++ b/addons/base_import/static/src/legacy/scss/icons.scss @@ -33,7 +33,7 @@ border: 1px solid darkgrey; } &:after{ - background-image: url('/base_import/static/src/img/ui/studio_icons.png'); + background-image: url('/base_import/static/src/legacy/img/ui/studio_icons.png'); background-repeat: no-repeat; content: ''; width: 20px; diff --git a/addons/base_import/static/src/xml/base_import.xml b/addons/base_import/static/src/legacy/xml/base_import.xml similarity index 100% rename from addons/base_import/static/src/xml/base_import.xml rename to addons/base_import/static/src/legacy/xml/base_import.xml From aeb8e49972f871b0c9b07c507987ee77cf5e8abd Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Tue, 7 Sep 2021 06:22:41 +0000 Subject: [PATCH 18/31] [IMP] web,*: view infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We refactor the action service and allow the legacy and new views to share a global state within a same action. We introduce two new components View and WithSearch along with the search infrastructure: ControlPanel, FilterMenu,... Part-of: odoo/odoo#73311 Co-authored-by: Aaron Bohy Co-authored-by: Bruno Boi Co-authored-by: Géry Debongnie Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Michaël Mattiello Co-authored-by: Julien Mougenot Co-authored-by: Lucas Perais Co-authored-by: Jorge Pinna Puissant Co-authored-by: Stefano Rigano --- .../src/import_records/import_records.js | 48 + .../src/import_records/import_records.xml | 10 + .../static/src/add_to_board/add_to_board.js | 112 + .../static/src/add_to_board/add_to_board.xml | 16 + .../add_to_google_spreadsheet.js | 51 + .../add_to_google_spreadsheet.xml | 10 + addons/web/__manifest__.py | 8 +- .../web/static/src/legacy/action_adapters.js | 20 +- addons/web/static/src/legacy/backend_utils.js | 265 ++ .../control_panel_model_extension.js | 65 +- addons/web/static/src/legacy/js/model.js | 2 +- .../src/legacy/js/views/search_panel.js | 10 +- .../src/legacy/legacy_client_actions.js | 4 +- .../src/legacy/legacy_service_provider.js | 2 +- addons/web/static/src/legacy/legacy_setup.js | 2 +- addons/web/static/src/legacy/legacy_views.js | 34 +- addons/web/static/src/legacy/utils.js | 56 - addons/web/static/src/legacy/xml/base.xml | 20 - .../static/src/legacy/xml/search_panel.xml | 16 +- .../search/comparison_menu/comparison_menu.js | 33 + .../comparison_menu/comparison_menu.xml | 25 + .../src/search/control_panel/control_panel.js | 53 +- .../search/control_panel/control_panel.xml | 12 +- .../favorite_menu/custom_favorite_item.js | 100 + .../favorite_menu/custom_favorite_item.xml | 32 + .../src/search/favorite_menu/favorite_menu.js | 68 + .../search/favorite_menu/favorite_menu.xml | 41 + .../search/filter_menu/custom_filter_item.js | 317 ++ .../search/filter_menu/custom_filter_item.xml | 112 + .../src/search/filter_menu/filter_menu.js | 39 + .../src/search/filter_menu/filter_menu.xml | 57 + .../group_by_menu/custom_group_by_item.js | 19 + .../group_by_menu/custom_group_by_item.xml | 24 + .../src/search/group_by_menu/group_by_menu.js | 72 + .../search/group_by_menu/group_by_menu.xml | 58 + .../static/src/search/search_arch_parser.js | 353 ++ .../src/search/search_bar/search_bar.js | 459 +++ .../src/search/search_bar/search_bar.xml | 98 + addons/web/static/src/search/search_model.js | 2086 ++++++++++ .../src/search/search_panel/search_panel.js | 206 + .../search_panel}/search_panel.scss | 2 + .../src/search/search_panel/search_panel.xml | 132 + .../search_panel}/search_view.scss | 0 .../search_panel}/search_view_extra.scss | 0 addons/web/static/src/search/utils/dates.js | 429 +++ .../web/static/src/search/utils/group_by.js | 62 + addons/web/static/src/search/utils/misc.js | 18 + .../src/search/with_search/with_search.js | 144 + .../src/search/with_search/with_search.xml | 8 + addons/web/static/src/views/helpers/model.js | 77 + .../src/views/helpers/no_content_helpers.xml | 28 + .../src/views/helpers/standard_view_props.js | 33 + .../web/static/src/views/helpers/view_hook.js | 12 + addons/web/static/src/views/view.js | 331 ++ addons/web/static/src/views/view.xml | 8 + .../src/webclient/actions/action_hook.js | 92 +- .../src/webclient/actions/action_service.js | 140 +- .../web/static/tests/helpers/mock_server.js | 1471 ++++---- .../control_panel/comparison_menu_tests.js | 2 +- .../control_panel/custom_filter_item_tests.js | 2 +- .../custom_group_by_item_tests.js | 2 +- .../control_panel/favorite_menu_tests.js | 2 +- .../legacy/control_panel/filter_menu_tests.js | 2 +- .../control_panel/groupby_menu_tests.js | 2 +- .../tests/legacy/views/search_panel_tests.js | 5 +- .../tests/legacy/views/state_mapping_tests.js | 352 ++ .../tests/search/comparison_menu_tests.js | 115 + .../web/static/tests/search/control_panel.js | 187 +- .../search/custom_favorite_item_tests.js | 462 +++ .../tests/search/custom_filter_item_tests.js | 685 ++++ .../search/custom_group_by_item_tests.js | 200 + .../tests/search/favorite_menu_tests.js | 341 ++ .../static/tests/search/filter_menu_tests.js | 549 +++ .../tests/search/group_by_menu_tests.js | 452 +++ .../web/static/tests/search/group_by_tests.js | 99 + addons/web/static/tests/search/helpers.js | 275 ++ .../static/tests/search/search_bar_tests.js | 700 ++++ .../static/tests/search/search_model_tests.js | 780 ++++ .../static/tests/search/search_panel_tests.js | 3353 +++++++++++++++++ .../static/tests/search/search_utils_tests.js | 490 +++ .../static/tests/search/with_search_tests.js | 335 ++ addons/web/static/tests/views/helpers.js | 84 + addons/web/static/tests/views/view_tests.js | 1109 ++++++ .../webclient/actions/concurrency_tests.js | 86 +- .../webclient/actions/window_action_tests.js | 49 +- addons/web/static/tests/webclient/helpers.js | 4 +- 86 files changed, 17684 insertions(+), 1042 deletions(-) create mode 100644 addons/base_import/static/src/import_records/import_records.js create mode 100644 addons/base_import/static/src/import_records/import_records.xml create mode 100644 addons/board/static/src/add_to_board/add_to_board.js create mode 100644 addons/board/static/src/add_to_board/add_to_board.xml create mode 100644 addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js create mode 100644 addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml create mode 100644 addons/web/static/src/legacy/backend_utils.js create mode 100644 addons/web/static/src/search/comparison_menu/comparison_menu.js create mode 100644 addons/web/static/src/search/comparison_menu/comparison_menu.xml create mode 100644 addons/web/static/src/search/favorite_menu/custom_favorite_item.js create mode 100644 addons/web/static/src/search/favorite_menu/custom_favorite_item.xml create mode 100644 addons/web/static/src/search/favorite_menu/favorite_menu.js create mode 100644 addons/web/static/src/search/favorite_menu/favorite_menu.xml create mode 100644 addons/web/static/src/search/filter_menu/custom_filter_item.js create mode 100644 addons/web/static/src/search/filter_menu/custom_filter_item.xml create mode 100644 addons/web/static/src/search/filter_menu/filter_menu.js create mode 100644 addons/web/static/src/search/filter_menu/filter_menu.xml create mode 100644 addons/web/static/src/search/group_by_menu/custom_group_by_item.js create mode 100644 addons/web/static/src/search/group_by_menu/custom_group_by_item.xml create mode 100644 addons/web/static/src/search/group_by_menu/group_by_menu.js create mode 100644 addons/web/static/src/search/group_by_menu/group_by_menu.xml create mode 100644 addons/web/static/src/search/search_arch_parser.js create mode 100644 addons/web/static/src/search/search_bar/search_bar.js create mode 100644 addons/web/static/src/search/search_bar/search_bar.xml create mode 100644 addons/web/static/src/search/search_model.js create mode 100644 addons/web/static/src/search/search_panel/search_panel.js rename addons/web/static/src/{legacy/scss => search/search_panel}/search_panel.scss (98%) create mode 100644 addons/web/static/src/search/search_panel/search_panel.xml rename addons/web/static/src/{legacy/scss => search/search_panel}/search_view.scss (100%) rename addons/web/static/src/{legacy/scss => search/search_panel}/search_view_extra.scss (100%) create mode 100644 addons/web/static/src/search/utils/dates.js create mode 100644 addons/web/static/src/search/utils/group_by.js create mode 100644 addons/web/static/src/search/utils/misc.js create mode 100644 addons/web/static/src/search/with_search/with_search.js create mode 100644 addons/web/static/src/search/with_search/with_search.xml create mode 100644 addons/web/static/src/views/helpers/model.js create mode 100644 addons/web/static/src/views/helpers/no_content_helpers.xml create mode 100644 addons/web/static/src/views/helpers/standard_view_props.js create mode 100644 addons/web/static/src/views/helpers/view_hook.js create mode 100644 addons/web/static/src/views/view.js create mode 100644 addons/web/static/src/views/view.xml create mode 100644 addons/web/static/tests/legacy/views/state_mapping_tests.js create mode 100644 addons/web/static/tests/search/comparison_menu_tests.js create mode 100644 addons/web/static/tests/search/custom_favorite_item_tests.js create mode 100644 addons/web/static/tests/search/custom_filter_item_tests.js create mode 100644 addons/web/static/tests/search/custom_group_by_item_tests.js create mode 100644 addons/web/static/tests/search/favorite_menu_tests.js create mode 100644 addons/web/static/tests/search/filter_menu_tests.js create mode 100644 addons/web/static/tests/search/group_by_menu_tests.js create mode 100644 addons/web/static/tests/search/group_by_tests.js create mode 100644 addons/web/static/tests/search/helpers.js create mode 100644 addons/web/static/tests/search/search_bar_tests.js create mode 100644 addons/web/static/tests/search/search_model_tests.js create mode 100644 addons/web/static/tests/search/search_panel_tests.js create mode 100644 addons/web/static/tests/search/search_utils_tests.js create mode 100644 addons/web/static/tests/search/with_search_tests.js create mode 100644 addons/web/static/tests/views/helpers.js create mode 100644 addons/web/static/tests/views/view_tests.js diff --git a/addons/base_import/static/src/import_records/import_records.js b/addons/base_import/static/src/import_records/import_records.js new file mode 100644 index 00000000000..7ffa17660fe --- /dev/null +++ b/addons/base_import/static/src/import_records/import_records.js @@ -0,0 +1,48 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +const { Component } = owl; +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +/** + * 'Import records' menu + * + * This component is used to import the records for particular model. + * @extends Component + */ +export class ImportRecords extends Component { + setup() { + this.action = useService("action"); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + importRecords() { + const { context, resModel } = this.env.searchModel; + this.action.doAction({ + type: "ir.actions.client", + tag: "import", + params: { model: resModel, context } + }); + } +} + +ImportRecords.template = "base_import.ImportRecords"; + +const importRecordsItem = { + Component: ImportRecords, + groupNumber: 4, + isDisplayed: ({ isSmall, searchModel }) => + !isSmall && + searchModel.action.type === "ir.actions.act_window" && + ["kanban", "list"].includes(searchModel.view.type) + // TODO: add arch info to searchModel? + // !!JSON.parse(env.view.arch.attrs.import || "1") && + // !!JSON.parse(env.view.arch.attrs.create || "1"), +}; + +favoriteMenuRegistry.add("import-menu", importRecordsItem, { sequence: 1 }); diff --git a/addons/base_import/static/src/import_records/import_records.xml b/addons/base_import/static/src/import_records/import_records.xml new file mode 100644 index 00000000000..a16a5f9f2f4 --- /dev/null +++ b/addons/base_import/static/src/import_records/import_records.xml @@ -0,0 +1,10 @@ + + + + + + Import records + + + + diff --git a/addons/board/static/src/add_to_board/add_to_board.js b/addons/board/static/src/add_to_board/add_to_board.js new file mode 100644 index 00000000000..69c28cc4eda --- /dev/null +++ b/addons/board/static/src/add_to_board/add_to_board.js @@ -0,0 +1,112 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useAutofocus, useService } from "@web/core/utils/hooks"; +import { sprintf } from "@web/core/utils/strings"; + +const { Component, useState } = owl; +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +/** + * 'Add to board' menu + * + * Component consisiting of a toggle button, a text input and an 'Add' button. + * The first button is simply used to toggle the component and will determine + * whether the other elements should be rendered. + * The input will be given the name (or title) of the view that will be added. + * Finally, the last button will send the name as well as some of the action + * properties to the server to add the current view (and its context) to the + * user's dashboard. + * This component is only available in actions of type 'ir.actions.act_window'. + * @extends Component + */ +export class AddToBoard extends Component { + setup() { + this.notification = useService("notification"); + this.rpc = useService("rpc"); + this.state = useState({ name: this.env.searchModel.displayName }); + + useAutofocus(); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + async addToBoard() { + const { + action, + displayName, + domain, + comparison, + context, + groupBy, + orderedBy, + view + } = this.env.searchModel; + + // Retrieves view context + const fns = this.env.__saveParams__.callbacks; + const { context: viewContext } = Object.assign({}, ...fns.map((fn) => fn())); + + const contextToSave = { + ...context, + group_by: groupBy, + orderedBy, + dashboard_merge_domains_contexts: false, + ...viewContext, + }; + + if (comparison) { + contextToSave.comparison = comparison; + } + + const result = await this.rpc("/board/add_to_dashboard", { + action_id: action.id, + context_to_save: contextToSave, + domain, + name: this.state.name, + view_mode: view.type, + }); + + if (result) { + this.notification.add( + this.env._t("Please refresh your browser for the changes to take effect."), + { + title: sprintf(this.env._t(`"%s" added to dashboard`), this.state.name), + type: "warning", + } + ); + this.state.name = displayName; + } else { + this.notification.add( + this.env._t("Could not add filter to dashboard"), + { type: "danger" } + ); + } + } + + //--------------------------------------------------------------------- + // Handlers + //--------------------------------------------------------------------- + + /** + * @param {KeyboardEvent} ev + */ + onInputKeydown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.addToBoard(); + } + } +} + +AddToBoard.template = "board.AddToBoard"; + +const addToBoardItem = { + Component: AddToBoard, + groupNumber: 4, + isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window", +}; + +favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 }); diff --git a/addons/board/static/src/add_to_board/add_to_board.xml b/addons/board/static/src/add_to_board/add_to_board.xml new file mode 100644 index 00000000000..6419bd154af --- /dev/null +++ b/addons/board/static/src/add_to_board/add_to_board.xml @@ -0,0 +1,16 @@ + + + + + + Add to my dashboard +
  • + +
  • +
  • + +
  • +
    +
    + +
    diff --git a/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js new file mode 100644 index 00000000000..49c0e988001 --- /dev/null +++ b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +const { Component } = owl; +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +/** + * 'Add to Google spreadsheet' menu item + * + * Component consisting only of a button calling the server to add the current + * view to the user's spreadsheet configuration. + * This component is only available in actions of type 'ir.actions.act_window'. + * @extends Component + */ +export class AddToGoogleSpreadsheet extends Component { + setup() { + this.orm = useService("orm"); + } + + //--------------------------------------------------------------------- + // Protected + //--------------------------------------------------------------------- + + async addToGoogleSpreadsheet() { + const { domain, groupBy, resModel, view } = this.env.searchModel; + + const result = await this.orm.call( + "google.drive.config", + "set_spreadsheet", + [resModel, domain, groupBy, view.id] + ); + + if (result.url) { + // According to MDN doc, one should not use _blank as title. + // todo: find a good name for the new window + window.open(result.url, "_blank"); + } + } +} + +AddToGoogleSpreadsheet.template = "google_spreadsheet.AddToGoogleSpreadsheet"; + +const addToGoogleSpreadsheetItem = { + Component: AddToGoogleSpreadsheet, + groupNumber: 4, + isDisplayed: ({ searchModel }) => searchModel.action.type === "ir.actions.act_window", +}; + +favoriteMenuRegistry.add("add-to-google-spreadsheet", addToGoogleSpreadsheetItem, { sequence: 20 }); diff --git a/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml new file mode 100644 index 00000000000..b5ce85aa399 --- /dev/null +++ b/addons/google_spreadsheet/static/src/add_to_google_spreadsheet/add_to_google_spreadsheet.xml @@ -0,0 +1,10 @@ + + + + + + Add to Google Spreadsheet + + + + diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 63b0a7d7f47..0133195d3cc 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -48,9 +48,9 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/xml/kanban.xml', 'web/static/src/legacy/xml/pivot.xml', 'web/static/src/legacy/xml/report.xml', - 'web/static/src/legacy/xml/search_panel.xml', 'web/static/src/legacy/xml/web_calendar.xml', 'web/static/src/legacy/xml/graph.xml', + 'web/static/src/legacy/xml/search_panel.xml', 'web/static/src/legacy/xml/week_days.xml', ], 'web.assets_common_minimal': [ @@ -123,8 +123,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/scss/kanban_column_progressbar.scss', 'web/static/src/legacy/scss/kanban_view.scss', 'web/static/src/legacy/scss/web_calendar.scss', - 'web/static/src/legacy/scss/search_view.scss', - 'web/static/src/legacy/scss/search_panel.scss', 'web/static/src/legacy/scss/data_export.scss', 'base/static/src/scss/onboarding.scss', 'web/static/src/legacy/scss/attachment_preview.scss', @@ -137,7 +135,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/scss/fields_extra.scss', 'web/static/src/legacy/scss/form_view_extra.scss', 'web/static/src/legacy/scss/list_view_extra.scss', - 'web/static/src/legacy/scss/search_view_extra.scss', 'web/static/src/legacy/action_adapters.js', 'web/static/src/legacy/debug_manager.js', @@ -150,6 +147,7 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/root_widget.js', 'web/static/src/legacy/systray_menu.js', 'web/static/src/legacy/systray_menu_item.js', + 'web/static/src/legacy/backend_utils.js', 'web/static/src/legacy/utils.js', 'web/static/src/legacy/web_client.js', 'web/static/src/legacy/js/_deprecated/*', @@ -460,6 +458,7 @@ This module provides the core of the Odoo Web Client. # 'web/static/tests/legacy/main_tests.js', 'web/static/tests/helpers/**/*.js', + 'web/static/tests/search/helpers.js', 'web/static/tests/webclient/**/helpers.js', 'web/static/tests/qunit.js', 'web/static/tests/main.js', @@ -481,6 +480,7 @@ This module provides the core of the Odoo Web Client. 'web/static/tests/core/**/*.js', 'web/static/tests/fields/**/*.js', 'web/static/tests/search/**/*.js', + ('remove', 'web/static/tests/search/helpers.js'), 'web/static/tests/views/**/*.js', 'web/static/tests/webclient/**/*.js', ('remove', 'web/static/tests/webclient/**/helpers.js'), diff --git a/addons/web/static/src/legacy/action_adapters.js b/addons/web/static/src/legacy/action_adapters.js index a414c6482ac..2a6fb3dff59 100644 --- a/addons/web/static/src/legacy/action_adapters.js +++ b/addons/web/static/src/legacy/action_adapters.js @@ -8,7 +8,8 @@ import { useDebugCategory } from "../core/debug/debug_context"; import { Dialog } from "../core/dialog/dialog"; import { useEffect, useService } from "@web/core/utils/hooks"; import { ViewNotFoundError } from "../webclient/actions/action_service"; -import { cleanDomFromBootstrap, wrapSuccessOrFail, mapDoActionOptionAPI } from "./utils"; +import { cleanDomFromBootstrap, wrapSuccessOrFail } from "./utils"; +import { mapDoActionOptionAPI } from "./backend_utils"; const { Component, tags, hooks } = owl; @@ -335,15 +336,16 @@ export class ViewAdapter extends ActionAdapter { async _trigger_up(ev) { const payload = ev.data; if (ev.name === "switch_view") { - const state = ev.target.exportState(); try { - await this.actionService.switchView(payload.view_type, { - resId: payload.res_id, - resIds: state.resIds, - searchModel: state.searchModel, - searchPanel: state.searchPanel, - mode: payload.mode, - }); + const props = {}; + if (payload.mode) { + props.mode = payload.mode; + } + // if (payload.res_id) { + // if make 'open a record, come back, and create a new record' crash + props.resId = payload.res_id; + // } + await this.actionService.switchView(payload.view_type, props); } catch (e) { if (e instanceof ViewNotFoundError) { return; diff --git a/addons/web/static/src/legacy/backend_utils.js b/addons/web/static/src/legacy/backend_utils.js new file mode 100644 index 00000000000..d1c014158cf --- /dev/null +++ b/addons/web/static/src/legacy/backend_utils.js @@ -0,0 +1,265 @@ +/** @odoo-module **/ + +import ActionModel from "@web/legacy/js/views/action_model"; + +/** + * @param {string} state + * @returns {string} + */ +function searchModelStateFromLegacy(state) { + /** + * Possible problem if only one of ControlPanelModelExtension or SearchPanelModelExtension is installed. + * We might need to do something in SearchModel. + * @todo (DAM) check when search panel is reworked. + */ + const parsedState = JSON.parse(state); + const newState = {}; + + if (parsedState.ControlPanelModelExtension) { + const { + query, + filters, + nextGroupId, + nextGroupNumber, + nextId, + } = parsedState.ControlPanelModelExtension; + + newState.nextGroupId = nextGroupId; + newState.nextGroupNumber = nextGroupNumber; + newState.nextId = nextId; + newState.query = []; + newState.searchItems = {}; + + for (const queryElem of query) { + const filterId = queryElem.filterId; + const filter = filters[filterId]; + const newQueryElem = { searchItemId: filterId }; + switch (filter.type) { + case "filter": + if (filter.hasOptions) { + newQueryElem.generatorId = queryElem.optionId; + } + break; + case "groupBy": + if (filter.hasOptions) { + newQueryElem.intervalId = queryElem.optionId; + } + break; + case "field": + newQueryElem.autocompleteValue = { + value: queryElem.value, + label: queryElem.label, + operator: queryElem.operator, + }; + break; + } + newState.query.push(newQueryElem); + } + + for (const filter of Object.values(filters)) { + const item = Object.assign({}, filter); + switch (item.type) { + case "groupBy": + if (filter.hasOptions) { + item.type = "dateGroupBy"; + item.defaultIntervalId = item.defaultOptionId; + delete item.hasOptions; + delete item.defaultOptionId; + } + break; + case "filter": + if (filter.hasOptions) { + item.type = "dateFilter"; + item.defaultGeneratorId = item.defaultOptionId; + delete item.hasOptions; + delete item.isDateFilter; + delete item.defaultOptionId; + } + break; + case "favorite": + item.orderBy = item.orderedBy; + delete item.orderedBy; + break; + } + newState.searchItems[filter.id] = item; + } + } + + if (parsedState.SearchPanelModelExtension) { + const { sections, searchPanelInfo } = parsedState.SearchPanelModelExtension; + newState.sections = sections; + //! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway + newState.searchPanelInfo = searchPanelInfo; + } + + for (const [key, extension] of Object.entries(ActionModel.registry.entries())) { + if ( + !["ControlPanel", "SearchPanel"].includes(key) && + parsedState[extension.name] !== undefined + ) { + newState[key] = parsedState[extension.name]; + } + } + + if (!Object.keys(newState).length) { + return; + } + + return JSON.stringify(newState); +} + +/** + * @param {string} state + * @returns {string} + */ +export function searchModelStateToLegacy(state) { + if (!state) { + return; + } + + const parsedState = JSON.parse(state); + const query = []; + for (const queryElem of parsedState.query) { + const searchItemId = queryElem.searchItemId; + const searchItem = parsedState.searchItems[searchItemId]; + const groupId = searchItem.groupId; + const legacyQueryElem = { filterId: searchItemId, groupId }; + switch (searchItem.type) { + case "dateFilter": + legacyQueryElem.optionId = queryElem.generatorId; + break; + case "dateGroupBy": + legacyQueryElem.optionId = queryElem.intervalId; + break; + case "comparison": + legacyQueryElem.type = "comparison"; + legacyQueryElem.dateFilterId = searchItem.dateFilterId; + break; + case "field": + legacyQueryElem.value = queryElem.autocompleteValue.value; + legacyQueryElem.label = queryElem.autocompleteValue.label; + legacyQueryElem.operator = queryElem.autocompleteValue.operator; + break; + } + query.push(legacyQueryElem); + } + + const filters = {}; + for (const item of Object.values(parsedState.searchItems)) { + const filter = Object.assign({}, item); + switch (item.type) { + case "dateGroupBy": + filter.type = "groupBy"; + filter.hasOptions = true; + filter.defaultOptionId = item.defaultIntervalId; + delete filter.defaultIntervalId; + break; + case "dateFilter": + filter.type = "filter"; + filter.isDateFilter = true; + filter.hasOptions = true; + filter.defaultOptionId = item.defaultGeneratorId; + delete filter.defaultGeneratorId; + break; + case "favorite": + filter.orderedBy = item.orderBy; + delete filter.orderBy; + break; + } + filters[item.id] = filter; + } + + const { nextGroupId, nextGroupNumber, nextId, sections, searchPanelInfo } = parsedState; + const legacyState = {}; + legacyState.ControlPanelModelExtension = { + query, + filters, + nextGroupId, + nextGroupNumber, + nextId, + }; + legacyState.SearchPanelModelExtension = { sections, searchPanelInfo }; + + for (const [key, extension] of Object.entries(ActionModel.registry.entries())) { + if (!["ControlPanel", "SearchPanel"].includes(key) && parsedState[key] !== undefined) { + legacyState[extension.name] = parsedState[key]; + } + } + + return JSON.stringify(legacyState); +} + +export function exportGlobalState(legacyControllerState) { + const { resIds, searchModel, searchPanel } = legacyControllerState; + const globalState = {}; + if (searchPanel) { + globalState.searchPanel = searchPanel; + } + if (resIds) { + globalState.resIds = resIds; + } + const newSearchModel = searchModelStateFromLegacy(searchModel); + if (newSearchModel) { + globalState.searchModel = newSearchModel; + } + return globalState; +} + +export function exportLocalState(legacyControllerState) { + const state = Object.assign({}, legacyControllerState); + delete state.searchModel; + delete state.searchPanel; + delete state.resIds; + return state; +} + +export function mapDoActionOptionAPI(legacyOptions) { + legacyOptions = legacyOptions || {}; + // use camelCase instead of snake_case for some keys + Object.assign(legacyOptions, { + additionalContext: legacyOptions.additional_context, + clearBreadcrumbs: legacyOptions.clear_breadcrumbs, + viewType: legacyOptions.view_type, + onClose: legacyOptions.on_close, + props: { + resId: legacyOptions.res_id, + }, + }); + if (legacyOptions.controllerState) { + legacyOptions.props.globalState = exportGlobalState(legacyOptions.controllerState); + } + delete legacyOptions.additional_context; + delete legacyOptions.clear_breadcrumbs; + delete legacyOptions.view_type; + delete legacyOptions.res_id; + delete legacyOptions.on_close; + return legacyOptions; +} + +export function makeLegacyActionManagerService(legacyEnv) { + // add a service to redirect 'do-action' events triggered on the bus in the + // legacy env to the action-manager service in the wowl env + return { + dependencies: ["action"], + start(env) { + function do_action(action, options) { + const legacyOptions = mapDoActionOptionAPI(options); + return env.services.action.doAction(action, legacyOptions); + } + legacyEnv.bus.on("do-action", null, (payload) => { + const { action, options } = payload; + do_action(action, options); + }); + return { do_action }; + }, + }; +} + +export function breadcrumbsToLegacy(breadcrumbs) { + if (!breadcrumbs) { + return; + } + return breadcrumbs.slice().map((bc) => { + return { title: bc.name, controllerID: bc.jsId }; + }); +} diff --git a/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js b/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js index 08e54983eee..b97b6f2fe65 100644 --- a/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js +++ b/addons/web/static/src/legacy/js/control_panel/control_panel_model_extension.js @@ -13,10 +13,6 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", const FAVORITE_SHARED_GROUP = 2; const DISABLE_FAVORITE = "search_disable_custom_filters"; - let filterId = 1; - let groupId = 1; - let groupNumber = 0; - /** * Control panel model * @@ -215,6 +211,9 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", */ prepareState() { Object.assign(this.state, { + nextGroupId: 1, + nextGroupNumber: 1, + nextId: 1, filters: {}, query: [], }); @@ -318,13 +317,13 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", const preFavorite = await this._saveQuery(preFilter); this.clearQuery(); const filter = Object.assign(preFavorite, { - groupId, - id: filterId, + groupId: this.state.nextGroupId, + id: this.state.nextId, }); - this.state.filters[filterId] = filter; - this.state.query.push({ groupId, filterId }); - groupId++; - filterId++; + this.state.filters[this.state.nextId] = filter; + this.state.query.push({ groupId: this.state.nextGroupId, filterId: this.state.nextId }); + this.state.nextGroupId++; + this.state.nextId++; } /** @@ -340,18 +339,18 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", const newFilterIdS = []; prefilters.forEach(preFilter => { const filter = Object.assign(preFilter, { - groupId, - groupNumber, - id: filterId, + groupId: this.state.nextGroupId, + groupNumber: this.state.nextGroupNumber, + id: this.state.nextId, type: 'filter', }); - this.state.filters[filterId] = filter; - this.state.query.push({ groupId, filterId }); - newFilterIdS.push(filterId); - filterId++; + this.state.filters[this.state.nextId] = filter; + this.state.query.push({ groupId: this.state.nextGroupId, filterId: this.state.nextId }); + newFilterIdS.push(this.state.nextId); + this.state.nextId++; }); - groupId++; - groupNumber++; + this.state.nextGroupId++; + this.state.nextGroupNumber++; return newFilterIdS; } @@ -366,22 +365,22 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", description: field.string || field.name, fieldName: field.name, fieldType: field.type, - groupId: groupBy ? groupBy.groupId : groupId++, - groupNumber, - id: filterId, + groupId: groupBy ? groupBy.groupId : this.state.nextGroupId++, + groupNumber: this.state.nextGroupNumber, + id: this.state.nextId, type: 'groupBy', custom: true, }; - this.state.filters[filterId] = filter; + this.state.filters[this.state.nextId] = filter; if (['date', 'datetime'].includes(field.type)) { filter.hasOptions = true; filter.defaultOptionId = DEFAULT_INTERVAL; - this.toggleFilterWithOptions(filterId); + this.toggleFilterWithOptions(this.state.nextId); } else { - this.toggleFilter(filterId); + this.toggleFilter(this.state.nextId); } - groupNumber++; - filterId++; + this.state.nextGroupNumber++; + this.state.nextId++; } /** @@ -732,14 +731,14 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", */ _createGroupOfFilters(pregroup) { pregroup.forEach(preFilter => { - const filter = Object.assign(preFilter, { groupId, id: filterId }); - this.state.filters[filterId] = filter; + const filter = Object.assign(preFilter, { groupId: this.state.nextGroupId, id: this.state.nextId }); + this.state.filters[this.state.nextId] = filter; if (!this.defaultFavoriteId && filter.isDefault && filter.type === 'field') { this._prepareDefaultLabel(filter); } - filterId++; + this.state.nextId++; }); - groupId++; + this.state.nextGroupId++; } /** @@ -784,7 +783,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", } currentTag = preFilter.tag; currentGroup = []; - groupNumber++; + this.state.nextGroupNumber++; } if (preFilter.tag !== 'separator') { const filter = { @@ -811,7 +810,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js", } } if (filter.type === 'filter' || filter.type === 'groupBy') { - filter.groupNumber = groupNumber; + filter.groupNumber = this.state.nextGroupNumber; } this._extractAttributes(filter, preFilter.attrs); currentGroup.push(filter); diff --git a/addons/web/static/src/legacy/js/model.js b/addons/web/static/src/legacy/js/model.js index a367706ea4c..46aff673cbb 100644 --- a/addons/web/static/src/legacy/js/model.js +++ b/addons/web/static/src/legacy/js/model.js @@ -151,7 +151,7 @@ odoo.define("web.Model", function (require) { * 3. Registration of the extension into the main model * @see Registry() * ``` - * ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10); + * ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10); * ``` * *------------------------------------------------------------------------- diff --git a/addons/web/static/src/legacy/js/views/search_panel.js b/addons/web/static/src/legacy/js/views/search_panel.js index e5936260e86..c8e5f8c7f1c 100644 --- a/addons/web/static/src/legacy/js/views/search_panel.js +++ b/addons/web/static/src/legacy/js/views/search_panel.js @@ -46,6 +46,14 @@ this._updateActiveValues(); } + //--------------------------------------------------------------------- + // Getters + //--------------------------------------------------------------------- + + get sections() { + return this.model.get("sections", s => !s.empty); + } + //--------------------------------------------------------------------- // Public //--------------------------------------------------------------------- @@ -204,6 +212,6 @@ importedState: { type: String, optional: 1 }, searchModel: Model, }; - SearchPanel.template = "web.SearchPanel"; + SearchPanel.template = "web.Legacy.SearchPanel"; export default SearchPanel; diff --git a/addons/web/static/src/legacy/legacy_client_actions.js b/addons/web/static/src/legacy/legacy_client_actions.js index 38d33c805a3..e33a50980ae 100644 --- a/addons/web/static/src/legacy/legacy_client_actions.js +++ b/addons/web/static/src/legacy/legacy_client_actions.js @@ -6,7 +6,7 @@ import Widget from "web.Widget"; import { setScrollPosition } from "../core/utils/scrolling"; import { useSetupAction } from "../webclient/actions/action_hook"; import { ClientActionAdapter } from "./action_adapters"; -import { breadcrumbsToLegacy } from "./utils"; +import { breadcrumbsToLegacy } from "./backend_utils"; const { Component, hooks, tags } = owl; const actionRegistry = registry.category("actions"); @@ -42,7 +42,7 @@ function registerClientAction(name, action) { this.props.state && this.props.state.__on_reverse_breadcrumb__; useSetupAction({ beforeLeave: () => this.controllerRef.comp.__widget.canBeRemoved(), - export: () => this.controllerRef.comp.exportState(), + exportLocalState: () => this.controllerRef.comp.exportState(), }); this.onScrollTo = (ev) => { setScrollPosition(this, { left: ev.detail.left, top: ev.detail.top }); diff --git a/addons/web/static/src/legacy/legacy_service_provider.js b/addons/web/static/src/legacy/legacy_service_provider.js index ce4e291f1c3..b8efe0125b1 100644 --- a/addons/web/static/src/legacy/legacy_service_provider.js +++ b/addons/web/static/src/legacy/legacy_service_provider.js @@ -5,7 +5,7 @@ import Context from "web.Context"; import { browser } from "../core/browser/browser"; import { registry } from "../core/registry"; -import { mapDoActionOptionAPI } from "./utils"; +import { mapDoActionOptionAPI } from "./backend_utils"; export const legacyServiceProvider = { dependencies: ["effect", "action"], diff --git a/addons/web/static/src/legacy/legacy_setup.js b/addons/web/static/src/legacy/legacy_setup.js index b29673ba33f..5eee8f2774b 100644 --- a/addons/web/static/src/legacy/legacy_setup.js +++ b/addons/web/static/src/legacy/legacy_setup.js @@ -3,13 +3,13 @@ import { registry } from "../core/registry"; import { makeLegacyNotificationService, - makeLegacyActionManagerService, makeLegacyRpcService, makeLegacySessionService, makeLegacyDialogMappingService, makeLegacyCrashManagerService, makeLegacyCommandService, } from "./utils"; +import { makeLegacyActionManagerService } from "./backend_utils"; import * as AbstractService from "web.AbstractService"; import * as legacyEnv from "web.env"; import * as session from "web.session"; diff --git a/addons/web/static/src/legacy/legacy_views.js b/addons/web/static/src/legacy/legacy_views.js index 01a752db645..681295e9198 100644 --- a/addons/web/static/src/legacy/legacy_views.js +++ b/addons/web/static/src/legacy/legacy_views.js @@ -6,7 +6,12 @@ import { useSetupAction } from "../webclient/actions/action_hook"; import legacyViewRegistry from "web.view_registry"; import { ViewAdapter } from "./action_adapters"; import Widget from "web.Widget"; -import { breadcrumbsToLegacy } from "./utils"; +import { + breadcrumbsToLegacy, + exportGlobalState, + exportLocalState, + searchModelStateToLegacy, +} from "./backend_utils"; import { setScrollPosition } from "../core/utils/scrolling"; import { registry } from "@web/core/registry"; @@ -34,6 +39,17 @@ function registerView(name, LegacyView) { this.Widget = Widget; // fool the ComponentAdapter with a simple Widget this.View = LegacyView; this.viewInfo = {}; + + let resIds; + let searchModel; + let searchPanel; + const { globalState } = this.props; + if (globalState) { + resIds = globalState.resIds; + searchModel = searchModelStateToLegacy(globalState.searchModel); + searchPanel = globalState.searchPanel; + } + this.viewParams = Object.assign({}, this.props.actionFlags, { action: this.props.action, // legacy views automatically add the last part of the breadcrumbs @@ -45,13 +61,9 @@ function registerView(name, LegacyView) { "resId" in this.props ? this.props.resId : this.props.state && this.props.state.currentId, - resIds: this.props.resIds || (this.props.state && this.props.state.resIds), - searchModel: - this.props.searchModel || - (this.props.state && this.props.state.searchModel), - searchPanel: - this.props.searchPanel || - (this.props.state && this.props.state.searchPanel), + resIds: this.props.resIds || resIds, + searchModel, + searchPanel, }, }); @@ -71,7 +83,8 @@ function registerView(name, LegacyView) { this.props.state && this.props.state.__on_reverse_breadcrumb__; useSetupAction({ beforeLeave: () => this.controllerRef.comp.__widget.canBeRemoved(), - export: () => this.controllerRef.comp.exportState(), + exportGlobalState: () => exportGlobalState(this.controllerRef.comp.exportState()), + exportLocalState: () => exportLocalState(this.controllerRef.comp.exportState()), }); this.onScrollTo = (ev) => { setScrollPosition(this, { left: ev.detail.left, top: ev.detail.top }); @@ -85,8 +98,7 @@ function registerView(name, LegacyView) { context: this.props.context, }; const options = { - actionId: this.props.actionId, - context: this.props.context, + actionId: this.props.action.id, loadActionMenus: this.props.loadActionMenus, loadIrFilters: this.props.loadIrFilters, }; diff --git a/addons/web/static/src/legacy/utils.js b/addons/web/static/src/legacy/utils.js index 5310281cdf7..0ab1e395dda 100644 --- a/addons/web/static/src/legacy/utils.js +++ b/addons/web/static/src/legacy/utils.js @@ -10,53 +10,6 @@ import { } from "../core/network/rpc_service"; import { ErrorDialog } from "../core/errors/error_dialogs"; -export function mapDoActionOptionAPI(legacyOptions) { - legacyOptions = legacyOptions || {}; - // use camelCase instead of snake_case for some keys - Object.assign(legacyOptions, { - additionalContext: legacyOptions.additional_context, - clearBreadcrumbs: legacyOptions.clear_breadcrumbs, - viewType: legacyOptions.view_type, - onClose: legacyOptions.on_close, - props: { - resId: legacyOptions.res_id, - }, - }); - if (legacyOptions.controllerState) { - if (legacyOptions.controllerState.searchModel) { - legacyOptions.props.searchModel = legacyOptions.controllerState.searchModel; - } - if (legacyOptions.controllerState.searchPanel) { - legacyOptions.props.searchPanel = legacyOptions.controllerState.searchPanel; - } - } - delete legacyOptions.additional_context; - delete legacyOptions.clear_breadcrumbs; - delete legacyOptions.view_type; - delete legacyOptions.res_id; - delete legacyOptions.on_close; - return legacyOptions; -} - -export function makeLegacyActionManagerService(legacyEnv) { - // add a service to redirect 'do-action' events triggered on the bus in the - // legacy env to the action-manager service in the wowl env - return { - dependencies: ["action"], - start(env) { - function do_action(action, options) { - const legacyOptions = mapDoActionOptionAPI(options); - return env.services.action.doAction(action, legacyOptions); - } - legacyEnv.bus.on("do-action", null, (payload) => { - const { action, options } = payload; - do_action(action, options); - }); - return { do_action }; - }, - }; -} - export function makeLegacyRpcService(legacyEnv) { return { start(env) { @@ -218,15 +171,6 @@ export function mapLegacyEnvToWowlEnv(legacyEnv, wowlEnv) { }); } -export function breadcrumbsToLegacy(breadcrumbs) { - if (!breadcrumbs) { - return; - } - return breadcrumbs.slice().map((bc) => { - return { title: bc.name, controllerID: bc.jsId }; - }); -} - const reBSTooltip = /^bs-.*$/; export function cleanDomFromBootstrap() { diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index aa1e34787d0..87f31711859 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -810,26 +810,6 @@
    - - -
    -
    - -
    -
    -
      - + @@ -57,7 +57,7 @@
      - + @@ -65,7 +65,7 @@
    - + @@ -73,7 +73,7 @@
    - +
  • @@ -96,7 +96,7 @@ />
      - +
    @@ -104,7 +104,7 @@ - +
  • diff --git a/addons/web/static/src/search/comparison_menu/comparison_menu.js b/addons/web/static/src/search/comparison_menu/comparison_menu.js new file mode 100644 index 00000000000..450e833932c --- /dev/null +++ b/addons/web/static/src/search/comparison_menu/comparison_menu.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ + +import { FACET_ICONS } from "../utils/misc"; +import { useBus } from "@web/core/utils/hooks"; + +const { Component } = owl; + +export class ComparisonMenu extends Component { + setup() { + this.icon = FACET_ICONS.comparison; + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Object[]} + */ + get items() { + return this.env.searchModel.getSearchItems( + (searchItem) => searchItem.type === "comparison" + ); + } + + /** + * @param {CustomEvent} + */ + onComparisonSelected(ev) { + const { itemId } = ev.detail.payload; + this.env.searchModel.toggleSearchItem(itemId); + } +} + +ComparisonMenu.template = "web.ComparisonMenu"; diff --git a/addons/web/static/src/search/comparison_menu/comparison_menu.xml b/addons/web/static/src/search/comparison_menu/comparison_menu.xml new file mode 100644 index 00000000000..0c676e49ee8 --- /dev/null +++ b/addons/web/static/src/search/comparison_menu/comparison_menu.xml @@ -0,0 +1,25 @@ + + + + + + + + Comparison + + + + + + + + + + diff --git a/addons/web/static/src/search/control_panel/control_panel.js b/addons/web/static/src/search/control_panel/control_panel.js index 7a15102ed59..dac80643c39 100644 --- a/addons/web/static/src/search/control_panel/control_panel.js +++ b/addons/web/static/src/search/control_panel/control_panel.js @@ -1,9 +1,21 @@ /** @odoo-module **/ +import { ComparisonMenu } from "../comparison_menu/comparison_menu"; +import { FavoriteMenu } from "../favorite_menu/favorite_menu"; +import { FilterMenu } from "../filter_menu/filter_menu"; +import { GroupByMenu } from "../group_by_menu/group_by_menu"; +import { SearchBar } from "../search_bar/search_bar"; import { useService } from "@web/core/utils/hooks"; const { Component } = owl; +const MAPPING = { + filter: FilterMenu, + groupBy: GroupByMenu, + comparison: ComparisonMenu, + favorite: FavoriteMenu, +}; + export class ControlPanel extends Component { setup() { this.actionService = useService("action"); @@ -20,13 +32,31 @@ export class ControlPanel extends Component { "bottom-left": true, "bottom-right": true, }, - this.props.display + this.props.display || this.env.searchModel.display.controlPanel ); display.top = display["top-left"] || display["top-right"]; display.bottom = display["bottom-left"] || display["bottom-right"]; return display; } + /** + * @returns {Component[]} + */ + get searchMenus() { + const searchMenus = []; + for (const key of this.env.searchModel.searchMenuTypes) { + // look in display instead? + if ( + key === "comparison" && + this.env.searchModel.getSearchItems((i) => i.type === "comparison").length === 0 + ) { + continue; + } + searchMenus.push({ Component: MAPPING[key], key }); + } + return searchMenus; + } + /** * Called when an element of the breadcrumbs is clicked. * @@ -46,8 +76,27 @@ export class ControlPanel extends Component { } } +ControlPanel.components = { ComparisonMenu, FavoriteMenu, FilterMenu, GroupByMenu, SearchBar }; ControlPanel.template = "web.ControlPanel"; +// ControlPanel.props = { +// breadcrumbs: { type: Array, element: { jsId: String, name: String }, optional: true }, +// display: { type: Object, optional: true }, +// displayName: { type: String, optional: true }, +// viewSwitcherEntries: { +// type: Array, +// element: { +// type: Object, +// shape: { +// active: { type: Boolean, optional: true }, +// icon: String, +// multiRecord: { type: Boolean, optional: true }, +// name: [Object, String], +// type: String, +// }, +// }, +// optional: true, +// }, +// }; ControlPanel.defaultProps = { breadcrumbs: [], - display: {}, }; diff --git a/addons/web/static/src/search/control_panel/control_panel.xml b/addons/web/static/src/search/control_panel/control_panel.xml index fb3a87ea024..617c77b2d98 100644 --- a/addons/web/static/src/search/control_panel/control_panel.xml +++ b/addons/web/static/src/search/control_panel/control_panel.xml @@ -10,7 +10,9 @@
    - + + +
    @@ -18,7 +20,13 @@
    - + + +
    diff --git a/addons/web/static/src/search/favorite_menu/custom_favorite_item.js b/addons/web/static/src/search/favorite_menu/custom_favorite_item.js new file mode 100644 index 00000000000..f66b017f422 --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/custom_favorite_item.js @@ -0,0 +1,100 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useAutofocus, useService } from "@web/core/utils/hooks"; + +const { Component, hooks } = owl; +const { useRef, useState } = hooks; + +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +export class CustomFavoriteItem extends Component { + setup() { + this.notificationService = useService("notification"); + this.descriptionRef = useRef("description"); + useAutofocus(); + this.state = useState({ + description: this.env.searchModel.displayName, + isDefault: false, + isShared: false, + }); + } + + /** + * @param {Event} ev + */ + saveFavorite(ev) { + if (!this.state.description.length) { + this.notificationService.add( + this.env._t("A name for your favorite filter is required."), + { type: "danger" } + ); + ev.stopPropagation(); + return this.descriptionRef.el.focus(); + } + const favorites = this.env.searchModel.getSearchItems( + (s) => s.type === "favorite" && s.description === this.state.description + ); + if (favorites.length) { + this.notificationService.add(this.env._t("A filter with same name already exists."), { + type: "danger", + }); + ev.stopPropagation(); + return this.descriptionRef.el.focus(); + } + const { description, isDefault, isShared } = this.state; + this.env.searchModel.createNewFavorite({ description, isDefault, isShared }); + + Object.assign(this.state, { + description: this.env.searchModel.displayName, + isDefault: false, + isShared: false, + }); + } + + /** + * @param {Event} ev + */ + onDefaultCheckboxChange(ev) { + const { checked } = ev.target; + this.state.isDefault = checked; + if (checked) { + this.state.isShared = false; + } + } + + /** + * @param {Event} ev + */ + onShareCheckboxChange(ev) { + const { checked } = ev.target; + this.state.isShared = checked; + if (checked) { + this.state.isDefault = false; + } + } + + /** + * @param {KeyboardEvent} ev + */ + onInputKeydown(ev) { + switch (ev.key) { + case "Enter": + ev.preventDefault(); + this.saveFavorite(); + break; + case "Escape": + // Gives the focus back to the component. + ev.preventDefault(); + ev.target.blur(); + break; + } + } +} + +CustomFavoriteItem.template = "web.CustomFavoriteItem"; +favoriteMenuRegistry.add( + "custom-favorite-item", + { Component: CustomFavoriteItem, groupNumber: 3 }, + { sequence: 0 } +); diff --git a/addons/web/static/src/search/favorite_menu/custom_favorite_item.xml b/addons/web/static/src/search/favorite_menu/custom_favorite_item.xml new file mode 100644 index 00000000000..82b7aa92cee --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/custom_favorite_item.xml @@ -0,0 +1,32 @@ + + + + + + + Save current search + +
  • + + + Use by default + + + Share with all users + +
  • +
  • + +
  • + +
    + + diff --git a/addons/web/static/src/search/favorite_menu/favorite_menu.js b/addons/web/static/src/search/favorite_menu/favorite_menu.js new file mode 100644 index 00000000000..38e36f9a515 --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/favorite_menu.js @@ -0,0 +1,68 @@ +/** @odoo-module **/ + +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { FACET_ICONS } from "../utils/misc"; +import { registry } from "@web/core/registry"; +import { useBus } from "@web/core/utils/hooks"; +import { useService } from "@web/core/utils/hooks"; + +const favoriteMenuRegistry = registry.category("favoriteMenu"); + +const { Component } = owl; + +export class FavoriteMenu extends Component { + setup() { + this.icon = FACET_ICONS.favorite; + this.dialogService = useService("dialog"); + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Array} + */ + get items() { + const favorites = this.env.searchModel.getSearchItems( + (searchItem) => searchItem.type === "favorite" + ); + const registryMenus = []; + for (const item of favoriteMenuRegistry.getAll()) { + if ("isDisplayed" in item ? item.isDisplayed(this.env) : true) { + registryMenus.push({ + Component: item.Component, + groupNumber: item.groupNumber, + key: item.Component.name, + }); + } + } + return [...favorites, ...registryMenus]; + } + + /** + * @param {CustomEvent} ev + */ + onFavoriteSelected(ev) { + const { itemId } = ev.detail.payload; + this.env.searchModel.toggleSearchItem(itemId); + } + + /** + * @param {number} itemId + */ + openConfirmationDialog(itemId) { + const { userId } = this.items.find((item) => item.id === itemId); + const dialogProps = { + title: this.env._t("Warning"), + body: userId + ? this.env._t("Are you sure that you want to remove this filter?") + : this.env._t( + "This filter is global and will be removed for everybody if you continue." + ), + confirm: () => this.env.searchModel.deleteFavorite(itemId), + cancel: () => {}, + }; + this.dialogService.add(ConfirmationDialog, dialogProps); + } +} + +FavoriteMenu.template = "web.FavoriteMenu"; diff --git a/addons/web/static/src/search/favorite_menu/favorite_menu.xml b/addons/web/static/src/search/favorite_menu/favorite_menu.xml new file mode 100644 index 00000000000..690925e8cc4 --- /dev/null +++ b/addons/web/static/src/search/favorite_menu/favorite_menu.xml @@ -0,0 +1,41 @@ + + + + + + + + Favorites + + + + + + + + + + + + + + diff --git a/addons/web/static/src/search/filter_menu/filter_menu.js b/addons/web/static/src/search/filter_menu/filter_menu.js new file mode 100644 index 00000000000..ea450285e57 --- /dev/null +++ b/addons/web/static/src/search/filter_menu/filter_menu.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { CustomFilterItem } from "./custom_filter_item"; +import { FACET_ICONS } from "../utils/misc"; +import { useBus } from "@web/core/utils/hooks"; + +const { Component } = owl; + +export class FilterMenu extends Component { + setup() { + this.icon = FACET_ICONS.filter; + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Object[]} + */ + get items() { + return this.env.searchModel.getSearchItems((searchItem) => + ["filter", "dateFilter"].includes(searchItem.type) + ); + } + + /** + * @param {CustomEvent} ev + */ + onFilterSelected(ev) { + const { itemId, optionId } = ev.detail.payload; + if (optionId) { + this.env.searchModel.toggleDateFilter(itemId, optionId); + } else { + this.env.searchModel.toggleSearchItem(itemId); + } + } +} + +FilterMenu.components = { CustomFilterItem }; +FilterMenu.template = "web.FilterMenu"; diff --git a/addons/web/static/src/search/filter_menu/filter_menu.xml b/addons/web/static/src/search/filter_menu/filter_menu.xml new file mode 100644 index 00000000000..f562887e5cf --- /dev/null +++ b/addons/web/static/src/search/filter_menu/filter_menu.xml @@ -0,0 +1,57 @@ + + + + + + + + Filters + + + + +
  • + +
  • + + + +
    +
    + +
    diff --git a/addons/web/static/src/search/group_by_menu/group_by_menu.js b/addons/web/static/src/search/group_by_menu/group_by_menu.js new file mode 100644 index 00000000000..d9426bbbf32 --- /dev/null +++ b/addons/web/static/src/search/group_by_menu/group_by_menu.js @@ -0,0 +1,72 @@ +/** @odoo-module **/ + +import { CustomGroupByItem } from "./custom_group_by_item"; +import { FACET_ICONS, GROUPABLE_TYPES } from "../utils/misc"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { sortBy } from "@web/core/utils/arrays"; +import { useBus } from "@web/core/utils/hooks"; + +const { Component } = owl; + +export class GroupByMenu extends Component { + setup() { + this.icon = FACET_ICONS.groupBy; + this.dropdownProps = Object.keys(this.props) + .filter((key) => key in Dropdown.props) + .reduce((obj, key) => ({ ...obj, [key]: this.props[key] }), {}); + const fields = []; + for (const [fieldName, field] of Object.entries(this.env.searchModel.searchViewFields)) { + if (this.validateField(fieldName, field)) { + fields.push(Object.assign({ name: fieldName }, field)); + } + } + this.fields = sortBy(fields, "string"); + + useBus(this.env.searchModel, "update", this.render); + } + + /** + * @returns {Object[]} + */ + get items() { + return this.env.searchModel.getSearchItems((searchItem) => + ["groupBy", "dateGroupBy"].includes(searchItem.type) + ); + } + + /** + * @param {string} fieldName + * @param {Object} field + * @returns {boolean} + */ + validateField(fieldName, field) { + const { sortable, type } = field; + return fieldName !== "id" && sortable && GROUPABLE_TYPES.includes(type); + } + + /** + * @param {CustomEvent} ev + */ + onGroupBySelected(ev) { + const { itemId, optionId } = ev.detail.payload; + if (optionId) { + this.env.searchModel.toggleDateGroupBy(itemId, optionId); + } else { + this.env.searchModel.toggleSearchItem(itemId); + } + } + + /** + * @param {CustomEvent} ev + */ + onAddCustomGroup(ev) { + const { fieldName } = ev.detail; + this.env.searchModel.createNewGroupBy(fieldName); + } +} + +GroupByMenu.components = { CustomGroupByItem }; +GroupByMenu.template = "web.GroupByMenu"; +GroupByMenu.defaultProps = { + showActiveItems: true, +}; diff --git a/addons/web/static/src/search/group_by_menu/group_by_menu.xml b/addons/web/static/src/search/group_by_menu/group_by_menu.xml new file mode 100644 index 00000000000..384a15337a4 --- /dev/null +++ b/addons/web/static/src/search/group_by_menu/group_by_menu.xml @@ -0,0 +1,58 @@ + + + + + + + + Group By + + + + +