From 3e6bbb4ebd221bc3f9623a116e18194064363a11 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 23 Jun 2022 07:40:51 +0000 Subject: [PATCH 01/50] [REF] web: linting and small code improvements Part-of: odoo/odoo#92475 --- addons/web/static/src/core/browser/browser.js | 4 +- .../static/src/core/file_input/file_input.xml | 2 +- addons/web/static/src/core/macro.js | 4 +- addons/web/static/src/core/ui/ui_service.js | 56 +++++++------------ addons/web/static/src/core/utils/numbers.js | 4 +- addons/web/static/src/core/utils/strings.js | 5 +- addons/web/static/src/env.js | 2 +- .../src/webclient/actions/client_actions.js | 4 +- .../core/commands/command_service_tests.js | 14 ++--- .../tests/core/l10n/translation_tests.js | 4 +- .../static/tests/core/position_hook_tests.js | 11 ++-- 11 files changed, 51 insertions(+), 59 deletions(-) diff --git a/addons/web/static/src/core/browser/browser.js b/addons/web/static/src/core/browser/browser.js index 0223cc526d6..eb341750fa5 100644 --- a/addons/web/static/src/core/browser/browser.js +++ b/addons/web/static/src/core/browser/browser.js @@ -52,11 +52,11 @@ Object.defineProperty(browser, "location", { configurable: true, }); -Object.defineProperty(browser, 'innerHeight', { +Object.defineProperty(browser, "innerHeight", { get: () => window.innerHeight, configurable: true, }); -Object.defineProperty(browser, 'innerWidth', { +Object.defineProperty(browser, "innerWidth", { get: () => window.innerWidth, configurable: true, }); diff --git a/addons/web/static/src/core/file_input/file_input.xml b/addons/web/static/src/core/file_input/file_input.xml index aec7608927d..cb94c5d790d 100644 --- a/addons/web/static/src/core/file_input/file_input.xml +++ b/addons/web/static/src/core/file_input/file_input.xml @@ -9,7 +9,7 @@ diff --git a/addons/web/static/src/core/macro.js b/addons/web/static/src/core/macro.js index 3fd121ca403..7511e564dab 100644 --- a/addons/web/static/src/core/macro.js +++ b/addons/web/static/src/core/macro.js @@ -4,7 +4,9 @@ import { browser } from "@web/core/browser/browser"; // rough approximation of a visible element. not perfect (does not take into // account opacity = 0 for example), but good enough for our purpose -const isVisible = (e) => e.offsetWidth > 0 || e.offsetHeight > 0; +function isVisible(e) { + return e.offsetWidth > 0 || e.offsetHeight > 0; +} export const ACTION_HELPERS = { click(el, _step) { diff --git a/addons/web/static/src/core/ui/ui_service.js b/addons/web/static/src/core/ui/ui_service.js index 70e5245e707..6ad6ce1e9ec 100644 --- a/addons/web/static/src/core/ui/ui_service.js +++ b/addons/web/static/src/core/ui/ui_service.js @@ -55,7 +55,7 @@ export const MEDIAS_BREAKPOINTS = [ * * @returns {MediaQueryList[]} */ - export function getMediaQueryLists() { +export function getMediaQueryLists() { return MEDIAS_BREAKPOINTS.map(({ minWidth, maxWidth }) => { if (!maxWidth) { return window.matchMedia(`(min-width: ${minWidth}px)`); @@ -72,11 +72,9 @@ const MEDIAS = getMediaQueryLists(); export const uiService = { getSize() { - return MEDIAS.findIndex(media => media.matches); + return MEDIAS.findIndex((media) => media.matches); }, start(env) { - let ui = {}; - // block/unblock code const bus = new EventBus(); registry.category("main_components").add("BlockUI", { Component: BlockUI, props: { bus } }); @@ -101,18 +99,6 @@ export const uiService = { } } - Object.assign(ui, { - bus, - block, - unblock, - }); - - Object.defineProperty(ui, "isBlocked", { - get() { - return blockCount > 0; - }, - }); - // UI active element code let activeElems = [document]; @@ -132,34 +118,34 @@ export const uiService = { } } - Object.assign(ui, { + const ui = { + bus, + size: this.getSize(), + get activeElement() { + return activeElems[activeElems.length - 1]; + }, + get isBlocked() { + return blockCount > 0; + }, + get isSmall() { + return ui.size <= SIZES.SM; + }, + block, + unblock, activateElement, deactivateElement, getActiveElementOf, - }); - Object.defineProperty(ui, "activeElement", { - get() { - return activeElems[activeElems.length - 1]; - }, - }); + }; // listen to media query status changes - function updateSize() { + const updateSize = () => { ui.size = this.getSize(); - } - browser.addEventListener("resize", debounce(updateSize.bind(this), 100)); + }; + browser.addEventListener("resize", debounce(updateSize, 100)); - Object.assign(ui, { - size: this.getSize(), - }); - Object.defineProperty(ui, "isSmall", { - get() { - return ui.size <= SIZES.SM; - }, - }); Object.defineProperty(env, "isSmall", { get() { - return ui.size <= SIZES.SM; + return ui.isSmall; }, }); diff --git a/addons/web/static/src/core/utils/numbers.js b/addons/web/static/src/core/utils/numbers.js index 38a9d09e7da..a7bc46b8676 100644 --- a/addons/web/static/src/core/utils/numbers.js +++ b/addons/web/static/src/core/utils/numbers.js @@ -23,10 +23,10 @@ export function computeVariation(value, comparisonValue) { /** * Returns value clamped to the inclusive range of min and max. - * + * * @param {Number} num * @param {Number} min - * @param {Number} max + * @param {Number} max * @returns {Number} */ export function clamp(num, min, max) { diff --git a/addons/web/static/src/core/utils/strings.js b/addons/web/static/src/core/utils/strings.js index 879355ccebc..88b5185e3f9 100644 --- a/addons/web/static/src/core/utils/strings.js +++ b/addons/web/static/src/core/utils/strings.js @@ -91,9 +91,9 @@ export function intersperse(str, indices, separator = "") { export function sprintf(s, ...values) { if (values.length === 1 && Object.prototype.toString.call(values[0]) === "[object Object]") { const valuesDict = values[0]; - s = s.replace(/\%\(?([^\)]+)\)s/g, (match, value) => valuesDict[value]); + s = s.replace(/%\(?([^)]+)\)s/g, (match, value) => valuesDict[value]); } else if (values.length > 0) { - s = s.replace(/\%s/g, () => values.shift()); + s = s.replace(/%s/g, () => values.shift()); } return s; } @@ -108,6 +108,7 @@ export function capitalize(s) { return s ? s[0].toUpperCase() + s.slice(1) : ""; } +/* eslint-disable */ // prettier-ignore const diacriticsMap = { '\u0041': 'A','\u24B6': 'A','\uFF21': 'A','\u00C0': 'A','\u00C1': 'A','\u00C2': 'A','\u1EA6': 'A','\u1EA4': 'A','\u1EAA': 'A','\u1EA8': 'A', diff --git a/addons/web/static/src/env.js b/addons/web/static/src/env.js index bee4ef2fd4d..721fec8b261 100644 --- a/addons/web/static/src/env.js +++ b/addons/web/static/src/env.js @@ -118,7 +118,7 @@ async function _startServices(env, toStart) { } if (value instanceof Promise) { proms.push( - new Promise(async (resolve) => { + new Promise((resolve) => { value .then((val) => { services[name] = val || null; diff --git a/addons/web/static/src/webclient/actions/client_actions.js b/addons/web/static/src/webclient/actions/client_actions.js index 844142f381e..e157040d885 100644 --- a/addons/web/static/src/webclient/actions/client_actions.js +++ b/addons/web/static/src/webclient/actions/client_actions.js @@ -6,7 +6,7 @@ import { escape, sprintf } from "@web/core/utils/strings"; const { Component, onMounted, xml } = owl; -export const displayNotificationAction = (env, action) => { +export function displayNotificationAction(env, action) { const params = action.params || {}; const options = { className: params.className || "", @@ -20,7 +20,7 @@ export const displayNotificationAction = (env, action) => { const message = owl.markup(sprintf(escape(params.message), ...links)); env.services.notification.add(message, options); return params.next; -}; +} registry.category("actions").add("display_notification", displayNotificationAction); diff --git a/addons/web/static/tests/core/commands/command_service_tests.js b/addons/web/static/tests/core/commands/command_service_tests.js index 98b98579008..960030ffe7c 100644 --- a/addons/web/static/tests/core/commands/command_service_tests.js +++ b/addons/web/static/tests/core/commands/command_service_tests.js @@ -228,13 +228,13 @@ QUnit.test("data-hotkey added to command palette", async (assert) => { } MyComponent.components = { TestComponent }; MyComponent.template = xml` -
-
- `; +
+
+ `; await mount(MyComponent, target, { env }); // Open palette diff --git a/addons/web/static/tests/core/l10n/translation_tests.js b/addons/web/static/tests/core/l10n/translation_tests.js index 56048645de8..e675b319d2c 100644 --- a/addons/web/static/tests/core/l10n/translation_tests.js +++ b/addons/web/static/tests/core/l10n/translation_tests.js @@ -24,7 +24,7 @@ class TestComponent extends Component {} * @param {string} lang * @returns {Promise} */ -const patchLang = async (lang) => { +async function patchLang(lang) { const { defaultLocale, defaultNumberingSystem } = Settings; registerCleanup(() => { Settings.defaultLocale = defaultLocale; @@ -50,7 +50,7 @@ const patchLang = async (lang) => { }); serviceRegistry.add("localization", localizationService); await makeTestEnv(); -}; +} QUnit.module("Translations"); diff --git a/addons/web/static/tests/core/position_hook_tests.js b/addons/web/static/tests/core/position_hook_tests.js index f95c6134f18..7004c188ab3 100644 --- a/addons/web/static/tests/core/position_hook_tests.js +++ b/addons/web/static/tests/core/position_hook_tests.js @@ -153,7 +153,7 @@ QUnit.test("has no effect when component is destroyed", async (assert) => { ); }); -const getPositionTest = (position, positionToCheck) => { +function getPositionTest(position, positionToCheck) { return async (assert) => { assert.expect(2); positionToCheck = positionToCheck || position; @@ -167,7 +167,8 @@ const getPositionTest = (position, positionToCheck) => { }); await mount(TestComp, container); }; -}; +} + QUnit.test("position top", getPositionTest("top")); QUnit.test("position left", getPositionTest("left")); QUnit.test("position bottom", getPositionTest("bottom")); @@ -198,7 +199,8 @@ const CONTAINER_STYLE_MAP = { h125: { height: "125px" }, // height of popper + 1/2 reference w125: { width: "125px" }, // width of popper + 1/2 reference }; -const getRepositionTest = (from, to, containerStyleChanges) => { + +function getRepositionTest(from, to, containerStyleChanges) { return async (assert) => { assert.expect(4); const TestComp = getTestComponent({ @@ -220,7 +222,8 @@ const getRepositionTest = (from, to, containerStyleChanges) => { [d, v = "middle"] = to.split("-"); assert.verifySteps([`${d}-${v}`], `has ${to} position`); }; -}; +} + // ----------------------------------------------------------------------------- QUnit.test( "reposition from top-start to bottom-start", From 7e1ed51c5eba89c7312f1025ded12e9112fa3fe4 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Thu, 23 Jun 2022 20:31:01 +0000 Subject: [PATCH 02/50] [FIX] web: domain: can follow field relations Before this commit, a domain like ``` [["partner_id.name", "ilike", "Agrolait"]] ``` would crash when being evaluated. Part-of: odoo/odoo#92475 --- addons/web/static/src/core/domain.js | 8 +++ addons/web/static/tests/core/domain_tests.js | 56 +++++++++++++++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js index af1e0f5c3a4..592bf95a70b 100644 --- a/addons/web/static/src/core/domain.js +++ b/addons/web/static/src/core/domain.js @@ -187,6 +187,14 @@ function matchCondition(record, condition) { return condition; } const [field, operator, value] = condition; + + if (typeof field === "string") { + const names = field.split("."); + if (names.length >= 2) { + return matchCondition(record[names[0]], [names.slice(1).join("."), operator, value]); + } + } + const fieldValue = typeof field === "number" ? field : record[field]; switch (operator) { case "=": diff --git a/addons/web/static/tests/core/domain_tests.js b/addons/web/static/tests/core/domain_tests.js index e7499740540..3cc6d4b0463 100644 --- a/addons/web/static/tests/core/domain_tests.js +++ b/addons/web/static/tests/core/domain_tests.js @@ -161,16 +161,37 @@ QUnit.module("domain", {}, () => { '[("name", "=", "true")]' ); assert.strictEqual(new Domain().toString(), "[]"); - assert.strictEqual(new Domain([['name', 'in', [true, false]]]).toString(), '[("name", "in", [True, False])]'); - assert.strictEqual(new Domain([['name', 'in', [null]]]).toString(), '[("name", "in", [None])]'); - assert.strictEqual(new Domain([['name', 'in', ["foo", "bar"]]]).toString(), '[("name", "in", ["foo", "bar"])]'); - assert.strictEqual(new Domain([['name', 'in', [1, 2]]]).toString(), '[("name", "in", [1, 2])]'); - assert.strictEqual(new Domain(['&', ['name', '=', 'foo'], ['type', '=', 'bar']]).toString(), '["&", ("name", "=", "foo"), ("type", "=", "bar")]'); - assert.strictEqual(new Domain(['|', ['name', '=', 'foo'], ['type', '=', 'bar']]).toString(), '["|", ("name", "=", "foo"), ("type", "=", "bar")]'); + assert.strictEqual( + new Domain([["name", "in", [true, false]]]).toString(), + '[("name", "in", [True, False])]' + ); + assert.strictEqual( + new Domain([["name", "in", [null]]]).toString(), + '[("name", "in", [None])]' + ); + assert.strictEqual( + new Domain([["name", "in", ["foo", "bar"]]]).toString(), + '[("name", "in", ["foo", "bar"])]' + ); + assert.strictEqual( + new Domain([["name", "in", [1, 2]]]).toString(), + '[("name", "in", [1, 2])]' + ); + assert.strictEqual( + new Domain(["&", ["name", "=", "foo"], ["type", "=", "bar"]]).toString(), + '["&", ("name", "=", "foo"), ("type", "=", "bar")]' + ); + assert.strictEqual( + new Domain(["|", ["name", "=", "foo"], ["type", "=", "bar"]]).toString(), + '["|", ("name", "=", "foo"), ("type", "=", "bar")]' + ); assert.strictEqual(new Domain().toString(), "[]"); // string domains are only reformatted - assert.strictEqual(new Domain('[("name","ilike","foo")]').toString(), '[("name", "ilike", "foo")]'); + assert.strictEqual( + new Domain('[("name","ilike","foo")]').toString(), + '[("name", "ilike", "foo")]' + ); }); QUnit.test("implicit &", function (assert) { @@ -310,6 +331,27 @@ QUnit.module("domain", {}, () => { assert.throws(() => new Domain(["!"]), /invalid domain .* \(missing 1 segment/); }); + QUnit.test("follow relations", function (assert) { + assert.ok( + new Domain([["partner.city", "ilike", "Bru"]]).contains({ + name: "Lucas", + partner: { + city: "Bruxelles", + }, + }) + ); + assert.ok( + new Domain([["partner.city.name", "ilike", "Bru"]]).contains({ + name: "Lucas", + partner: { + city: { + name: "Bruxelles", + }, + }, + }) + ); + }); + // --------------------------------------------------------------------------- // Normalization // --------------------------------------------------------------------------- From aa7a901678293328978cf18b4daa84756e929853 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Thu, 23 Jun 2022 20:31:43 +0000 Subject: [PATCH 03/50] [FIX] web: domain: correctly compare arrays With this commit, we ensure that array values in domains are correctly compared, w.r.t. the = and <> operators. Part-of: odoo/odoo#92475 --- addons/web/static/src/core/domain.js | 6 +++- addons/web/static/src/core/utils/arrays.js | 18 ++++++++++ addons/web/static/tests/core/domain_tests.js | 9 +++++ .../static/tests/core/utils/arrays_tests.js | 35 ++++++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js index 592bf95a70b..58bfb2326e1 100644 --- a/addons/web/static/src/core/domain.js +++ b/addons/web/static/src/core/domain.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { shallowEqual } from "@web/core/utils/arrays"; import { evaluate, formatAST, parseExpr } from "./py_js/py"; import { toPyValue } from "./py_js/py_utils"; @@ -199,10 +200,13 @@ function matchCondition(record, condition) { switch (operator) { case "=": case "==": + if (Array.isArray(fieldValue) && Array.isArray(value)) { + return shallowEqual(fieldValue, value); + } return fieldValue === value; case "!=": case "<>": - return fieldValue !== value; + return JSON.stringify(fieldValue) !== JSON.stringify(value); case "<": return fieldValue < value; case "<=": diff --git a/addons/web/static/src/core/utils/arrays.js b/addons/web/static/src/core/utils/arrays.js index f73d4cc121c..da7e771c7b3 100644 --- a/addons/web/static/src/core/utils/arrays.js +++ b/addons/web/static/src/core/utils/arrays.js @@ -151,6 +151,24 @@ export function cartesian() { } } +/** + * Shallow compares two arrays. + * @param {any[]} arrayA + * @param {any[]} arrayB + * @returns {boolean} true iff arrayA and arrayB are shallow equal + */ +export function shallowEqual(arrayA, arrayB) { + if (arrayA.length !== arrayB.length) { + return false; + } + for (let i = 0; i < arrayA.length; i++) { + if (arrayA[i] !== arrayB[i]) { + return false; + } + } + return true; +} + /** * Returns all initial sections of a given array, e.g. for [1, 2] the array * [[], [1], [1, 2]] is returned. diff --git a/addons/web/static/tests/core/domain_tests.js b/addons/web/static/tests/core/domain_tests.js index 3cc6d4b0463..d70f0d435b9 100644 --- a/addons/web/static/tests/core/domain_tests.js +++ b/addons/web/static/tests/core/domain_tests.js @@ -352,6 +352,15 @@ QUnit.module("domain", {}, () => { ); }); + QUnit.test("Arrays comparison", (assert) => { + const domain = new Domain(["&", ["a", "==", []], ["b", "!=", []]]); + + assert.ok(domain.contains({ a: [] })); + assert.ok(domain.contains({ a: [], b: [4] })); + assert.notOk(domain.contains({ a: [1] })); + assert.notOk(domain.contains({ b: [] })); + }); + // --------------------------------------------------------------------------- // Normalization // --------------------------------------------------------------------------- diff --git a/addons/web/static/tests/core/utils/arrays_tests.js b/addons/web/static/tests/core/utils/arrays_tests.js index d78bfc2098a..6dd1a278b2e 100644 --- a/addons/web/static/tests/core/utils/arrays_tests.js +++ b/addons/web/static/tests/core/utils/arrays_tests.js @@ -1,6 +1,13 @@ /** @odoo-module **/ -import { cartesian, groupBy, intersection, sortBy, unique } from "@web/core/utils/arrays"; +import { + cartesian, + groupBy, + intersection, + shallowEqual, + sortBy, + unique, +} from "@web/core/utils/arrays"; QUnit.module("utils", () => { QUnit.module("Arrays"); @@ -233,4 +240,30 @@ QUnit.module("utils", () => { assert.deepEqual(unique([1, 2, 3, 2, 4, 3, 1, 4]), [1, 2, 3, 4]); assert.deepEqual(unique("a d c a b c d b".split(" ")), "a d c b".split(" ")); }); + + QUnit.test("shallowEqual: simple valid cases", function (assert) { + assert.ok(shallowEqual([], [])); + assert.ok(shallowEqual([1], [1])); + assert.ok(shallowEqual([1, "a"], [1, "a"])); + }); + + QUnit.test("shallowEqual: simple invalid cases", function (assert) { + assert.notOk(shallowEqual([1], [])); + assert.notOk(shallowEqual([], [1])); + assert.notOk(shallowEqual([1, "b"], [1, "a"])); + }); + + QUnit.test("shallowEqual: arrays with non primitive values", function (assert) { + const obj = { b: 3 }; + assert.ok(shallowEqual([obj], [obj])); + assert.notOk(shallowEqual([{ b: 3 }], [{ b: 3 }])); + + const arr = ["x", "y", "z"]; + assert.ok(shallowEqual([arr], [arr])); + assert.notOk(shallowEqual([["x", "y", "z"]], [["x", "y", "z"]])); + + const fn = () => {}; + assert.ok(shallowEqual([fn], [fn])); + assert.notOk(shallowEqual([() => {}], [() => {}])); + }); }); From 3c870eacf6bb260c013ef6d347b94a5d2543ee41 Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Thu, 23 Jun 2022 07:17:11 +0000 Subject: [PATCH 04/50] [FIX] web: urls: encode keys and correctly handle falsy values This commit ensures that keys in the hash or querystring in the url is URI encoded. Moreover, it properly handles the case where a value in the hash is falsy. Before this commit, if a value, say "x", was falsy, it resulted in the url in "x", which produces a crash when decoding. With this commit, it generates "x=" in that case. Part-of: odoo/odoo#92475 --- addons/web/static/src/core/utils/urls.js | 4 ++-- addons/web/static/tests/core/router_service_tests.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/web/static/src/core/utils/urls.js b/addons/web/static/src/core/utils/urls.js index fb78bd9ad5c..48fc9e2196f 100644 --- a/addons/web/static/src/core/utils/urls.js +++ b/addons/web/static/src/core/utils/urls.js @@ -3,7 +3,7 @@ import { browser } from "../browser/browser"; /** - * Trasnforms a key value mapping to a string formatted as url hash, e.g. + * Transforms a key value mapping to a string formatted as url hash, e.g. * {a: "x", b: 2} -> "a=x&b=2" * * @param {Object} obj @@ -11,7 +11,7 @@ import { browser } from "../browser/browser"; */ export function objectToUrlEncodedString(obj) { return Object.entries(obj) - .map(([k, v]) => (v ? `${k}=${encodeURIComponent(v)}` : k)) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v || "")}`) .join("&"); } diff --git a/addons/web/static/tests/core/router_service_tests.js b/addons/web/static/tests/core/router_service_tests.js index 718cea0ade9..fa0fb566e97 100644 --- a/addons/web/static/tests/core/router_service_tests.js +++ b/addons/web/static/tests/core/router_service_tests.js @@ -98,7 +98,7 @@ QUnit.test("routeToUrl encodes URI compatible strings", (assert) => { assert.strictEqual(routeToUrl(route), "/asf?a=11&g=summer%20wine"); route.hash = { b: "2", c: "", e: "kloug,gloubi" }; - assert.strictEqual(routeToUrl(route), "/asf?a=11&g=summer%20wine#b=2&c&e=kloug%2Cgloubi"); + assert.strictEqual(routeToUrl(route), "/asf?a=11&g=summer%20wine#b=2&c=&e=kloug%2Cgloubi"); }); QUnit.test("can redirect an URL", async (assert) => { From 35c2f217b0ea476512dcfed1031eda55671bd9c2 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Thu, 23 Jun 2022 06:50:04 +0000 Subject: [PATCH 05/50] [FIX] web: parseDate(Time): can handle short notations In the wowl codebase, we use luxon instead of moment to manipulate dates. Moment was able to parse dates with short notations (e.g. 08/15/22 for the format MM/DD/YYYY). Luxon is more strict and does not support those cases (the format must be perfectly respected). As a consequence, moving from moment to luxon would introduce a regression in our date and datetime fields. This commit implements the same kind of logic as what is done in moment, in our date parsers, such that those cases are handled. Part-of: odoo/odoo#92475 --- .../static/src/core/datepicker/datepicker.js | 149 +++++++++--------- .../static/src/core/datepicker/datepicker.xml | 9 +- addons/web/static/src/core/l10n/dates.js | 113 ++++++++----- .../web/static/tests/core/datepicker_tests.js | 41 ++++- .../web/static/tests/core/l10n/dates_tests.js | 34 ++++ 5 files changed, 227 insertions(+), 119 deletions(-) diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js index e24b2e5f44a..48cc42b07f9 100644 --- a/addons/web/static/src/core/datepicker/datepicker.js +++ b/addons/web/static/src/core/datepicker/datepicker.js @@ -1,7 +1,14 @@ /** @odoo-module **/ +import { + areDateEquals, + formatDate, + formatDateTime, + parseDate, + parseDateTime, +} from "@web/core/l10n/dates"; +import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; import { localization } from "@web/core/l10n/localization"; -import { registry } from "@web/core/registry"; import { useAutofocus } from "@web/core/utils/hooks"; const { @@ -15,38 +22,47 @@ const { } = owl; const { DateTime } = luxon; -const formatters = registry.category("formatters"); -const parsers = registry.category("parsers"); - let datePickerId = 0; /** * @param {DateTime} date * @returns {moment} */ -const luxonDateToMomentDate = (date) => { +function luxonDateToMomentDate(date) { return window.moment(String(date)); -}; +} /** * @param {string} format * @returns {string} */ -const luxonFormatToMomentFormat = (format) => { +function luxonFormatToMomentFormat(format) { return format.replace(/[dy]/g, (x) => x.toUpperCase()); -}; +} /** * @param {string} format * @returns {boolean} */ -const isValidStaticFormat = (format) => { - try { - return /^[\d\s/:-]+$/.test(DateTime.local().toFormat(format)); - } catch (_err) { - return false; - } -}; +function isValidStaticFormat(format) { + return /^[\d\s/:-]+$/.test(DateTime.local().toFormat(format)); +} + +/** + * @param {Function} fn + * @returns {[any, null] | [null, Error]} + */ +function wrapError(fn) { + return (...args) => { + const result = [null, null]; + try { + result[0] = fn(...args); + } catch (_err) { + result[1] = _err; + } + return result; + }; +} /** * Date picker @@ -63,7 +79,7 @@ const isValidStaticFormat = (format) => { */ export class DatePicker extends Component { setup() { - this.root = useRef("root"); + this.rootRef = useRef("root"); this.inputRef = useRef("input"); this.state = useState({ warning: false }); @@ -73,8 +89,7 @@ export class DatePicker extends Component { this.datePickerShown = false; this.initFormat(); - this.setDate(this.props); - this.setFormat(this.props); + this.setDateAndFormat(this.props); useAutofocus(); useExternalListener(window, "scroll", this.onWindowScroll); @@ -88,43 +103,33 @@ export class DatePicker extends Component { this.bootstrapDateTimePicker(this.props); this.updateInput(); - window.$(this.root.el).on("show.datetimepicker", () => { + window.$(this.rootRef.el).on("show.datetimepicker", () => { this.datePickerShown = true; this.inputRef.el.select(); }); - window.$(this.root.el).on("hide.datetimepicker", () => { + window.$(this.rootRef.el).on("hide.datetimepicker", () => { this.datePickerShown = false; this.onDateChange({ useStatic: true }); }); - window.$(this.root.el).on("error.datetimepicker", () => false); + window.$(this.rootRef.el).on("error.datetimepicker", () => false); } onWillUpdateProps(nextProps) { const pickerParams = {}; - let shouldUpdateInput = false; for (const prop in nextProps) { - const prev = this.props[prop]; - const next = nextProps[prop]; - if ((prev instanceof DateTime && !prev.equals(next)) || prev !== next) { + if (!areDateEquals(this.props[prop], nextProps[prop])) { pickerParams[prop] = nextProps[prop]; - if (prop === "date") { - this.setDate(nextProps); - shouldUpdateInput = true; - } else if (prop === "format") { - this.setFormat(nextProps); - shouldUpdateInput = true; - } } } - if (shouldUpdateInput) { + this.setDateAndFormat(nextProps); + if ("date" in pickerParams || "format" in pickerParams) { this.updateInput(); } this.bootstrapDateTimePicker(pickerParams); } onWillUnmount() { - window.$(this.root.el).off(); // Removes all jQuery events - + window.$(this.rootRef.el).off(); // Removes all jQuery events this.bootstrapDateTimePicker("destroy"); } @@ -136,7 +141,7 @@ export class DatePicker extends Component { return { format: !useStatic || isValidStaticFormat(this.format) ? this.format : this.staticFormat, - locale: this.date.locale, + locale: this.props.locale || (this.date && this.date.locale), timezone: this.isLocal, }; } @@ -147,8 +152,8 @@ export class DatePicker extends Component { initFormat() { this.defaultFormat = localization.dateFormat; this.staticFormat = "yyyy/MM/dd"; - this.formatValue = formatters.get("date"); - this.parseValue = parsers.get("date"); + this.formatValue = wrapError(formatDate); + this.parseValue = wrapError(parseDate); this.isLocal = false; } @@ -158,17 +163,10 @@ export class DatePicker extends Component { * @param {Object} params * @param {DateTime} params.date * @param {string} [params.locale] - */ - setDate({ date, locale }) { - this.date = locale ? date.setLocale(locale) : date; - } - - /** - * Sets the current format. - * @param {Object} params * @param {string} [params.format] */ - setFormat({ format }) { + setDateAndFormat({ date, locale, format }) { + this.date = date && locale ? date.setLocale(locale) : date; // Fallback to default localization format in `@web/core/l10n/dates.js`. this.format = format || this.defaultFormat; } @@ -179,10 +177,9 @@ export class DatePicker extends Component { * @param {boolean} [params.useStatic] */ updateInput({ useStatic } = {}) { - try { - this.inputRef.el.value = this.formatValue(this.date, this.getOptions(useStatic)); - } catch (_err) { - // Do nothing + const [formattedDate] = this.formatValue(this.date, this.getOptions(useStatic)); + if (formattedDate) { + this.inputRef.el.value = formattedDate; } } @@ -197,20 +194,20 @@ export class DatePicker extends Component { bootstrapDateTimePicker(commandOrParams) { if (typeof commandOrParams === "object") { const format = isValidStaticFormat(this.format) ? this.format : this.staticFormat; - const params = { ...commandOrParams, format: luxonFormatToMomentFormat(format) }; - if (!params.locale && commandOrParams.date) { - params.locale = commandOrParams.date.locale; - } + const params = { + ...commandOrParams, + date: this.date || null, + format: luxonFormatToMomentFormat(format), + locale: commandOrParams.locale || (this.date && this.date.locale), + }; for (const prop in params) { if (params[prop] instanceof DateTime) { - const luxonDate = params[prop]; - const momentDate = luxonDateToMomentDate(luxonDate); - params[prop] = momentDate; + params[prop] = luxonDateToMomentDate(params[prop]); } } commandOrParams = params; } - return window.$(this.root.el).datetimepicker(commandOrParams); + return window.$(this.rootRef.el).datetimepicker(commandOrParams); } //--------------------------------------------------------------------- @@ -232,17 +229,26 @@ export class DatePicker extends Component { * @param {boolean} [params.useStatic] */ onDateChange({ useStatic } = {}) { - let date; - try { - date = this.parseValue(this.inputRef.el.value, this.getOptions(useStatic)); - } catch (_err) { + const { value } = this.inputRef.el; + const options = this.getOptions(useStatic); + const parsedDate = value && this.parseValue(value, options)[0]; + this.state.warning = parsedDate && parsedDate > DateTime.local(); + if (value && !parsedDate) { // Reset to default (= given) date. - } - if (!date || date.equals(this.date)) { this.updateInput(); - } else { - this.state.warning = date > DateTime.local(); - this.props.onDateTimeChanged(date); + } + if (!areDateEquals(this.date, parsedDate)) { + this.props.onDateTimeChanged(parsedDate); + } + } + + onInputKeydown(ev) { + const hotkey = getActiveHotkey(ev); + if (this.datePickerShown && hotkey === "escape") { + this.bootstrapDateTimePicker("hide"); + this.inputRef.el.select(); + ev.preventDefault(); + ev.stopPropagation(); } } @@ -277,7 +283,7 @@ DatePicker.defaultProps = { DatePicker.props = { // Components props onDateTimeChanged: Function, - date: DateTime, + date: { type: [DateTime, { value: false }], optional: true }, warn_future: { type: Boolean, optional: true }, // Bootstrap datepicker options buttons: { @@ -313,6 +319,7 @@ DatePicker.props = { readonly: { type: Boolean, optional: true }, useCurrent: { type: Boolean, optional: true }, widgetParent: { type: String, optional: true }, + daysOfWeekDisabled: { type: Array, optional: true }, }; DatePicker.template = "web.DatePicker"; @@ -333,8 +340,8 @@ export class DateTimePicker extends DatePicker { initFormat() { this.defaultFormat = localization.dateTimeFormat; this.staticFormat = "yyyy/MM/dd HH:mm:ss"; - this.formatValue = formatters.get("datetime"); - this.parseValue = parsers.get("datetime"); + this.formatValue = wrapError(formatDateTime); + this.parseValue = wrapError(parseDateTime); this.isLocal = true; } } diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml index 3278d0c3705..66665ca7c52 100644 --- a/addons/web/static/src/core/datepicker/datepicker.xml +++ b/addons/web/static/src/core/datepicker/datepicker.xml @@ -13,10 +13,13 @@ t-ref="input" t-on-change="() => this.onDateChange()" t-on-click="onInputClick" + t-on-keydown="onInputKeydown" + /> + - - This date is on the future. Make sure it is what you expected. - diff --git a/addons/web/static/src/core/l10n/dates.js b/addons/web/static/src/core/l10n/dates.js index a6ace94719a..8a737a2e4d8 100644 --- a/addons/web/static/src/core/l10n/dates.js +++ b/addons/web/static/src/core/l10n/dates.js @@ -51,6 +51,15 @@ const smartDateUnits = { }; const smartDateRegex = new RegExp(`^([+-])(\\d+)([${Object.keys(smartDateUnits).join("")}]?)$`); +/** + * @param {any} d1 + * @param {any} d2 + * @returns {boolean} + */ +export function areDateEquals(d1, d2) { + return d1 instanceof DateTime && d2 instanceof DateTime ? d1.equals(d2) : d1 === d2; +} + /** * Smart date inputs are shortcuts to write dates quicker. * These shortcuts should respect the format ^[+-]\d+[dmwy]?$ @@ -86,14 +95,10 @@ function parseSmartDateInput(value) { * Returns false otherwise. * * @param {DateTime | false} date - * @returns {DateTime | false} + * @returns {boolean} */ -function constrain(dt) { - let valid = dt !== false; - valid = valid && dt.isValid; - valid = valid && dt.year >= 1000; - valid = valid && dt.year < 10000; - return valid ? dt : false; +function isValidDateTime(dt) { + return dt && dt.isValid && dt.year >= 1000 && dt.year < 10000; } /** @@ -187,7 +192,7 @@ export function formatDateTime(value, options = {}) { return ""; } const format = options.format || localization.dateTimeFormat; - const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem; + const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem || "latn"; const zone = options.timezone ? "system" : "utc"; value = value.setZone(zone, { keepLocaltime: options.timezone }); return value.toFormat(format, { numberingSystem }); @@ -208,6 +213,9 @@ export function formatDateTime(value, options = {}) { * @returns {DateTime | false} Luxon DateTime object */ export function parseDate(value, options = {}) { + if (!value) { + return false; + } return parseDateTime(value, { timezone: false, ...options }).startOf("day"); } @@ -256,46 +264,75 @@ export function parseDateTime(value, options = {}) { return false; } - const valueDigitsOnly = value.replace(nonDigitsRegex, ""); + const fmt = options.format || localization.dateTimeFormat; const parseOpts = { setZone: true, zone: options.timezone ? "system" : "utc", locale: options.locale, - numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem, + numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem || "latn", }; - let result = constrain(parseSmartDateInput(value)); + // Base case: try parsing with the given format and options + let result = DateTime.fromFormat(value, fmt, parseOpts); - if (!result) { - const fmt = options.format || localization.dateTimeFormat; + // Try parsing as a smart date + if (!isValidDateTime(result)) { + result = parseSmartDateInput(value); + } + + // Try parsing with partial date parts + if (!isValidDateTime(result)) { const fmtWoZero = stripAlphaDupes(fmt); + result = DateTime.fromFormat(value, fmtWoZero, parseOpts); + } + // Try parsing with custom shorthand date parts + if (!isValidDateTime(result)) { // Luxon is not permissive regarding delimiting characters in the format. // So if the value to parse has less characters than the format, we would // try to parse without the delimiting characters. - const woSeps = { - val: valueDigitsOnly, - fmt: fmt.replace(nonAlphaRegex, "").slice(0, valueDigitsOnly.length), - }; + const digitList = value.split(nonDigitsRegex).filter(Boolean); + const fmtList = fmt.split(nonAlphaRegex).filter(Boolean); + const valWoSeps = digitList.join(""); - result = - constrain(DateTime.fromFormat(value, fmt, parseOpts)) || - constrain(DateTime.fromFormat(value, fmtWoZero, parseOpts)) || - constrain(DateTime.fromFormat(woSeps.val, woSeps.fmt, parseOpts)); + // This is the weird part: we try to adapt the given format to comply with + // the amount of digits in the given value. To do this we split the format + // and the value on non-letter and non-digit characters respectively. This + // should create the same amount of grouping parameters, and the format + // groups are trimmed according to the length of their corresponding + // digit group. The 'carry' variable allows for the length of a digit + // group to overflow to the next format group. This is typically the case + // when the given value doesn't have non-digit separators and generates + // one big digit group instead. + let carry = 0; + const fmtWoSeps = fmtList + .map((part, i) => { + const digitLength = (digitList[i] || "").length; + const actualPart = part.slice(0, digitLength + carry); + carry += digitLength - actualPart.length; + return actualPart; + }) + .join(""); + + result = DateTime.fromFormat(valWoSeps, fmtWoSeps, parseOpts); } - if (!result) { - if (valueDigitsOnly.length > 4) { - // Also try some fallback formats, but only if value counts more than - // four digit characters as this could get misinterpreted as the time of - // the actual date. - result = - constrain(DateTime.fromISO(value, parseOpts)) || // ISO8601 - constrain(DateTime.fromSQL(value, parseOpts)); // last try: SQL + // Try with defaul ISO or SQL formats + if (!isValidDateTime(result)) { + // Also try some fallback formats, but only if value counts more than + // four digit characters as this could get misinterpreted as the time of + // the actual date. + const valueDigits = value.replace(nonDigitsRegex, ""); + if (valueDigits.length > 4) { + result = DateTime.fromISO(value, parseOpts); // ISO8601 + if (!isValidDateTime(result)) { + result = DateTime.fromSQL(value, parseOpts); // last try: SQL + } } } - if (!result) { + // No working parsing methods: throw an error + if (!isValidDateTime(result)) { throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value)); } @@ -307,45 +344,45 @@ export function parseDateTime(value, options = {}) { * @param {string} value * @returns {DateTime | false} */ -export const deserializeDate = (value) => { +export function deserializeDate(value) { return parseDate(value, { format: SERVER_DATE_FORMAT, numberingSystem: "latn", }); -}; +} /** * Returns a datetime object parsed from the given serialized string. * @param {string} value * @returns {DateTime | false} */ -export const deserializeDateTime = (value) => { +export function deserializeDateTime(value) { return parseDateTime(value, { format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}`, numberingSystem: "latn", }); -}; +} /** * Returns a serialized string representing the given date. * @param {DateTime} value * @returns {string} */ -export const serializeDate = (value) => { +export function serializeDate(value) { return formatDate(value, { format: SERVER_DATE_FORMAT, numberingSystem: "latn", }); -}; +} /** * Returns a serialized string representing the given datetime. * @param {DateTime} value * @returns {string} */ -export const serializeDateTime = (value) => { +export function serializeDateTime(value) { return formatDateTime(value, { format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}`, numberingSystem: "latn", }); -}; +} diff --git a/addons/web/static/tests/core/datepicker_tests.js b/addons/web/static/tests/core/datepicker_tests.js index ce3d76cb1b5..ce72f2a32a6 100644 --- a/addons/web/static/tests/core/datepicker_tests.js +++ b/addons/web/static/tests/core/datepicker_tests.js @@ -4,6 +4,7 @@ import { applyFilter, toggleMenu } from "@web/../tests/search/helpers"; import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; import { registry } from "@web/core/registry"; import { uiService } from "@web/core/ui/ui_service"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; import ActionModel from "web.ActionModel"; import CustomFilterItem from "web.CustomFilterItem"; import { createComponent } from "web.test_utils"; @@ -25,7 +26,7 @@ let target; * @param {Object} props * @returns {Promise} */ -const mountPicker = async (Picker, props) => { +async function mountPicker(Picker, props) { serviceRegistry .add( "localization", @@ -34,7 +35,8 @@ const mountPicker = async (Picker, props) => { dateTimeFormat: "dd/MM/yyyy HH:mm:ss", }) ) - .add("ui", uiService); + .add("ui", uiService) + .add("hotkey", hotkeyService); class Parent extends Component { setup() { @@ -54,9 +56,9 @@ const mountPicker = async (Picker, props) => { const env = await makeTestEnv(); await mount(Parent, target, { env, props: { Picker } }); -}; +} -const useFRLocale = () => { +function useFRLocale() { if (!window.moment.locales().includes("fr")) { // Mocks the FR locale if not loaded const originalLocale = window.moment.locale(); @@ -76,9 +78,9 @@ const useFRLocale = () => { registerCleanup(() => window.moment.updateLocale("fr", null)); } return "fr"; -}; +} -const useNOLocale = () => { +function useNOLocale() { if (!window.moment.locales().includes("nb")) { const originalLocale = window.moment.locale(); window.moment.defineLocale("nb", { @@ -97,7 +99,7 @@ const useNOLocale = () => { registerCleanup(() => window.moment.updateLocale("nb", null)); } return "nb"; -}; +} QUnit.module("Components", ({ beforeEach }) => { beforeEach(() => { @@ -527,4 +529,29 @@ QUnit.module("Components", ({ beforeEach }) => { await editSelect(valueInput, "05/05/2005"); await applyFilter(target); }); + + QUnit.test("start with no value", async function (assert) { + assert.expect(6); + + await mountPicker(DateTimePicker, { + onDateTimeChanged(date) { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy HH:mm:ss"), + "08/02/1997 15:45:05", + "Event should transmit the correct date" + ); + }, + }); + + const input = target.querySelector(".o_datepicker_input"); + assert.strictEqual(input.value, ""); + + assert.verifySteps([]); + input.value = "08/02/1997 15:45:05"; + await triggerEvent(target, ".o_datepicker_input", "change"); + + assert.verifySteps(["datetime-changed"]); + assert.strictEqual(input.value, "08/02/1997 15:45:05"); + }); }); diff --git a/addons/web/static/tests/core/l10n/dates_tests.js b/addons/web/static/tests/core/l10n/dates_tests.js index 0b40a6de5fb..866f60fff96 100644 --- a/addons/web/static/tests/core/l10n/dates_tests.js +++ b/addons/web/static/tests/core/l10n/dates_tests.js @@ -664,6 +664,33 @@ QUnit.module( }); }); + QUnit.test("parseDate with short notations", async (assert) => { + assert.strictEqual( + parseDate("20-10-20", { format: "yyyy-MM-dd" }).toISO(), + "2020-10-20T00:00:00.000Z" + ); + assert.strictEqual( + parseDate("20/10/20", { format: "yyyy/MM/dd" }).toISO(), + "2020-10-20T00:00:00.000Z" + ); + assert.strictEqual( + parseDate("10-20-20", { format: "MM-dd-yyyy" }).toISO(), + "2020-10-20T00:00:00.000Z" + ); + assert.strictEqual( + parseDate("10-20-20", { format: "MM-yyyy-dd" }).toISO(), + "2020-10-20T00:00:00.000Z" + ); + assert.strictEqual( + parseDate("1-20-2", { format: "MM-yyyy-dd" }).toISO(), + "2020-01-02T00:00:00.000Z" + ); + assert.strictEqual( + parseDate("20/1/2", { format: "yyyy/MM/dd" }).toISO(), + "2020-01-02T00:00:00.000Z" + ); + }); + QUnit.test("parseDateTime", async (assert) => { /** * Type of testSet key: string @@ -756,6 +783,13 @@ QUnit.module( }); }); + QUnit.test("parseDateTime with short notations", async (assert) => { + assert.strictEqual( + parseDateTime("20-10-20 8:5:3", { format: "yyyy-MM-dd hh:mm:ss" }).toISO(), + "2020-10-20T08:05:03.000Z" + ); + }); + QUnit.test("parseDateTime (with legacy options.isUTC = true)", async (assert) => { /** * Type of testSet key: string From 442d855c2e990d533313a4a9b8ff4e70056ce0dc Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 23 Jun 2022 07:08:57 +0000 Subject: [PATCH 06/50] [FIX] web: notification: properly use className option Before this commit, opening a notification with the className option would result in a notification being displayed without the given className. That className was overriden by the transition className. This commit ensures we keep all classNames. Part-of: odoo/odoo#92475 --- .../core/notifications/notification_container.js | 2 +- .../tests/core/notifications/notifications_tests.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/addons/web/static/src/core/notifications/notification_container.js b/addons/web/static/src/core/notifications/notification_container.js index 03ee640d5f2..0eb9617186f 100644 --- a/addons/web/static/src/core/notifications/notification_container.js +++ b/addons/web/static/src/core/notifications/notification_container.js @@ -15,7 +15,7 @@ NotificationContainer.template = xml`
- +
`; diff --git a/addons/web/static/tests/core/notifications/notifications_tests.js b/addons/web/static/tests/core/notifications/notifications_tests.js index d7d516f8944..a8266fd50b1 100644 --- a/addons/web/static/tests/core/notifications/notifications_tests.js +++ b/addons/web/static/tests/core/notifications/notifications_tests.js @@ -38,6 +38,19 @@ QUnit.test("can display a basic notification", async (assert) => { assert.hasClass(notif, "border-warning"); }); +QUnit.test("can display a notification with a className", async (assert) => { + const env = await makeTestEnv({ serviceRegistry }); + const { Component: NotificationContainer, props } = registry + .category("main_components") + .get("NotificationContainer"); + const notifService = env.services.notification; + await mount(NotificationContainer, target, { env, props }); + + notifService.add("I'm a basic notification", { className: "abc" }); + await nextTick(); + assert.containsOnce(target, ".o_notification.abc"); +}); + QUnit.test("title and message are escaped by default", async (assert) => { const env = await makeTestEnv({ serviceRegistry }); const { Component: NotificationContainer, props } = registry From a535d7c6fb5a6abe083e7d665d7243f5061a4773 Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Fri, 24 Jun 2022 07:49:01 +0000 Subject: [PATCH 07/50] [FIX] web_tour: native events + better error messages This commit is two-fold: 1) events triggered with jQuery do not go through the native layer, so if an handler has been bound natively, it won't be called. Since our new code no longer uses jQuery, this caused issue in some tours. 2) this commit explicitely logs the failing step when a tour crashes, which helps debugging. Part-of: odoo/odoo#92475 --- .../web_tour/static/src/js/running_tour_action_helper.js | 2 +- addons/web_tour/static/src/js/tour_manager.js | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/addons/web_tour/static/src/js/running_tour_action_helper.js b/addons/web_tour/static/src/js/running_tour_action_helper.js index cee5a221cc4..f0c27750b4a 100644 --- a/addons/web_tour/static/src/js/running_tour_action_helper.js +++ b/addons/web_tour/static/src/js/running_tour_action_helper.js @@ -110,7 +110,7 @@ var RunningTourActionHelper = core.Class.extend({ values.$element.focusInEnd(); values.$element.trigger($.Event( "keyup", {key: '_', keyCode: 95})); } - values.$element.trigger("change"); + values.$element[0].dispatchEvent(new Event("change", { bubbles: true, cancelable: false })); }, _text_blur: function (values, text) { this._text(values, text); diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js index 8e180c96150..a6b35709cce 100644 --- a/addons/web_tour/static/src/js/tour_manager.js +++ b/addons/web_tour/static/src/js/tour_manager.js @@ -189,7 +189,7 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { // redirect to its starting point (or /web by default) window.location.href = window.location.origin + (tour.url || '/web'); }, - run: function (tour_name, step_delay) { + run: async function (tour_name, step_delay) { console.log(_.str.sprintf("Preparing tour %s", tour_name)); if (this.running_tour) { this._deactivate_tip(this.active_tooltips[this.running_tour]); @@ -534,7 +534,12 @@ return core.Class.extend(mixins.EventDispatcherMixin, ServicesMixin, { tip.run.call(tip.widget, action_helper); } else if (tip.run !== undefined) { var m = tip.run.match(/^([a-zA-Z0-9_]+) *(?:\(? *(.+?) *\)?)?$/); - action_helper[m[1]](m[2]); + try { + action_helper[m[1]](m[2]); + } catch (e) { + console.error(`Tour ${tour_name} failed at step ${self._describeTip(tip)}: ${e.message}`); + throw e; + } } else if (tour.current_step === tour.steps.length - 1) { console.log('Tour %s: ignoring action (auto) of last step', tour_name); } else { From 178aa2360beaf989dba641313fc7db4b63904acc Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 23 Jun 2022 07:09:22 +0000 Subject: [PATCH 08/50] [IMP] web: orm service: add nameGet Part-of: odoo/odoo#92475 --- addons/web/static/src/core/orm_service.js | 11 +++++++++- .../static/tests/core/orm_service_tests.js | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/addons/web/static/src/core/orm_service.js b/addons/web/static/src/core/orm_service.js index efe349ad013..b5f929e92cf 100644 --- a/addons/web/static/src/core/orm_service.js +++ b/addons/web/static/src/core/orm_service.js @@ -125,6 +125,14 @@ export class ORM { return this.call(model, "create", [state], { context: ctx }); } + nameGet(model, ids, ctx) { + validatePrimitiveList("ids", "number", ids); + if (!ids.length) { + return Promise.resolve([]); + } + return this.call(model, "name_get", [ids], { context: ctx }); + } + read(model, ids, fields, ctx) { validatePrimitiveList("ids", "number", ids); if (fields) { @@ -187,7 +195,7 @@ export class ORM { fields, context: ctx, }; - assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]); + assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit", "expand"]); return this.call(model, "web_read_group", [], kwargs); } @@ -223,6 +231,7 @@ export const ormService = { async: [ "call", "create", + "name_get", "read", "readGroup", "search", diff --git a/addons/web/static/tests/core/orm_service_tests.js b/addons/web/static/tests/core/orm_service_tests.js index 04742690c64..24ad25e17ba 100644 --- a/addons/web/static/tests/core/orm_service_tests.js +++ b/addons/web/static/tests/core/orm_service_tests.js @@ -114,6 +114,27 @@ QUnit.test("create method", async (assert) => { }); }); +QUnit.test("nameGet method", async (assert) => { + const [query, rpc] = makeFakeRPC(); + serviceRegistry.add("rpc", rpc); + const env = await makeTestEnv(); + await env.services.orm.nameGet("sale.order", [2, 5], { complete: true }); + assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/name_get"); + assert.deepEqual(query.params, { + args: [[2, 5]], + kwargs: { + context: { + complete: true, + lang: "en", + tz: "taht", + uid: 7, + }, + }, + method: "name_get", + model: "sale.order", + }); +}); + QUnit.test("unlink method", async (assert) => { const [query, rpc] = makeFakeRPC(); serviceRegistry.add("rpc", rpc); From 5501744a83cb9dc99c90fbfda7699f26729c9a68 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 14 Jun 2022 08:47:57 +0000 Subject: [PATCH 09/50] [IMP] web: add searchCount implementation in ORM service Part-of: odoo/odoo#92475 --- addons/web/static/src/core/orm_service.js | 8 ++++++++ .../static/tests/core/orm_service_tests.js | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/addons/web/static/src/core/orm_service.js b/addons/web/static/src/core/orm_service.js index b5f929e92cf..56933502625 100644 --- a/addons/web/static/src/core/orm_service.js +++ b/addons/web/static/src/core/orm_service.js @@ -177,6 +177,14 @@ export class ORM { return this.call(model, "search_read", [], kwargs); } + searchCount(model, domain, ctx = {}) { + validateArray('domain', domain); + const kwargs = { + context: ctx, + }; + return this.call(model, 'search_count', [domain], kwargs); + } + unlink(model, ids, ctx) { validatePrimitiveList("ids", "number", ids); if (!ids.length) { diff --git a/addons/web/static/tests/core/orm_service_tests.js b/addons/web/static/tests/core/orm_service_tests.js index 24ad25e17ba..cf3f7fb0869 100644 --- a/addons/web/static/tests/core/orm_service_tests.js +++ b/addons/web/static/tests/core/orm_service_tests.js @@ -257,6 +257,26 @@ QUnit.test("searchRead method", async (assert) => { }); }); +QUnit.test("searchCount method", async (assert) => { + const [query, rpc] = makeFakeRPC(); + serviceRegistry.add("rpc", rpc); + const env = await makeTestEnv(); + await env.services.orm.searchCount("sale.order", [["user_id", "=", 2]]); + assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/search_count"); + assert.deepEqual(query.params, { + args: [[["user_id", "=", 2]]], + kwargs: { + context: { + lang: "en", + tz: "taht", + uid: 7, + }, + }, + method: "search_count", + model: "sale.order", + }); +}); + QUnit.test("webSearchRead method", async (assert) => { const [query, rpc] = makeFakeRPC(); serviceRegistry.add("rpc", rpc); From 313052ffb80b55fcecc13f8e3d1b5285dc7129bd Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 23 Jun 2022 07:11:43 +0000 Subject: [PATCH 10/50] [IMP] web: object utils: add pluck function Part-of: odoo/odoo#92475 --- addons/web/static/src/core/utils/objects.js | 10 ++++++++++ addons/web/static/tests/core/utils/objects_tests.js | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/addons/web/static/src/core/utils/objects.js b/addons/web/static/src/core/utils/objects.js index 4e7638b0b53..eaf24f0bf8d 100644 --- a/addons/web/static/src/core/utils/objects.js +++ b/addons/web/static/src/core/utils/objects.js @@ -21,3 +21,13 @@ export function shallowEqual(obj1, obj2) { export function deepCopy(obj) { return JSON.parse(JSON.stringify(obj)); } + +/** + * @template {T} + * @param {T} object + * @param {...(keyof T)} properties + * @returns {Partial} + */ +export function pluck(object, ...properties) { + return Object.fromEntries(Object.entries(object).filter(([k]) => properties.includes(k))); +} diff --git a/addons/web/static/tests/core/utils/objects_tests.js b/addons/web/static/tests/core/utils/objects_tests.js index 27e14807ea4..41cde986927 100644 --- a/addons/web/static/tests/core/utils/objects_tests.js +++ b/addons/web/static/tests/core/utils/objects_tests.js @@ -1,10 +1,18 @@ /** @odoo-module **/ -import { shallowEqual } from "@web/core/utils/objects"; +import { pluck, shallowEqual } from "@web/core/utils/objects"; QUnit.module("utils", () => { QUnit.module("Objects"); + QUnit.test("pluck", function (assert) { + assert.deepEqual(pluck({}), {}); + assert.deepEqual(pluck({}, "a"), {}); + assert.deepEqual(pluck({ a: 3, b: "a", c: [] }, "a"), { a: 3 }); + assert.deepEqual(pluck({ a: 3, b: "a", c: [] }, "a", "c"), { a: 3, c: [] }); + assert.deepEqual(pluck({ a: 3, b: "a", c: [] }, "a", "b", "c"), { a: 3, b: "a", c: [] }); + }); + QUnit.test("shallowEqual: simple valid cases", function (assert) { assert.ok(shallowEqual({}, {})); assert.ok(shallowEqual({ a: 1 }, { a: 1 })); From 7a8052efd8b1cdd01f3944516d6d487a32e2db5d Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 23 Jun 2022 07:10:43 +0000 Subject: [PATCH 11/50] [IMP] web: concurrency utils: add Deferred Part-of: odoo/odoo#92475 --- addons/web/static/src/core/utils/concurrency.js | 15 +++++++++++++++ .../static/tests/core/utils/concurrency_tests.js | 16 +++++++++++++++- addons/web/static/tests/helpers/utils.js | 13 +++---------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/addons/web/static/src/core/utils/concurrency.js b/addons/web/static/src/core/utils/concurrency.js index b673cb6b8b4..9fa431c121a 100644 --- a/addons/web/static/src/core/utils/concurrency.js +++ b/addons/web/static/src/core/utils/concurrency.js @@ -154,3 +154,18 @@ export class Race { return this.currentProm; } } + +/** + * Deferred is basically a resolvable/rejectable extension of Promise. + */ +export class Deferred { + constructor() { + let resolve; + let reject; + const prom = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return Object.assign(prom, { resolve, reject }); + } +} diff --git a/addons/web/static/tests/core/utils/concurrency_tests.js b/addons/web/static/tests/core/utils/concurrency_tests.js index bf61402ee69..b8bbc888188 100644 --- a/addons/web/static/tests/core/utils/concurrency_tests.js +++ b/addons/web/static/tests/core/utils/concurrency_tests.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { Mutex, KeepLast, Race } from "@web/core/utils/concurrency"; +import { Deferred, Mutex, KeepLast, Race } from "@web/core/utils/concurrency"; import { nextTick, makeDeferred } from "../../helpers/utils"; QUnit.module("utils", () => { @@ -312,4 +312,18 @@ QUnit.module("utils", () => { assert.verifySteps(["ok (44)"]); assert.strictEqual(race.getCurrentProm(), null); }); + + QUnit.test("Deferred: basic use", async function (assert) { + const def1 = new Deferred(); + def1.then((v) => assert.step(`ok (${v})`)); + def1.resolve(44); + await nextTick(); + assert.verifySteps(["ok (44)"]); + + const def2 = new Deferred(); + def2.catch((v) => assert.step(`ko (${v})`)); + def2.reject(44); + await nextTick(); + assert.verifySteps(["ko (44)"]); + }); }); diff --git a/addons/web/static/tests/helpers/utils.js b/addons/web/static/tests/helpers/utils.js index 8354eaafe65..0abdc2ee4da 100644 --- a/addons/web/static/tests/helpers/utils.js +++ b/addons/web/static/tests/helpers/utils.js @@ -2,9 +2,10 @@ import { browser } from "@web/core/browser/browser"; import { isMacOS } from "@web/core/browser/feature_detection"; +import { download } from "@web/core/network/download"; +import { Deferred } from "@web/core/utils/concurrency"; import { patch, unpatch } from "@web/core/utils/patch"; import { registerCleanup } from "./cleanup"; -import { download } from "@web/core/network/download"; const { App, onMounted, onPatched, useComponent } = owl; @@ -150,15 +151,7 @@ export async function nextTick() { } export function makeDeferred() { - /** @type {(value:any)=>void} */ - let resolve; - /** @type {(reason?:any)=>void} */ - let reject; - const prom = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return Object.assign(prom, { resolve, reject }); + return new Deferred(); } function findElement(el, selector) { From e5152781cc3e9b64cf8d7d153f3c0a00dff8a786 Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Thu, 23 Jun 2022 07:16:48 +0000 Subject: [PATCH 12/50] [IMP] web: debounce: support "animationFrame" delay By passing "animationFrame" as delay value, the debounced function will be called just before the next animation frame following the moment if has been effectively called. Part-of: odoo/odoo#92475 --- addons/web/static/src/core/utils/timing.js | 33 +++++++++++-------- .../web/static/tests/core/ui_service_tests.js | 2 +- .../static/tests/core/utils/timing_tests.js | 18 +++++++++- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js index 5d9101335fa..7cc64925030 100644 --- a/addons/web/static/src/core/utils/timing.js +++ b/addons/web/static/src/core/utils/timing.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { browser } from "../browser/browser"; +import { browser } from "@web/core/browser/browser"; /** * Creates a version of the function where only the last call between two @@ -33,7 +33,7 @@ export function throttleForAnimation(func) { /** * Creates and returns a new debounced version of the passed function (func) - * which will postpone its execution until after wait milliseconds + * which will postpone its execution until after 'delay' milliseconds * have elapsed since the last time it was invoked. The debounced function * will return a Promise that will be resolved when the function (func) * has been fully executed. If `immediate` is passed, trigger the function @@ -41,26 +41,31 @@ export function throttleForAnimation(func) { * * @template T the return type of the original function * @param {(...args) => T} func the function to debounce - * @param {number} wait how long should elapse before the function is called + * @param {number | "animationFrame"} delay how long should elapse before the function + * is called. If 'animationFrame' is given instead of a number, 'requestAnimationFrame' + * will be used instead of 'setTimeout'. * @param {boolean} [immediate=false] whether the function should be called on * the leading edge instead of the trailing edge. * @returns {{ (...args): Promise, cancel: () => void }} the debounced function */ -export function debounce(func, wait, immediate = false) { - let timeout; +export function debounce(func, delay, immediate = false) { + let handle; const funcName = func.name ? func.name + " (debounce)" : "debounce"; + const useAnimationFrame = delay === "animationFrame"; + const setFnName = useAnimationFrame ? "requestAnimationFrame" : "setTimeout"; + const clearFnName = useAnimationFrame ? "cancelAnimationFrame" : "clearTimeout"; return Object.assign( { [funcName](...args) { return new Promise((resolve) => { - const callNow = immediate && !timeout; - browser.clearTimeout(timeout); - timeout = browser.setTimeout(() => { - timeout = null; + const callNow = immediate && !handle; + browser[clearFnName](handle); + handle = browser[setFnName](() => { + handle = null; if (!immediate) { Promise.resolve(func.apply(this, args)).then(resolve); } - }, wait); + }, delay); if (callNow) { Promise.resolve(func.apply(this, args)).then(resolve); } @@ -69,7 +74,7 @@ export function debounce(func, wait, immediate = false) { }[funcName], { cancel() { - browser.clearTimeout(timeout); + browser[clearFnName](handle); }, } ); @@ -82,10 +87,10 @@ export function debounce(func, wait, immediate = false) { * @deprecated this function has behaviour that is unexpected considering its * name, prefer _.throttle until this function is rewritten * @param {Function} func - * @param {number} wait + * @param {number} delay * @returns {Function} */ -export function throttle(func, wait) { +export function throttle(func, delay) { let waiting = false; const funcName = func.name ? func.name + " (throttle)" : "throttle"; return { @@ -96,7 +101,7 @@ export function throttle(func, wait) { browser.setTimeout(function () { waiting = false; func.call(context, ...args); - }, wait); + }, delay); } }, }[funcName]; diff --git a/addons/web/static/tests/core/ui_service_tests.js b/addons/web/static/tests/core/ui_service_tests.js index a528702c9f8..3a145436bef 100644 --- a/addons/web/static/tests/core/ui_service_tests.js +++ b/addons/web/static/tests/core/ui_service_tests.js @@ -14,7 +14,7 @@ let browser; let baseConfig; let BlockUI, props; -QUnit.module("UI", { +QUnit.module("UI service", { async beforeEach() { target = getFixture(); serviceRegistry.add("ui", uiService); diff --git a/addons/web/static/tests/core/utils/timing_tests.js b/addons/web/static/tests/core/utils/timing_tests.js index 87a1746062c..db0f03d4042 100644 --- a/addons/web/static/tests/core/utils/timing_tests.js +++ b/addons/web/static/tests/core/utils/timing_tests.js @@ -74,7 +74,7 @@ QUnit.module("utils", () => { assert.step("myFunc"); return 42; }; - const myDebouncedFunc = debounce(myFunc, 3000, true); + const myDebouncedFunc = debounce(myFunc, 3000, { immediate: true }); myDebouncedFunc().then((x) => { assert.step("resolved " + x); }); @@ -98,6 +98,22 @@ QUnit.module("utils", () => { assert.verifySteps(["resolved 42"]); }); + QUnit.test("debounce with 'animationFrame' delay", async function (assert) { + const { execRegisteredTimeouts } = mockTimeout(); + const execRegisteredAnimationFrames = mockAnimationFrame(); + const myFunc = () => { + assert.step("myFunc"); + }; + debounce(myFunc, "animationFrame")(); + assert.verifySteps([]); + + execRegisteredTimeouts(); // should have no effect as we wait for the animation frame + assert.verifySteps([]); + + execRegisteredAnimationFrames(); // should call the function + assert.verifySteps(["myFunc"]); + }); + QUnit.test("debounced call can be cancelled", async function (assert) { assert.expect(3); const { execRegisteredTimeouts } = mockTimeout(); From ebb410accbe3aac051e73031384f640a40544efc Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Thu, 23 Jun 2022 06:15:26 +0000 Subject: [PATCH 13/50] [IMP] web: introduce useSortable hook This hook offers an alternative to jQuery UI sortable and draggable. It's a step in the way to removing jQuery UI. This hook will be used by the new owl list and kanban views. Part-of: odoo/odoo#92475 --- addons/web/static/src/core/utils/sortable.js | 552 ++++++++++++++++++ addons/web/static/src/core/utils/ui.js | 42 ++ .../web/static/tests/core/utils/ui_tests.js | 277 +++++++++ 3 files changed, 871 insertions(+) create mode 100644 addons/web/static/src/core/utils/sortable.js create mode 100644 addons/web/static/tests/core/utils/ui_tests.js diff --git a/addons/web/static/src/core/utils/sortable.js b/addons/web/static/src/core/utils/sortable.js new file mode 100644 index 00000000000..e3c78a3f694 --- /dev/null +++ b/addons/web/static/src/core/utils/sortable.js @@ -0,0 +1,552 @@ +/** @odoo-module **/ + +import { clamp } from "@web/core/utils/numbers"; +import { debounce } from "@web/core/utils/timing"; + +const { useEffect, useEnv, useExternalListener, onWillUnmount } = owl; + +/** + * @typedef SortableParams + * + * MANDATORY + * + * @property {{ el: HTMLElement | null }} ref + * @property {string} elements defines sortable elements + * + * OPTIONAL + * + * @property {boolean | () => boolean} [enable] whether the sortable system should + * be enabled. + * @property {string | () => string} [groups] defines parent groups of sortable + * elements. This allows to add `onGroupEnter` and `onGroupLeave` callbacks to + * work on group elements during the dragging sequence. + * @property {string | () => string} [handle] additional selector for when the dragging + * sequence must be initiated when dragging on a certain part of the element. + * @property {boolean | () => boolean} [connectGroups] whether elements can be dragged + * accross different parent groups. Note that it requires a `groups` param to work. + * @property {string | () => string} [cursor] cursor style during the dragging sequence. + * + * HANDLERS (also optional) + * + * @property {(group: HTMLElement | null, element: HTMLElement) => any} [onStart] + * called when a dragging sequence is initiated. + * @property {(element: HTMLElement) => any} [onElementEnter] called when the cursor + * enters another sortable element. + * @property {(element: HTMLElement) => any} [onElementLeave] called when the cursor + * leaves another sortable element. + * @property {(group: HTMLElement) => any} [onGroupEnter] (if a `groups` is specified): + * will be called when the cursor enters another group element. + * @property {(group: HTMLElement) => any} [onGroupLeave] (if a `groups` is specified): + * will be called when the cursor leaves another group element. + * @property {(group: HTMLElement | null, element: HTMLElement) => any} [onStop] + * called when the dragging sequence ends, regardless of the reason. + * @property {(params: DropParams) => any} [onDrop] called when the dragging sequence + * ends on a mouseup action AND the dragged element has been moved elsewhere. The + * callback will be given an object with any useful element regarding the new position + * of the dragged element (@see DropParams ). + */ + +/** + * @typedef DropParams + * @property {HTMLElement} element + * @property {HTMLElement | null} group + * @property {HTMLElement | null} previous + * @property {HTMLElement | null} next + * @property {HTMLElement | null} parent + */ + +const LEFT_CLICK = 0; +const MANDATORY_SORTABLE_PARAMS = ["ref", "elements"]; +const SORTABLE_PARAMS = { + enable: ["boolean", "function"], + ref: ["object"], + elements: ["string"], + groups: ["string", "function"], + handle: ["string", "function"], + connectGroups: ["boolean", "function"], + cursor: ["string"], +}; + +/** + * Cancels the default behavior and propagation of a given event. + * @param {Event} ev + */ +function cancelEvent(ev) { + ev.stopPropagation(); + ev.stopImmediatePropagation(); + ev.preventDefault(); +} + +/** + * @param {SortableParams} params + * @returns {[string, string | boolean][]} + */ +function computeParams(params) { + const computedParams = { enable: true }; + for (const prop in SORTABLE_PARAMS) { + if (prop in params) { + computedParams[prop] = params[prop]; + if (typeof params[prop] === "function") { + computedParams[prop] = computedParams[prop](); + } + } + } + return Object.entries(computedParams); +} + +/** + * Converts a CSS pixel value to a number, removing the 'px' part. + * @param {string} val + * @returns {number} + */ +function cssValueToNumber(val) { + return Number(val.slice(0, -2)); +} + +/** + * Basic error builder for the sortable hook. + * @param {string} reason + * @returns {Error} + */ +function sortableError(reason) { + return new Error(`Unable to use sortable feature: ${reason}.`); +} + +/** + * Sortable feature hook. + * + * This hook needs 2 things to work: + * + * 1) a `ref` object (@see owl.useRef) which will be used as the root element to + * calculate boundaries of dragged elements; + * + * 2) an `elements` selector string or function that will determine which elements + * are sortable in the reference element. + * + * All other parameters are optional and define the constraints of the dragged elements + * (and the appearance of the cursor during a dragging sequence), or the different + * available handlers triggered during the drag sequence. + * @see SortableParams + * + * @param {SortableParams} params + */ +export function useSortable(params) { + const env = useEnv(); + const { ref } = params; + /** @type {(() => any)[]} */ + const cleanups = []; + + // Basic error handling asserting that the parameters are valid. + for (const prop in SORTABLE_PARAMS) { + if (params[prop] && !SORTABLE_PARAMS[prop].includes(typeof params[prop])) { + throw sortableError(`invalid type for property "${prop}" in parameters`); + } else if (!params[prop] && MANDATORY_SORTABLE_PARAMS.includes(prop)) { + throw sortableError(`missing required property "${prop}" in parameters`); + } + } + + /** + * Stores the current element selector. + * @type {string | null} + */ + let groupSelector = null; + /** + * Stores the current group selector (optional). + * @type {string | null} + */ + let elementSelector = null; + /** + * Stores the full selector used to initiate a drag sequence. + * @type {string | null} + */ + let fullSelector = null; + + /** + * Stores the style of the cursor, if defined. + * @type {string | null} + */ + let cursor = null; + /** + * Stores whether the sortable elements can be dragged in different groups. + * @type {boolean} + */ + let connectGroups = false; + /** + * Stores the position and dimensions of the confining element (ref or + * parent). + * @type {DOMRect | null} + */ + let currentContainerRect = null; + + /** + * Stores the current dragged element. + * @type {HTMLElement | null} + */ + let currentElement = null; + /** + * Stores the dimensions and position of the dragged element. + * @type {DOMRect | null} + */ + let currentElementRect = null; + /** + * Stores the group in which the current element originated. + * @type {HTMLElement | null} + */ + let currentGroup = null; + /** + * Stores the ghost element taking place of the actual dragged element. + * @type {HTMLElement | null} + */ + let ghost = null; + + /** + * Stores whether a drag sequence can be initiated. + * This is determined by both the given ref being in the document and the + * `setup` function returning the required params (namely: `elements`). + * @type {boolean} + */ + let enabled = false; + /** + * Stores whether a drag sequence has been initiated. + * @type {boolean} + */ + let started = false; + + /** + * These 2 variables store the initial offset between the initial mousedown + * position and the top-left corner of the dragged element. + */ + /** @type {number} */ + let offsetX = 0; + /** @type {number} */ + let offsetY = 0; + + /** + * Adds an event listener to be cleaned up after the next drag sequence + * has stopped. An additionnal `timeout` param allows the handler to be + * delayed after a timeout. + * @param {EventTarget} el + * @param {string} event + * @param {(...args: any[]) => any} callback + * @param {boolean | Record} [options] + */ + const addListener = (el, event, callback, options) => { + el.addEventListener(event, callback, options); + cleanups.push(() => el.removeEventListener(event, callback, options)); + }; + + /** + * Adds style to an element to be cleaned up after the next drag sequence has + * stopped. + * @param {HTMLElement} el + * @param {Record} style + */ + const addStyle = (el, style) => { + const originalStyle = el.getAttribute("style"); + cleanups.push(() => + originalStyle ? el.setAttribute("style", originalStyle) : el.removeAttribute("style") + ); + for (const key in style) { + el.style[key] = style[key]; + } + }; + + /** + * Safely executes a handler from the `params`, so that the drag sequence can + * be interrupted if an error occurs. + * @param {string} callbackName + * @param {...any} args + */ + const execHandler = (callbackName, ...args) => { + if (typeof params[callbackName] === "function") { + try { + params[callbackName](...args); + } catch (err) { + dragStop(true, true); + throw err; + } + } + }; + + /** + * Element "mouseenter" event handler. + * @param {MouseEvent} ev + */ + const onElementMouseenter = (ev) => { + const element = ev.currentTarget; + if (connectGroups || !groupSelector || currentGroup === element.closest(groupSelector)) { + const pos = ghost.compareDocumentPosition(element); + if (pos === 2 /* BEFORE */) { + element.before(ghost); + } else if (pos === 4 /* AFTER */) { + element.after(ghost); + } + } + execHandler("onElementEnter", element); + }; + + /** + * Element "mouseleave" event handler. + * @param {MouseEvent} ev + */ + const onElementMouseleave = (ev) => { + const element = ev.currentTarget; + execHandler("onElementLeave", element); + }; + + /** + * Group "mouseenter" event handler. + * @param {MouseEvent} ev + */ + const onGroupMouseenter = (ev) => { + const group = ev.currentTarget; + group.appendChild(ghost); + execHandler("onGroupEnter", group); + }; + + /** + * Group "mouseleave" event handler. + * @param {MouseEvent} ev + */ + const onGroupMouseleave = (ev) => { + const group = ev.currentTarget; + execHandler("onGroupLeave", group); + }; + + /** + * Window "keydown" event handler. + * @param {KeyboardEvent} ev + */ + const onKeydown = (ev) => { + if (!enabled || !started) { + return; + } + switch (ev.key) { + case "Escape": + case "Tab": { + cancelEvent(ev); + dragStop(true); + } + } + }; + + /** + * Global (= ref) "mousedown" event handler. + * @param {MouseEvent} ev + */ + const onMousedown = (ev) => { + // A drag sequence can still be in progress if the mouseup occurred + // outside of the window. + dragStop(true); + + if (ev.button !== LEFT_CLICK || !enabled || !ev.target.closest(fullSelector)) { + return; + } + + currentElement = ev.target.closest(elementSelector); + currentGroup = groupSelector && ev.target.closest(groupSelector); + offsetX = ev.clientX; + offsetY = ev.clientY; + }; + + /** + * Window "mousemove" event handler. + * @param {MouseEvent} ev + */ + const onMousemove = (ev) => { + if (!enabled || !currentElement) { + return; + } + if (started) { + // Updates the position of the dragged element. + currentElement.style.left = `${clamp( + ev.clientX - offsetX, + currentContainerRect.x, + currentContainerRect.x + currentContainerRect.width - currentElementRect.width + )}px`; + currentElement.style.top = `${clamp( + ev.clientY - offsetY, + currentContainerRect.y, + currentContainerRect.y + currentContainerRect.height - currentElementRect.height + )}px`; + } else { + dragStart(); + } + }; + + /** + * Window "mouseup" event handler. + */ + const onMouseup = () => dragStop(false); + + /** + * Main entry function to start a drag sequence. + */ + const dragStart = () => { + started = true; + + // Calculates the bounding rectangles of the current element, and of the + // container element (`parentElement` or `ref.el`). + const container = connectGroups || !groupSelector ? ref.el : currentGroup; + const containerStyle = getComputedStyle(container); + const [pleft, pright, ptop, pbottom] = [ + "padding-left", + "padding-right", + "padding-top", + "padding-bottom", + ].map((prop) => cssValueToNumber(containerStyle.getPropertyValue(prop))); + + currentElementRect = currentElement.getBoundingClientRect(); + currentContainerRect = container.getBoundingClientRect(); + const { x, y, width, height } = currentElementRect; + + // Reduces the container's dimensions according to its padding. + currentContainerRect.x += pleft; + currentContainerRect.width -= pleft + pright; + currentContainerRect.y += ptop; + currentContainerRect.height -= ptop + pbottom; + + // Prepares the ghost element + ghost = currentElement.cloneNode(true); + ghost.style.visibility = "hidden"; + cleanups.push(() => ghost.remove()); + + // Binds handlers on eligible groups, if the elements are not confined to + // their parents and a 'groupSelector' has been provided. + if (connectGroups && groupSelector) { + for (const siblingGroup of ref.el.querySelectorAll(groupSelector)) { + addListener(siblingGroup, "mouseenter", onGroupMouseenter); + addListener(siblingGroup, "mouseleave", onGroupMouseleave); + addStyle(siblingGroup, { "pointer-events": "auto" }); + } + } + + // Binds handlers on eligible elements + for (const siblingElement of ref.el.querySelectorAll(elementSelector)) { + if (siblingElement !== currentElement && siblingElement !== ghost) { + addListener(siblingElement, "mouseenter", onElementMouseenter); + addListener(siblingElement, "mouseleave", onElementMouseleave); + addStyle(siblingElement, { "pointer-events": "auto" }); + } + } + + execHandler("onStart", currentGroup, currentElement); + + // Ghost is initially added right after the current element. + currentElement.after(ghost); + + // Adjusts the offset + offsetX -= x; + offsetY -= y; + + addStyle(currentElement, { + position: "fixed", + "pointer-events": "none", + "z-index": 1000, + width: `${width}px`, + height: `${height}px`, + left: `${x}px`, + top: `${y}px`, + }); + + const bodyStyle = { + "pointer-events": "none", + "user-select": "none", + }; + if (cursor) { + bodyStyle.cursor = cursor; + } + addStyle(document.body, bodyStyle); + }; + + /** + * Main exit function to stop a drag sequence. Note that it can be called + * even if a drag sequence did not start yet to perform a cleanup of all + * current context variables. + * @param {boolean} cancelled + * @param {boolean} [inErrorState] can be set to true when an error + * occurred to avoid falling into an infinite loop if the error + * originated from one of the handlers. + */ + const dragStop = (cancelled, inErrorState) => { + if (started) { + if (!inErrorState) { + execHandler("onStop", currentGroup, currentElement); + const previous = ghost.previousElementSibling; + const next = ghost.nextElementSibling; + if (!cancelled && previous !== currentElement && next !== currentElement) { + execHandler("onDrop", { + group: currentGroup, + element: currentElement, + previous, + next, + parent: groupSelector && ghost.closest(groupSelector), + }); + } + } + } + + // Performes all registered clean-ups. + while (cleanups.length) { + cleanups.pop()(); + } + + currentContainerRect = null; + + currentElement = null; + currentElementRect = null; + currentGroup = null; + ghost = null; + + started = false; + }; + + // OWL HOOKS + + // Effect depending on the params to update them. + useEffect( + (...deps) => { + const actualParams = Object.fromEntries(deps); + enabled = Boolean(ref.el && !env.isSmall && actualParams.enable); + if (!enabled) { + return; + } + + // Selectors + elementSelector = actualParams.elements; + groupSelector = actualParams.groups || null; + if (!elementSelector) { + throw sortableError(`no value found by "elements" selector: ${elementSelector}`); + } + const allSelectors = [elementSelector]; + cursor = actualParams.cursor; + if (groupSelector) { + allSelectors.unshift(groupSelector); + } + if (actualParams.handle) { + allSelectors.push(actualParams.handle); + } + fullSelector = allSelectors.join(" "); + + // Connection accross groups + connectGroups = actualParams.connectGroups; + }, + () => computeParams(params) + ); + // Effect depending on the `ref.el` to add triggering mouse events listener. + useEffect( + (el) => { + if (el) { + el.addEventListener("mousedown", onMousedown); + return () => el.removeEventListener("mousedown", onMousedown); + } + }, + () => [ref.el] + ); + // Other global mouse event listeners. + const debouncedMousemove = debounce(onMousemove, "animationFrame", true); + useExternalListener(window, "mousemove", debouncedMousemove); + useExternalListener(window, "mouseup", onMouseup); + useExternalListener(window, "keydown", onKeydown, true); + onWillUnmount(() => dragStop(true)); +} diff --git a/addons/web/static/src/core/utils/ui.js b/addons/web/static/src/core/utils/ui.js index 418d04295e2..79c7381ffe2 100644 --- a/addons/web/static/src/core/utils/ui.js +++ b/addons/web/static/src/core/utils/ui.js @@ -15,3 +15,45 @@ export function getVisibleElements(activeElement, selector) { } return visibleElements; } + +// ----------------------------------------------------------------------------- +// Get Tabable Elements +// ----------------------------------------------------------------------------- +// TODISCUSS: +// - leave the following in this file ? +// - redefine this selector in tests env with ":not(#qunit *)" ? + +// Following selector is based on this spec: https://html.spec.whatwg.org/multipage/interaction.html#dom-tabindex +let TABABLE_SELECTOR = "[tabindex], a, area, button, frame, iframe, input, object, select, textarea, details > summary:nth-child(1)," + .split(",") + .join(':not([tabindex="-1"]):not(:disabled),'); +TABABLE_SELECTOR = TABABLE_SELECTOR.slice(0, TABABLE_SELECTOR.length - 1); + +export function getTabableElements(container = document.body) { + const elements = container.querySelectorAll(TABABLE_SELECTOR); + const byTabIndex = {}; + for (const el of [...elements]) { + if (!byTabIndex[el.tabIndex]) { + byTabIndex[el.tabIndex] = []; + } + byTabIndex[el.tabIndex].push(el); + } + + const withTabIndexZero = byTabIndex[0] || []; + delete byTabIndex[0]; + return [...Object.values(byTabIndex).flat(), ...withTabIndexZero]; +} + +export function getNextTabableElement(container = document.body) { + const tabableElements = getTabableElements(container); + const index = tabableElements.indexOf(document.activeElement); + return index === -1 ? tabableElements[0] : tabableElements[index + 1] || null; +} + +export function getPreviousTabableElement(container = document.body) { + const tabableElements = getTabableElements(container); + const index = tabableElements.indexOf(document.activeElement); + return index === -1 + ? tabableElements[tabableElements.length - 1] + : tabableElements[index - 1] || null; +} diff --git a/addons/web/static/tests/core/utils/ui_tests.js b/addons/web/static/tests/core/utils/ui_tests.js new file mode 100644 index 00000000000..61e27c8a429 --- /dev/null +++ b/addons/web/static/tests/core/utils/ui_tests.js @@ -0,0 +1,277 @@ +/** @odoo-module **/ + +import { dragAndDrop, getFixture, mount, nextTick } from "@web/../tests/helpers/utils"; +import { useSortable } from "@web/core/utils/sortable"; + +const { Component, reactive, useRef, useState, xml } = owl; + +let target; +QUnit.module("UI", ({ beforeEach }) => { + beforeEach(() => (target = getFixture())); + + QUnit.module("Sortable hook"); + + QUnit.test("Parameters error handling", async (assert) => { + assert.expect(8); + + const mountListAndAssert = async (setupList, shouldThrow) => { + class List extends Component { + setup() { + setupList(); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + let err; + await mount(List, target).catch((e) => (err = e)); + + assert.ok( + shouldThrow ? err : !err, + `An error should${shouldThrow ? "" : "n't"} have been thrown when mounting.` + ); + }; + + // Incorrect params + await mountListAndAssert(() => { + useSortable({}); + }, true); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + elements: ".item", + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + elements: ".item", + groups: ".list", + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + setup: () => ({ elements: ".item" }), + }); + }, true); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + elements: () => ".item", + }); + }, true); + + // Correct params + await mountListAndAssert(() => { + useSortable({ + ref: {}, + elements: ".item", + enable: false, + }); + }, false); + await mountListAndAssert(() => { + useSortable({ + ref: useRef("root"), + elements: ".item", + connectGroups: () => true, + }); + }, false); + }); + + QUnit.test("Simple sorting in single group", async (assert) => { + assert.expect(19); + + class List extends Component { + setup() { + useSortable({ + ref: useRef("root"), + elements: ".item", + onStart(group, element) { + assert.step("start"); + assert.notOk(group); + assert.strictEqual(element.innerText, "1"); + }, + onElementEnter(element) { + assert.step("elemententer"); + assert.strictEqual(element.innerText, "2"); + }, + onStop(group, element) { + assert.step("stop"); + assert.notOk(group); + assert.strictEqual(element.innerText, "1"); + assert.containsN(target, ".item", 4); + }, + onDrop({ group, element, previous, next, parent }) { + assert.step("drop"); + assert.notOk(group); + assert.strictEqual(element.innerText, "1"); + assert.strictEqual(previous.innerText, "2"); + assert.strictEqual(next.innerText, "3"); + assert.notOk(parent); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + + assert.containsN(target, ".item", 3); + assert.verifySteps([]); + + // First item after 2nd item + await dragAndDrop(".item:first-child", ".item:nth-child(2)"); + + assert.containsN(target, ".item", 3); + assert.verifySteps(["start", "elemententer", "stop", "drop"]); + }); + + QUnit.test("Simple sorting in multiple groups", async (assert) => { + assert.expect(20); + + class List extends Component { + setup() { + useSortable({ + ref: useRef("root"), + elements: ".item", + groups: ".list", + connectGroups: true, + onStart(group, element) { + assert.step("start"); + assert.hasClass(group, "list2"); + assert.strictEqual(element.innerText, "2 1"); + }, + onGroupEnter(group) { + assert.step("groupenter"); + assert.hasClass(group, "list1"); + }, + onStop(group, element) { + assert.step("stop"); + assert.hasClass(group, "list2"); + assert.strictEqual(element.innerText, "2 1"); + }, + onDrop({ group, element, previous, next, parent }) { + assert.step("drop"); + assert.hasClass(group, "list2"); + assert.strictEqual(element.innerText, "2 1"); + assert.strictEqual(previous.innerText, "1 3"); + assert.notOk(next); + assert.hasClass(parent, "list1"); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + + assert.containsN(target, ".list", 3); + assert.containsN(target, ".item", 9); + assert.verifySteps([]); + + // First item of 2nd list appended to first list + await dragAndDrop(".list2 .item:first-child", ".list1"); + + assert.containsN(target, ".list", 3); + assert.containsN(target, ".item", 9); + assert.verifySteps(["start", "groupenter", "stop", "drop"]); + }); + + QUnit.test("Dynamically disable sortable feature", async (assert) => { + assert.expect(4); + + let state = reactive({ enableSortable: true }); + class List extends Component { + setup() { + this.state = useState(state); + useSortable({ + ref: useRef("root"), + elements: ".item", + enable: () => this.state.enableSortable, + onStart() { + assert.step("start"); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target); + + assert.verifySteps([]); + + // First item before last item + await dragAndDrop(".item:first-child", ".item:last-child"); + + // Drag should have occurred + assert.verifySteps(["start"]); + + state.enableSortable = false; + await nextTick(); + + // First item before last item + await dragAndDrop(".item:first-child", ".item:last-child"); + + // Drag shouldn't have occurred + assert.verifySteps([]); + }); + + QUnit.test("Disabled in small environment", async (assert) => { + assert.expect(2); + + class List extends Component { + setup() { + useSortable({ + ref: useRef("root"), + elements: ".item", + onStart() { + throw new Error("Shouldn't start the sortable feature."); + }, + }); + } + } + + List.template = xml` +
+
    +
  • +
+
`; + + await mount(List, target, { env: { isSmall: true } }); + + assert.containsN(target, ".item", 3); + + // First item after 2nd item + await dragAndDrop(".item:first-child", ".item:nth-child(2)"); + + assert.ok(true, "No drag sequence should have been initiated"); + }); +}); From a8b13c24815b4a8bd74ca01274f1d50d236ee7ab Mon Sep 17 00:00:00 2001 From: "Michael (mcm)" Date: Thu, 23 Jun 2022 06:29:12 +0000 Subject: [PATCH 14/50] [IMP] web: add AutoComplete component This commit introduces an AutoComplete component, which displays an input and reacts to input events to display a list of suggestions in a dropdown. The main usecase for this component is the Many2One field. Part-of: odoo/odoo#92475 --- .../src/core/autocomplete/autocomplete.js | 318 ++++++++++++++++++ .../src/core/autocomplete/autocomplete.scss | 11 + .../src/core/autocomplete/autocomplete.xml | 59 ++++ addons/web/static/src/core/utils/timing.js | 60 ++-- .../static/tests/core/autocomplete_tests.js | 204 +++++++++++ 5 files changed, 629 insertions(+), 23 deletions(-) create mode 100644 addons/web/static/src/core/autocomplete/autocomplete.js create mode 100644 addons/web/static/src/core/autocomplete/autocomplete.scss create mode 100644 addons/web/static/src/core/autocomplete/autocomplete.xml create mode 100644 addons/web/static/tests/core/autocomplete_tests.js diff --git a/addons/web/static/src/core/autocomplete/autocomplete.js b/addons/web/static/src/core/autocomplete/autocomplete.js new file mode 100644 index 00000000000..d90b99c5eec --- /dev/null +++ b/addons/web/static/src/core/autocomplete/autocomplete.js @@ -0,0 +1,318 @@ +/** @odoo-module **/ + +import { useService } from "@web/core/utils/hooks"; +import { useDebounced } from "@web/core/utils/timing"; +import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; +import { usePosition } from "@web/core/position_hook"; + +const { Component, useExternalListener, useRef, useState, useEffect } = owl; + +export class AutoComplete extends Component { + setup() { + this.nextSourceId = 0; + this.nextOptionId = 0; + this.sources = []; + + this.state = useState({ + navigationRev: 0, + optionsRev: 0, + open: false, + activeSourceOption: null, + }); + + this.inputRef = useRef("input"); + this.root = useRef("root"); + this.debouncedOnInput = useDebounced(this.onInput, this.constructor.timeout); + useExternalListener(window, "scroll", this.onWindowScroll, true); + + this.hotkey = useService("hotkey"); + this.hotkeysToRemove = []; + + owl.onWillUpdateProps((nextProps) => { + if (nextProps.forceClose) { + this.inputRef.el.value = nextProps.value; + this.close(); + } + }); + + // position and size + const sourcesListRef = useRef("sourcesList"); + useEffect( + () => { + if (sourcesListRef.el) { + sourcesListRef.el.style.width = + this.inputRef.el.getBoundingClientRect().width + "px"; + } + }, + () => [sourcesListRef.el] + ); + usePosition(() => this.inputRef.el, { + popper: "sourcesList", + }); + } + + get isOpened() { + return this.state.open; + } + + open(useInput = false) { + this.state.open = true; + this.loadSources(useInput); + } + + close() { + this.state.open = false; + this.state.activeSourceOption = null; + } + + loadSources(useInput) { + const sources = []; + const proms = []; + for (const pSource of this.props.sources) { + const source = this.makeSource(pSource); + sources.push(source); + + const options = this.loadOptions( + pSource.options, + useInput ? this.inputRef.el.value.trim() : "" + ); + if (options instanceof Promise) { + source.isLoading = true; + const prom = options.then((options) => { + source.options = options.map((option) => this.makeOption(option)); + source.isLoading = false; + this.state.optionsRev++; + }); + proms.push(prom); + } else { + source.options = options.map((option) => this.makeOption(option)); + } + } + this.sources = sources; + Promise.all(proms).then(() => { + this.navigate(0); + }); + } + loadOptions(options, request) { + if (typeof options === "function") { + return options(request); + } else { + return options; + } + } + makeOption(option) { + return Object.assign(Object.create(option), { + id: ++this.nextOptionId, + }); + } + makeSource(source) { + return { + id: ++this.nextSourceId, + options: [], + isLoading: false, + placeholder: source.placeholder, + optionTemplate: source.optionTemplate, + }; + } + + isActiveSourceOption([sourceIndex, optionIndex]) { + return ( + this.state.activeSourceOption && + this.state.activeSourceOption[0] === sourceIndex && + this.state.activeSourceOption[1] === optionIndex + ); + } + selectOption(indices, params = {}) { + this.inputRef.el.value = ""; + + const option = this.sources[indices[0]].options[indices[1]]; + if (option.unselectable) { + return; + } + + this.props.onSelect(option, { + ...params, + inputValue: this.inputRef.el.value.trim(), + input: this.inputRef.el, + }); + this.close(); + } + + navigate(direction) { + let step = Math.sign(direction); + if (!step) { + this.state.activeSourceOption = null; + step = 1; + } else { + this.state.navigationRev++; + } + + if (this.state.activeSourceOption) { + let [sourceIndex, optionIndex] = this.state.activeSourceOption; + let source = this.sources[sourceIndex]; + + optionIndex += step; + if (0 > optionIndex || optionIndex >= source.options.length) { + sourceIndex += step; + source = this.sources[sourceIndex]; + + while (source && source.isLoading) { + sourceIndex += step; + source = this.sources[sourceIndex]; + } + + if (source) { + optionIndex = step < 0 ? source.options.length - 1 : 0; + } + } + + this.state.activeSourceOption = source ? [sourceIndex, optionIndex] : null; + } else { + let sourceIndex = step < 0 ? this.sources.length - 1 : 0; + let source = this.sources[sourceIndex]; + + while (source && source.isLoading) { + sourceIndex += step; + source = this.sources[sourceIndex]; + } + + if (source) { + const optionIndex = step < 0 ? source.options.length - 1 : 0; + this.state.activeSourceOption = [sourceIndex, optionIndex]; + } + } + } + + onInputBlur() { + const value = this.inputRef.el.value; + if ( + this.props.autoSelect && + this.state.activeSourceOption && + value.length > 0 && + value !== this.props.value + ) { + this.selectOption(this.state.activeSourceOption, { triggeredOnBlur: true }); + } else { + this.props.onBlur({ + inputValue: value, + }); + this.close(); + } + } + onInputClick() { + if (!this.isOpened) { + this.open(this.inputRef.el.value.trim() !== this.props.value); + } else { + this.close(); + } + } + onInputChange() { + this.props.onChange({ + inputValue: this.inputRef.el.value, + }); + } + onInput() { + this.props.onInput({ + inputValue: this.inputRef.el.value, + }); + this.open(true); + } + + onInputKeydown(ev) { + const hotkey = getActiveHotkey(ev); + switch (hotkey) { + case "enter": + if (!this.isOpened || !this.state.activeSourceOption) { + return; + } + this.selectOption(this.state.activeSourceOption); + break; + case "escape": + if (!this.isOpened) { + return; + } + this.close(); + break; + case "tab": + if (!this.isOpened) { + return; + } + if ( + this.props.autoSelect && + this.state.activeSourceOption && + (this.state.navigationRev > 0 || this.inputRef.el.value.length > 0) + ) { + this.selectOption(this.state.activeSourceOption); + } + this.close(); + return; + case "arrowup": + this.navigate(-1); + if (!this.isOpened) { + this.open(true); + } + break; + case "arrowdown": + this.navigate(+1); + if (!this.isOpened) { + this.open(true); + } + break; + default: + return; + } + + ev.stopPropagation(); + ev.preventDefault(); + } + + onOptionMouseEnter(indices) { + this.state.activeSourceOption = indices; + } + onOptionMouseLeave() { + this.state.activeSourceOption = null; + } + onOptionClick(indices) { + this.selectOption(indices); + } + + onWindowScroll(ev) { + if (this.isOpened && !this.root.el.contains(ev.target)) { + this.close(); + } + } +} +Object.assign(AutoComplete, { + template: "web.AutoComplete", + props: { + value: { type: String }, + id: { type: String, optional: true }, + onSelect: { type: Function }, + sources: { + type: Array, + element: { + type: Object, + shape: { + placeholder: { type: String, optional: true }, + optionTemplate: { type: String, optional: true }, + options: [Array, Function], + }, + }, + }, + placeholder: { type: String, optional: true }, + autoSelect: { type: Boolean, optional: true }, + onInput: { type: Function, optional: true }, + onChange: { type: Function, optional: true }, + onBlur: { type: Function, optional: true }, + forceClose: { type: Boolean, optional: true }, + }, + defaultProps: { + placeholder: "", + autoSelect: false, + onInput: () => {}, + onChange: () => {}, + onBlur: () => {}, + forceClose: false, + }, + timeout: 250, +}); diff --git a/addons/web/static/src/core/autocomplete/autocomplete.scss b/addons/web/static/src/core/autocomplete/autocomplete.scss new file mode 100644 index 00000000000..2f186ac15b1 --- /dev/null +++ b/addons/web/static/src/core/autocomplete/autocomplete.scss @@ -0,0 +1,11 @@ + +.o-autocomplete { + .o-autocomplete--input { + width: 100%; + } + + .o-autocomplete--dropdown-menu { + left: 0; + right: 0; + } +} diff --git a/addons/web/static/src/core/autocomplete/autocomplete.xml b/addons/web/static/src/core/autocomplete/autocomplete.xml new file mode 100644 index 00000000000..2654ff87029 --- /dev/null +++ b/addons/web/static/src/core/autocomplete/autocomplete.xml @@ -0,0 +1,59 @@ + + + + + + + + diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js index 7cc64925030..b40470dab23 100644 --- a/addons/web/static/src/core/utils/timing.js +++ b/addons/web/static/src/core/utils/timing.js @@ -2,6 +2,8 @@ import { browser } from "@web/core/browser/browser"; +const { onWillUnmount, useComponent } = owl; + /** * Creates a version of the function where only the last call between two * animation frames is executed before the browser's next repaint. This @@ -31,6 +33,33 @@ export function throttleForAnimation(func) { ); } +/** + * Returns a function, that, as long as it continues to be invoked, will be + * triggered every N milliseconds. + * + * @deprecated this function has behaviour that is unexpected considering its + * name, prefer _.throttle until this function is rewritten + * @param {Function} func + * @param {number} delay + * @returns {Function} + */ +export function throttle(func, delay) { + let waiting = false; + const funcName = func.name ? func.name + " (throttle)" : "throttle"; + return { + [funcName](...args) { + const context = this; + if (!waiting) { + waiting = true; + browser.setTimeout(function () { + waiting = false; + func.call(context, ...args); + }, delay); + } + }, + }[funcName]; +} + /** * Creates and returns a new debounced version of the passed function (func) * which will postpone its execution until after 'delay' milliseconds @@ -81,28 +110,13 @@ export function debounce(func, delay, immediate = false) { } /** - * Returns a function, that, as long as it continues to be invoked, will be - * triggered every N milliseconds. - * - * @deprecated this function has behaviour that is unexpected considering its - * name, prefer _.throttle until this function is rewritten - * @param {Function} func - * @param {number} delay - * @returns {Function} + * Hook that returns a debounced version of the given function, and cancels + * the potential pending execution on willUnmount. + * @see debounce */ -export function throttle(func, delay) { - let waiting = false; - const funcName = func.name ? func.name + " (throttle)" : "throttle"; - return { - [funcName](...args) { - const context = this; - if (!waiting) { - waiting = true; - browser.setTimeout(function () { - waiting = false; - func.call(context, ...args); - }, delay); - } - }, - }[funcName]; +export function useDebounced(callback, delay) { + const component = useComponent(); + const debounced = debounce(callback.bind(component), delay); + onWillUnmount(() => debounced.cancel()); + return debounced; } diff --git a/addons/web/static/tests/core/autocomplete_tests.js b/addons/web/static/tests/core/autocomplete_tests.js new file mode 100644 index 00000000000..5f1b07d1613 --- /dev/null +++ b/addons/web/static/tests/core/autocomplete_tests.js @@ -0,0 +1,204 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; +import { registry } from "@web/core/registry"; +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { makeTestEnv } from "../helpers/mock_env"; +import { + click, + getFixture, + mount, + patchWithCleanup, + triggerEvent, + triggerEvents, +} from "../helpers/utils"; + +const { Component, useState, xml } = owl; + +const serviceRegistry = registry.category("services"); + +let env; +let target; + +QUnit.module("Components", (hooks) => { + hooks.beforeEach(async () => { + serviceRegistry.add("hotkey", hotkeyService); + env = await makeTestEnv(); + target = getFixture(); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); + }); + + QUnit.module("AutoComplete"); + + QUnit.test("can be rendered", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsOnce(target, ".o-autocomplete"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await click(target, ".o-autocomplete--input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + const options = [...target.querySelectorAll(".o-autocomplete--dropdown-item")]; + assert.deepEqual( + options.map((el) => el.textContent), + ["World", "Hello"] + ); + }); + + QUnit.test("select option", async (assert) => { + class Parent extends Component { + setup() { + this.state = useState({ + value: "Hello", + }); + } + get sources() { + return [ + { + options: [{ label: "World" }, { label: "Hello" }], + }, + ]; + } + onSelect(option) { + this.state.value = option.label; + assert.step(option.label); + } + } + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello"); + + await click(target, ".o-autocomplete--input"); + await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[0]); + assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "World"); + assert.verifySteps(["World"]); + + await click(target, ".o-autocomplete--input"); + await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1]); + assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello"); + assert.verifySteps(["Hello"]); + }); + + QUnit.test("open dropdown on input", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + await triggerEvent(target, ".o-autocomplete--input", "input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("close dropdown on escape keydown", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Escape" }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("scroll outside should close dropdown", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await click(target, ".o-autocomplete--input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, null, "scroll"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("scroll inside should keep dropdown open", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await click(target, ".o-autocomplete--input"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, ".o-autocomplete--dropdown-menu", "scroll"); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + }); + + QUnit.test("losing focus should close dropdown", async (assert) => { + class Parent extends Component {} + Parent.components = { AutoComplete }; + Parent.template = xml` + + `; + + await mount(Parent, target, { env }); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]); + assert.containsOnce(target, ".o-autocomplete--dropdown-menu"); + + await triggerEvent(target, ".o-autocomplete--input", "blur"); + assert.containsNone(target, ".o-autocomplete--dropdown-menu"); + }); +}); From 0096f6f32ebad9a7bfe49cd39b0fccfbeebb305a Mon Sep 17 00:00:00 2001 From: "Lucas Perais (lpe)" Date: Thu, 23 Jun 2022 07:39:41 +0000 Subject: [PATCH 15/50] [IMP] web: add Notebook component This commit introduces a Notebook component, which displays tabs and an active page. The component is configurable to display the notebook vertically (e.g. form views) or horizontally (e.g. kanban examples dialog). Part-of: odoo/odoo#92475 --- .../web/static/src/core/notebook/notebook.js | 160 ++++++++++ .../static/src/core/notebook/notebook.scss | 18 ++ .../web/static/src/core/notebook/notebook.xml | 22 ++ .../web/static/tests/core/notebook_tests.js | 296 ++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 addons/web/static/src/core/notebook/notebook.js create mode 100644 addons/web/static/src/core/notebook/notebook.scss create mode 100644 addons/web/static/src/core/notebook/notebook.xml create mode 100644 addons/web/static/tests/core/notebook_tests.js diff --git a/addons/web/static/src/core/notebook/notebook.js b/addons/web/static/src/core/notebook/notebook.js new file mode 100644 index 00000000000..e6f6bc59942 --- /dev/null +++ b/addons/web/static/src/core/notebook/notebook.js @@ -0,0 +1,160 @@ +/** @odoo-module **/ + +import { scrollTo } from "@web/core/utils/scrolling"; + +const { Component, onWillDestroy, onWillUpdateProps, useEffect, useRef, useState } = owl; + +/** + * A notebook component that will render only the current page and allow + * switching between its pages. + * + * You can also set pages using a template component. Use an array with + * the `pages` props to do such rendering. + * + * Pages can also specify their index in the notebook. + * + * e.g.: + * PageTemplate.template = xml` +

+

`; + + * `pages` could be: + * [ + * { + * Component: PageTemplate, + * props: { + * title: "Some Title 1", + * isVisible: bool, + * text: "Text Content 1", + * }, + * name: 'some_name' // optional + * }, + * { + * Component: PageTemplate, + * props: { + * title: "Some Title 2", + * isVisible: bool, + * text: "Text Content 2", + * }, + * }, + * ] + * + * + * + *

Page Content 1
+ * + * + *
Page Content 2
+ *
+ * + * + * @extends Component + */ + +export class Notebook extends Component { + setup() { + this.activePane = useRef("activePane"); + this.anchorTarget = null; + this.pages = this.computePages(this.props); + this.state = useState({ currentPage: null }); + this.state.currentPage = this.props.defaultPage || this.computeActivePage(); + const onAnchorClicked = this.onAnchorClicked.bind(this); + this.env.bus.addEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked); + useEffect( + () => { + this.props.onPageUpdate(this.state.currentPage); + if (this.anchorTarget) { + const matchingEl = this.activePane.el.querySelector(`#${this.anchorTarget}`); + scrollTo(matchingEl, { isAnchor: true }); + this.anchorTarget = null; + } + }, + () => [this.state.currentPage] + ); + onWillUpdateProps((nextProps) => { + this.pages = this.computePages(nextProps); + this.state.currentPage = this.computeActivePage(); + }); + onWillDestroy(() => { + this.env.bus.removeEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked); + }); + } + + get navItems() { + return this.pages.filter((e) => e[1].isVisible); + } + + get page() { + const page = this.pages.find((e) => e[0] === this.state.currentPage)[1]; + return page.Component && page; + } + + onAnchorClicked(ev) { + if (!this.props.anchors) { + return; + } + const id = ev.detail.detail.id.substring(1); + if (this.props.anchors[id]) { + if (this.state.currentPage !== this.props.anchors[id].target) { + ev.preventDefault(); + ev.detail.detail.originalEv.preventDefault(); + this.anchorTarget = id; + this.state.currentPage = this.props.anchors[id].target; + } + } + } + + computePages(props) { + if (!props.slots && !props.pages) { + return []; + } + const pages = []; + const pagesWithIndex = []; + for (const [k, v] of Object.entries({ ...props.slots, ...props.pages })) { + if (v.props) { + v.isVisible = v.props.isVisible; + v.title = v.props.title; + v.index = v.props.index; + } + const id = v.id || k; + if (v.index) { + pagesWithIndex.push([id, v]); + } else { + pages.push([id, v]); + } + } + for (const page of pagesWithIndex) { + pages.splice(page[1].index, 0, page); + } + return pages; + } + + computeActivePage() { + if (!this.pages.length) { + return null; + } + const current = this.state.currentPage; + if (!current || (current && !this.pages.find((e) => e[0] === current)[1].isVisible)) { + const candidate = this.pages.find((v) => v[1].isVisible); + return candidate ? candidate[0] : null; + } + return current; + } +} + +Notebook.template = "web.Notebook"; +Notebook.defaultProps = { + className: "", + orientation: "horizontal", + onPageUpdate: () => {}, +}; +Notebook.props = { + slots: { type: Object, optional: true }, + pages: { type: Object, optional: true }, + class: { optional: true }, + className: { type: String, optional: true }, + anchors: { type: Object, optional: true }, + defaultPage: { type: String, optional: true }, + orientation: { type: String, optional: true }, + onPageUpdate: { type: Function, optional: true }, +}; diff --git a/addons/web/static/src/core/notebook/notebook.scss b/addons/web/static/src/core/notebook/notebook.scss new file mode 100644 index 00000000000..77553ab1d36 --- /dev/null +++ b/addons/web/static/src/core/notebook/notebook.scss @@ -0,0 +1,18 @@ +.o_notebook { + &.horizontal { + flex-direction: column; + + .nav.nav-tabs { + flex-flow: row nowrap; + } + + } + + &.vertical { + flex-direction: row; + + .nav.nav-pills { + width: max-content; + } + } +} diff --git a/addons/web/static/src/core/notebook/notebook.xml b/addons/web/static/src/core/notebook/notebook.xml new file mode 100644 index 00000000000..2604f6a501a --- /dev/null +++ b/addons/web/static/src/core/notebook/notebook.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/addons/web/static/tests/core/notebook_tests.js b/addons/web/static/tests/core/notebook_tests.js new file mode 100644 index 00000000000..53be41c4b4f --- /dev/null +++ b/addons/web/static/tests/core/notebook_tests.js @@ -0,0 +1,296 @@ +/** @odoo-module **/ + +import { Notebook } from "@web/core/notebook/notebook"; +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { click, getFixture, mount } from "@web/../tests/helpers/utils"; + +const { Component, xml } = owl; +let target; + +QUnit.module("Components", (hooks) => { + hooks.beforeEach(async () => { + target = getFixture(); + }); + + QUnit.module("Notebook"); + + QUnit.test("not rendered if empty slots", async (assert) => { + const env = await makeTestEnv(); + await mount(Notebook, target, { env, props: {} }); + assert.containsNone(target, "div.o_notebook"); + }); + + QUnit.test("notebook with multiple pages given as slots", async (assert) => { + const env = await makeTestEnv(); + + class Parent extends Component {} + Parent.template = xml` + +

About the bird

+

Owls are birds from the order Strigiformes which includes over + 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+ +

TODO find a great secret about OWLs.

+
+
`; + Parent.components = { Notebook }; + + await mount(Parent, target, { env }); + assert.containsOnce(target, "div.o_notebook"); + assert.hasClass( + target.querySelector(".o_notebook"), + "horizontal", + "default orientation is set as horizontal" + ); + assert.hasClass( + target.querySelector(".nav"), + "nav-tabs", + "navigation container uses the right class to display as horizontal tabs" + ); + assert.containsN( + target, + ".o_notebook_headers a.nav-link", + 2, + "navigation link is present for each visible page" + ); + assert.hasClass( + target.querySelector(".o_notebook_headers .nav-item:first-child a"), + "active", + "first page is selected by default" + ); + assert.strictEqual( + target.querySelector(".tab-pane.active").firstElementChild.textContent, + "About the bird", + "first page content is displayed by the notebook" + ); + + await click(target, ".o_notebook_headers .nav-item:nth-child(2) a"); + assert.hasClass( + target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"), + "active", + "second page is now selected" + ); + assert.strictEqual( + target.querySelector(".tab-pane.active").firstElementChild.textContent, + "Their favorite activity: hunting", + "second page content is displayed by the notebook" + ); + }); + + QUnit.test("notebook with defaultPage props", async (assert) => { + const env = await makeTestEnv(); + + class Parent extends Component {} + Parent.template = xml` + +

About the bird

+

Owls are birds from the order Strigiformes which includes over + 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+ +

TODO find a great secret about OWLs.

+
+
`; + Parent.components = { Notebook }; + + await mount(Parent, target, { env }); + assert.containsOnce(target, "div.o_notebook"); + assert.hasClass( + target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"), + "active", + "second page is selected by default" + ); + assert.strictEqual( + target.querySelector(".tab-pane.active").firstElementChild.textContent, + "Their favorite activity: hunting", + "second page content is displayed by the notebook" + ); + }); + + QUnit.test("notebook with defaultPage set on invisible page", async (assert) => { + const env = await makeTestEnv(); + + class Parent extends Component {} + Parent.template = xml` + +

About the bird

+

Owls are birds from the order Strigiformes which includes over + 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+ +

Oooops

+

TODO find a great secret to reveal about OWLs.

+
+
`; + Parent.components = { Notebook }; + + await mount(Parent, target, { env }); + assert.containsOnce(target, "div.o_notebook"); + assert.containsNone( + target, + ".o_notebook_headers .nav-item a.active", + "no tab is selected as the default page is not visible" + ); + assert.containsN( + target, + ".o_notebook_headers a.nav-link", + 2, + "navigation link is only present for visible pages" + ); + assert.strictEqual( + target.querySelector(".tab-pane.active").firstElementChild.textContent, + "Oooops", + "third page content is displayed by the notebook" + ); + }); + + QUnit.test("notebook set vertically", async (assert) => { + const env = await makeTestEnv(); + + class Parent extends Component {} + Parent.template = xml` + +

About the bird

+

Owls are birds from the order Strigiformes which includes over + 200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+
`; + Parent.components = { Notebook }; + + await mount(Parent, target, { env }); + assert.containsOnce(target, "div.o_notebook"); + assert.hasClass( + target.querySelector(".o_notebook"), + "vertical", + "orientation is set as vertical" + ); + assert.hasClass( + target.querySelector(".nav"), + "nav-pills", + "navigation container uses the right class to display as vertical buttons" + ); + }); + + QUnit.test("notebook pages rendered by a template component", async (assert) => { + const env = await makeTestEnv(); + + class NotebookPageRenderer extends Component {} + NotebookPageRenderer.template = xml` +

+

+ `; + + class Parent extends Component { + setup() { + this.pages = [ + { + Component: NotebookPageRenderer, + props: { + index: 1, + isVisible: true, + title: "Page 2", + text: "Second page rendered by a template component", + }, + }, + { + Component: NotebookPageRenderer, + props: { + index: 2, + isVisible: true, + title: "Page 3", + text: "Third page rendered by a template component", + }, + id: "page_three", // required to be set as default page + }, + ]; + } + } + Parent.template = xml` + +

Page 1

+

First page set directly as a slot

+ + +

Page 4

+
+ `; + Parent.components = { Notebook }; + + await mount(Parent, target, { env }); + + assert.containsOnce(target, "div.o_notebook"); + assert.hasClass( + target.querySelector(".o_notebook_headers .nav-item:nth-child(3) a"), + "active", + "third page is selected by default" + ); + + await click(target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a")); + assert.strictEqual( + target.querySelector(".o_notebook_content p").textContent, + "Second page rendered by a template component", + "displayed content corresponds to the current page" + ); + }); + + QUnit.test("each page is different", async (assert) => { + const env = await makeTestEnv(); + + class Page extends Component {} + Page.template = xml`

Coucou

`; + + class Parent extends Component { + setup() { + this.pages = [ + { + Component: Page, + props: { + index: 1, + isVisible: true, + title: "Page 1", + }, + }, + { + Component: Page, + props: { + index: 2, + isVisible: true, + title: "Page 2", + }, + }, + ]; + } + } + Parent.template = xml``; + Parent.components = { Notebook }; + + await mount(Parent, target, { env }); + + const firstPage = target.querySelector("h3"); + assert.ok(firstPage instanceof HTMLElement); + + await click(target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a")); + + const secondPage = target.querySelector("h3"); + assert.ok(firstPage instanceof HTMLElement); + + assert.notEqual(firstPage, secondPage); + }); +}); From ec8ed943fc25d256764fcbba6f8b92216665b251 Mon Sep 17 00:00:00 2001 From: "Michael (mcm)" Date: Thu, 23 Jun 2022 07:40:39 +0000 Subject: [PATCH 16/50] [IMP] web: new DomainSelector in owl This commit introduces the DomainSelector and ModelFieldSelector components. They will especially be used by the owl DomainField. Part-of: odoo/odoo#92475 --- .../core/domain_selector/domain_selector.js | 207 +++++++++ .../core/domain_selector/domain_selector.xml | 21 + .../domain_selector_branch_node.js | 30 ++ .../domain_selector_branch_node.xml | 31 ++ .../domain_selector_branch_operator.js | 20 + .../domain_selector_branch_operator.xml | 27 ++ .../domain_selector_control_panel.js | 39 ++ .../domain_selector_control_panel.xml | 40 ++ .../domain_selector_leaf_node.js | 101 +++++ .../domain_selector_leaf_node.xml | 71 ++++ .../domain_selector_operators.js | 166 ++++++++ .../domain_selector_root_node.js | 33 ++ .../domain_selector_root_node.xml | 53 +++ .../fields/domain_selector_boolean_field.js | 47 +++ .../fields/domain_selector_boolean_field.xml | 13 + .../fields/domain_selector_datetime_field.js | 47 +++ .../fields/domain_selector_datetime_field.xml | 10 + .../fields/domain_selector_field_input.js | 18 + .../fields/domain_selector_field_input.xml | 8 + .../domain_selector_field_input_with_tags.js | 25 ++ .../domain_selector_field_input_with_tags.xml | 31 ++ .../fields/domain_selector_number_field.js | 39 ++ .../fields/domain_selector_number_field.xml | 10 + .../fields/domain_selector_relation_field.js | 29 ++ .../fields/domain_selector_relation_field.xml | 10 + .../fields/domain_selector_selection_field.js | 26 ++ .../domain_selector_selection_field.xml | 18 + .../fields/domain_selector_text_field.js | 32 ++ .../fields/domain_selector_text_field.xml | 15 + .../model_field_selector/model_field_hook.js | 43 ++ .../model_field_selector.js | 120 ++++++ .../model_field_selector.xml | 31 ++ .../model_field_selector_popover.js | 107 +++++ .../model_field_selector_popover.scss | 83 ++++ .../model_field_selector_popover.xml | 59 +++ .../tests/core/domain_selector_tests.js | 342 +++++++++++++++ .../tests/core/model_field_selector_tests.js | 392 ++++++++++++++++++ 37 files changed, 2394 insertions(+) create mode 100644 addons/web/static/src/core/domain_selector/domain_selector.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector.xml create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_branch_node.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_control_panel.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_operators.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_root_node.js create mode 100644 addons/web/static/src/core/domain_selector/domain_selector_root_node.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.xml create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.js create mode 100644 addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.xml create mode 100644 addons/web/static/src/core/model_field_selector/model_field_hook.js create mode 100644 addons/web/static/src/core/model_field_selector/model_field_selector.js create mode 100644 addons/web/static/src/core/model_field_selector/model_field_selector.xml create mode 100644 addons/web/static/src/core/model_field_selector/model_field_selector_popover.js create mode 100644 addons/web/static/src/core/model_field_selector/model_field_selector_popover.scss create mode 100644 addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml create mode 100644 addons/web/static/tests/core/domain_selector_tests.js create mode 100644 addons/web/static/tests/core/model_field_selector_tests.js diff --git a/addons/web/static/src/core/domain_selector/domain_selector.js b/addons/web/static/src/core/domain_selector/domain_selector.js new file mode 100644 index 00000000000..15557df18b0 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector.js @@ -0,0 +1,207 @@ +/** @odoo-module **/ + +import { Domain } from "@web/core/domain"; +import { DomainSelectorRootNode } from "./domain_selector_root_node"; + +const { Component } = owl; + +export class DomainSelector extends Component { + setup() { + this.nextNodeId = 0; + } + + buildTree() { + try { + const domain = new Domain(this.props.value); + const ctx = { + parent: null, + index: 0, + domain: domain.toList(), + get currentElement() { + return ctx.domain[ctx.index]; + }, + next() { + ctx.index++; + }, + getFullDomain() { + return rootNode.computeDomain().toString(); + }, + }; + + const rootNode = this.makeRootNode(ctx); + ctx.parent = rootNode; + this.traverseNode(ctx); + + return ctx.parent; + } catch (_e) { + // WOWL TODO: rethrow error when not the expected type + return false; + } + } + + traverseNode(ctx) { + if (ctx.index < ctx.domain.length) { + if (typeof ctx.currentElement === "string" && ["&", "|"].includes(ctx.currentElement)) { + this.traverseBranchNode(ctx); + } else { + this.traverseLeafNode(ctx); + } + } + } + traverseBranchNode(ctx) { + if (ctx.parent.type !== "branch" || ctx.parent.operator !== ctx.currentElement) { + const node = this.makeBranchNode(ctx, ctx.currentElement, []); + ctx.parent.operands.push(node); + ctx = Object.assign(Object.create(ctx), { parent: node }); + } + ctx.next(); + this.traverseNode(ctx); + ctx.next(); + this.traverseNode(ctx); + } + traverseLeafNode(ctx) { + const condition = ctx.currentElement; + const [leftOperand, operator, rightOperand] = condition; + const node = this.makeLeafNode(ctx, operator, [leftOperand, rightOperand]); + ctx.parent.operands.push(node); + } + + makeBranchNode(ctx, operator, operands) { + const updateDomain = () => this.props.update(ctx.getFullDomain()); + const makeFakeNode = this.makeFakeNode.bind(this); + + return { + type: "branch", + id: this.nextNodeId++, + operator, + operands, + computeDomain() { + return Domain.combine( + this.operands.map((operand) => operand.computeDomain()), + this.operator === "&" ? "AND" : "OR" + ); + }, + update(operator) { + this.operator = operator; + updateDomain(); + }, + insert(newNodeType) { + const newNode = makeFakeNode(ctx, newNodeType); + const operands = ctx.parent.operands; + operands.splice(operands.indexOf(this) + 1, 0, newNode); + updateDomain(); + }, + delete() { + const operands = ctx.parent.operands; + operands.splice(operands.indexOf(this), 1); + updateDomain(); + }, + }; + } + makeLeafNode(ctx, operator, operands) { + const updateDomain = () => this.props.update(ctx.getFullDomain()); + const makeFakeNode = this.makeFakeNode.bind(this); + + return { + type: "leaf", + id: this.nextNodeId++, + operator, + operands, + computeDomain() { + return new Domain([[this.operands[0], this.operator, this.operands[1]]]); + }, + update(changes) { + if ("fieldName" in changes) { + this.operands[0] = changes.fieldName; + } + if ("operator" in changes) { + this.operator = changes.operator; + } + if ("value" in changes) { + this.operands[1] = changes.value; + } + updateDomain(); + }, + insert(newNodeType) { + const newNode = makeFakeNode(ctx, newNodeType); + const operands = ctx.parent.operands; + operands.splice(operands.indexOf(this) + 1, 0, newNode); + updateDomain(); + }, + delete() { + const operands = ctx.parent.operands; + operands.splice(operands.indexOf(this), 1); + updateDomain(); + }, + }; + } + makeRootNode(ctx) { + const updateDomain = (...args) => this.props.update(...args); + const makeFakeNode = this.makeFakeNode.bind(this); + + return { + type: "root", + id: this.nextNodeId++, + operator: "&", + operands: [], + computeDomain() { + return Domain.combine( + this.operands.map((operand) => operand.computeDomain()), + "AND" + ); + }, + update(newValue, fromDebug) { + if (typeof newValue === "string") { + updateDomain(newValue, fromDebug); + } else if (this.operands.length) { + this.operands[0].update(newValue); + } + }, + insert(newNodeType) { + const newNode = makeFakeNode(ctx, newNodeType); + if (ctx.parent) { + const operands = ctx.parent.operands; + operands.splice(operands.indexOf(this) + 1, 0, newNode); + } else { + this.operands.push(newNode); + } + updateDomain(ctx.getFullDomain()); + }, + delete() {}, + }; + } + + makeFakeNode(ctx, type) { + const [field, op, value] = this.props.defaultLeafValue; + if (type === "branch") { + return this.makeBranchNode(ctx, ctx.parent.operator === "&" ? "|" : "&", [ + this.makeLeafNode(ctx, op, [field, value]), + this.makeLeafNode(ctx, op, [field, value]), + ]); + } else { + return this.makeLeafNode(ctx, op, [field, value]); + } + } +} + +Object.assign(DomainSelector, { + template: "web._DomainSelector", + components: { + DomainSelectorRootNode, + }, + props: { + className: { type: String, optional: true }, + resModel: String, + value: String, + readonly: { type: Boolean, optional: true }, + update: { type: Function, optional: true }, + isDebugMode: { type: Boolean, optional: true }, + defaultLeafValue: { type: Array, optional: true }, + }, + defaultProps: { + readonly: true, + update: () => {}, + isDebugMode: false, + defaultLeafValue: ["id", "=", 1], + }, +}); diff --git a/addons/web/static/src/core/domain_selector/domain_selector.xml b/addons/web/static/src/core/domain_selector/domain_selector.xml new file mode 100644 index 00000000000..4b0fe8f42a6 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector.xml @@ -0,0 +1,21 @@ + + + + + + + + + +
This domain is not supported.
+
+
+ +
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_node.js b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.js new file mode 100644 index 00000000000..3ec2351b66c --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator"; +import { DomainSelectorControlPanel } from "./domain_selector_control_panel"; +import { DomainSelectorLeafNode } from "./domain_selector_leaf_node"; + +const { Component, useRef } = owl; + +export class DomainSelectorBranchNode extends Component { + setup() { + this.root = useRef("root"); + } + onHoverDeleteNodeBtn(hovering) { + this.root.el.classList.toggle("o_hover_btns", hovering); + } + onHoverInsertLeafNodeBtn(hovering) { + this.root.el.classList.toggle("o_hover_add_node", hovering); + } + onHoverInsertBranchNodeBtn(hovering) { + this.root.el.classList.toggle("o_hover_add_node", hovering); + this.root.el.classList.toggle("o_hover_add_inset_node", hovering); + } +} +DomainSelectorBranchNode.template = "web.DomainSelectorBranchNode"; +DomainSelectorBranchNode.components = { + DomainSelectorBranchNode, + DomainSelectorBranchOperator, + DomainSelectorControlPanel, + DomainSelectorLeafNode, +}; diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml new file mode 100644 index 00000000000..359ccb224c7 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml @@ -0,0 +1,31 @@ + + + + +
+
+ + + + + of: +
+
+ + + + + + + + +
+
+
+ +
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js new file mode 100644 index 00000000000..539ed130048 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; + +const { Component } = owl; + +export class DomainSelectorBranchOperator extends Component { + onOperatorSelected(operator) { + this.props.node.update(operator); + } +} +DomainSelectorBranchOperator.components = { + Dropdown, + DropdownItem, +}; +DomainSelectorBranchOperator.template = "web.DomainSelectorBranchOperator"; +DomainSelectorBranchOperator.props = { + node: Object, + readonly: Boolean, +}; diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml new file mode 100644 index 00000000000..2d2a31ab6e6 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml @@ -0,0 +1,27 @@ + + + + + +
+ + + All + Any + None + + All + Any + +
+
+ + + ALL + ANY + NONE + + +
+ +
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_control_panel.js b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.js new file mode 100644 index 00000000000..501d08abc79 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +const { Component, toRaw } = owl; + +export class DomainSelectorControlPanel extends Component { + deleteNode() { + this.props.node.delete(); + } + + insertNode(newNodeType) { + toRaw(this.props.node).insert(newNodeType); // FIXME WOWL reactivity + } + + onEnterDeleteNodeBtn() { + this.props.onHoverDeleteNodeBtn(true); + } + onLeaveDeleteNodeBtn() { + this.props.onHoverDeleteNodeBtn(false); + } + onEnterInsertLeafNodeBtn() { + this.props.onHoverInsertLeafNodeBtn(true); + } + onLeaveInsertLeafNodeBtn() { + this.props.onHoverInsertLeafNodeBtn(false); + } + onEnterInsertBranchNodeBtn() { + this.props.onHoverInsertBranchNodeBtn(true); + } + onLeaveInsertBranchNodeBtn() { + this.props.onHoverInsertBranchNodeBtn(false); + } +} +DomainSelectorControlPanel.template = "web.DomainSelectorControlPanel"; +DomainSelectorControlPanel.props = { + node: Object, + onHoverDeleteNodeBtn: Function, + onHoverInsertLeafNodeBtn: Function, + onHoverInsertBranchNodeBtn: Function, +}; diff --git a/addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml new file mode 100644 index 00000000000..c60a708ce69 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js new file mode 100644 index 00000000000..2a1a9d3bf9b --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js @@ -0,0 +1,101 @@ +/** @odoo-module **/ + +import { useModelField } from "@web/core/model_field_selector/model_field_hook"; +import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector"; +import { registry } from "@web/core/registry"; +import { DomainSelectorControlPanel } from "./domain_selector_control_panel"; + +const { Component, onWillStart, onWillUpdateProps, useRef } = owl; + +export class DomainSelectorLeafNode extends Component { + setup() { + this.root = useRef("root"); + this.modelField = useModelField(); + this.fieldInfo = { + type: "integer", + string: "ID", + }; + onWillStart(async () => { + this.fieldInfo = await this.loadField(this.props.resModel, this.props.node.operands[0]); + }); + onWillUpdateProps(async (nextProps) => { + this.fieldInfo = await this.loadField(nextProps.resModel, nextProps.node.operands[0]); + }); + } + + get displayedOperator() { + const op = this.getOperatorInfo(this.props.node.operator); + return op ? op.label : "?"; + } + get isValueHidden() { + return this.getOperatorInfo(this.props.node.operator).hideValue; + } + + async loadField(resModel, fieldName) { + const chain = await this.modelField.loadChain(resModel, fieldName); + if (!chain[chain.length - 1].field && chain.length > 1) { + return chain[chain.length - 2].field; + } + return ( + chain[chain.length - 1].field || { + type: "integer", + string: "ID", + } + ); + } + + getFieldComponent(type) { + return registry.category("domain_selector/fields").get(type, null); + } + getOperatorInfo(operator) { + return this.getFieldComponent(this.fieldInfo.type) + .getOperators() + .find((op) => + op.matches({ + field: this.fieldInfo, + operator, + value: this.props.node.operands[1], + }) + ); + } + + async onFieldChange(fieldName) { + const changes = { fieldName }; + const fieldInfo = await this.loadField(this.props.resModel, fieldName); + const component = this.getFieldComponent(fieldInfo.type); + Object.assign(changes, component.onDidTypeChange(fieldInfo)); + this.props.node.update(changes); + } + onOperatorChange(ev) { + const component = this.getFieldComponent(this.fieldInfo.type); + const operatorInfo = component.getOperators()[parseInt(ev.target.value, 10)]; + const changes = { operator: operatorInfo.value }; + Object.assign( + changes, + operatorInfo.onDidChange( + this.getOperatorInfo(this.props.node.operator), + component.onDidTypeChange + ) + ); + this.props.node.update(changes); + } + + onHoverDeleteNodeBtn(hovering) { + this.root.el.classList.toggle("o_hover_btns", hovering); + } + onHoverInsertLeafNodeBtn(hovering) { + this.root.el.classList.toggle("o_hover_add_node", hovering); + } + onHoverInsertBranchNodeBtn(hovering) { + this.root.el.classList.toggle("o_hover_add_node", hovering); + this.root.el.classList.toggle("o_hover_add_inset_node", hovering); + } +} + +Object.assign(DomainSelectorLeafNode, { + template: "web.DomainSelectorLeafNode", + components: { + DomainSelectorControlPanel, + ModelFieldSelector, + }, +}); diff --git a/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml new file mode 100644 index 00000000000..19bd49e603a --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml @@ -0,0 +1,71 @@ + + + + +
+ +
+ + + + "" + + + + + + + is + + not + + set + + + + + "" + or + + +
+
+ + +
+ + + +
+ +
+ + + + +
+
+
+
+ +
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_operators.js b/addons/web/static/src/core/domain_selector/domain_selector_operators.js new file mode 100644 index 00000000000..54e35368c65 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_operators.js @@ -0,0 +1,166 @@ +/** @odoo-module **/ + +import { _lt } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; + +function onDidChange(action) { + return function (oldOperator, fieldChange) { + if (this.category !== oldOperator.category) { + return action(fieldChange); + } + return {}; + }; +} + +function matchValue() { + return function ({ operator }) { + return operator === this.value; + }; +} + +const dso = registry.category("domain_selector/operator"); +dso.add("=", { + category: "equality", + label: "=", + value: "=", + onDidChange: onDidChange((fieldChange) => fieldChange()), + matches({ operator, value }) { + return operator === this.value && typeof value !== "boolean"; + }, +}); +dso.add("!=", { + category: "equality", + label: _lt("is not ="), + value: "!=", + onDidChange: onDidChange((fieldChange) => fieldChange()), + matches({ operator, value }) { + return operator === this.value && typeof value !== "boolean"; + }, +}); +dso.add(">", { + category: "comparison", + label: ">", + value: ">", + onDidChange: onDidChange((fieldChange) => fieldChange()), + matches: matchValue(), +}); +dso.add(">=", { + category: "comparison", + label: ">=", + value: ">=", + onDidChange: onDidChange((fieldChange) => fieldChange()), + matches: matchValue(), +}); +dso.add("<", { + category: "comparison", + label: "<", + value: "<", + onDidChange: onDidChange((fieldChange) => fieldChange()), + matches: matchValue(), +}); +dso.add("<=", { + category: "comparison", + label: "<=", + value: "<=", + onDidChange: onDidChange((fieldChange) => fieldChange()), + matches: matchValue(), +}); +dso.add("ilike", { + category: "like", + label: _lt("contains"), + value: "ilike", + onDidChange: onDidChange(() => ({ value: "" })), + matches: matchValue(), +}); +dso.add("not ilike", { + category: "like", + label: _lt("does not contain"), + value: "not ilike", + onDidChange: onDidChange(() => ({ value: "" })), + matches: matchValue(), +}); +dso.add("like", { + category: "like", + label: _lt("like"), + value: "like", + onDidChange: onDidChange(() => ({ value: "" })), +}); +dso.add("not like", { + category: "like", + label: _lt("not like"), + value: "not like", + onDidChange: onDidChange(() => ({ value: "" })), + matches: matchValue(), +}); +dso.add("=like", { + category: "like", + label: _lt("=like"), + value: "=like", + onDidChange: onDidChange(() => ({ value: "" })), + matches: matchValue(), +}); +dso.add("=ilike", { + category: "like", + label: _lt("=ilike"), + value: "=ilike", + onDidChange: onDidChange(() => ({ value: "" })), + matches: matchValue(), +}); +dso.add("child_of", { + category: "relation", + label: _lt("child of"), + value: "child_of", + onDidChange: onDidChange(() => ({ value: 1 })), + matches: matchValue(), +}); +dso.add("parent_of", { + category: "relation", + label: _lt("parent of"), + value: "parent_of", + onDidChange: onDidChange(() => ({ value: 1 })), + matches: matchValue(), +}); +dso.add("in", { + category: "in", + label: _lt("in"), + value: "in", + onDidChange: onDidChange(() => ({ value: [] })), + matches: matchValue(), +}); +dso.add("not in", { + category: "in", + label: _lt("not in"), + value: "not in", + onDidChange: onDidChange(() => ({ value: [] })), + matches: matchValue(), +}); +dso.add("set", { + category: "set", + label: _lt("is set"), + value: "set", + hideValue: true, + onDidChange() { + return { + operator: "!=", + value: false, + }; + }, + matches({ operator, value }) { + return operator === "!=" && typeof value === "boolean"; + }, +}); +dso.add("not set", { + category: "set", + label: _lt("is not set"), + value: "not set", + hideValue: true, + onDidChange() { + return { + operator: "=", + value: false, + }; + }, + matches({ operator, value }) { + return operator === "=" && typeof value === "boolean"; + }, +}); diff --git a/addons/web/static/src/core/domain_selector/domain_selector_root_node.js b/addons/web/static/src/core/domain_selector/domain_selector_root_node.js new file mode 100644 index 00000000000..0d5ecee2251 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_root_node.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ + +import { DomainSelectorBranchNode } from "./domain_selector_branch_node"; +import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator"; +import { DomainSelectorLeafNode } from "./domain_selector_leaf_node"; + +const { Component } = owl; + +export class DomainSelectorRootNode extends Component { + get hasNode() { + return this.props.node.operands.length > 0; + } + get node() { + return this.props.node.operands[0]; + } + + insertNode(newNodeType) { + this.props.node.insert(newNodeType); + } + + onOperatorSelected(ev) { + this.props.node.update(ev.detail.payload.operator); + } + onChange(ev) { + this.props.node.update(ev.target.value, true); + } +} +DomainSelectorRootNode.template = "web.DomainSelectorRootNode"; +DomainSelectorRootNode.components = { + DomainSelectorBranchNode, + DomainSelectorBranchOperator, + DomainSelectorLeafNode, +}; diff --git a/addons/web/static/src/core/domain_selector/domain_selector_root_node.xml b/addons/web/static/src/core/domain_selector/domain_selector_root_node.xml new file mode 100644 index 00000000000..a42f52d1527 --- /dev/null +++ b/addons/web/static/src/core/domain_selector/domain_selector_root_node.xml @@ -0,0 +1,53 @@ + + + + +
+ + Match All records + + + + + + + Match records with the following rule: +
+ +
+
+ + Match records with + + of the following rules: +
+ + + + + + + + +
+
+
+ +