vanilla 17.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:47:08 +02:00
parent d72e748793
commit a9bcec8e91
1986 changed files with 1613876 additions and 568976 deletions

View file

@ -119,6 +119,49 @@ QUnit.module("Components", (hooks) => {
assert.verifySteps(["Hello"]);
});
QUnit.test("autocomplete with resetOnSelect='true'", 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`
<div>
<div class= "test_value" t-esc="state.value"/>
<AutoComplete
value="''"
sources="sources"
onSelect="(option) => this.onSelect(option)"
resetOnSelect="true"
/>
</div>
`;
await mount(Parent, target, { env });
assert.strictEqual(target.querySelector(".test_value").textContent, "Hello");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
await editInput(target, ".o-autocomplete--input", "Blip");
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1]);
assert.strictEqual(target.querySelector(".test_value").textContent, "Hello");
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
assert.verifySteps(["Hello"]);
});
QUnit.test("open dropdown on input", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
@ -162,6 +205,19 @@ QUnit.module("Components", (hooks) => {
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
});
QUnit.test("select input text on first focus", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete value="'Bar'" sources="[{ options: [{ label: 'Bar' }] }]" onSelect="() => {}"/>
`;
await mount(Parent, target, { env });
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
const el = target.querySelector(".o-autocomplete--input");
assert.strictEqual(el.value.substring(el.selectionStart, el.selectionEnd), "Bar");
});
QUnit.test("scroll outside should cancel result", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
@ -380,6 +436,102 @@ QUnit.module("Components", (hooks) => {
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
});
QUnit.test("autofocus=true option work as expected", async (assert) => {
class Parent extends Component {}
Parent.components = { AutoComplete };
Parent.template = xml`
<AutoComplete value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
autofocus="true"
onSelect="() => {}"
/>
`;
await mount(Parent, target, { env });
assert.strictEqual(target.querySelector(".o-autocomplete input"), document.activeElement);
});
QUnit.test("autocomplete in edition keep edited value before select option", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({ value: "Hello" });
}
onClick() {
this.state.value = "My Click";
}
onSelect() {
this.state.value = "My Selection";
}
}
Parent.components = { AutoComplete };
Parent.template = xml`
<button class="myButton" t-on-click="onClick" />
<AutoComplete value="this.state.value"
sources="[{ options: [{ label: 'My Selection' }] }]"
onSelect.bind="onSelect"
/>
`;
await mount(Parent, target, { env });
const input = target.querySelector(".o-autocomplete input");
input.value = "Yolo";
await triggerEvent(input, null, "input");
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "Yolo");
await click(target, ".myButton");
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "Yolo");
// Leave inEdition mode when selecting an option
await click(target.querySelector(".o-autocomplete input"));
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[0]);
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "My Selection");
await click(target, ".myButton");
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "My Click");
});
QUnit.test("autocomplete in edition keep edited value before blur", async (assert) => {
let count = 0;
class Parent extends Component {
setup() {
this.state = useState({ value: "Hello" });
}
onClick() {
this.state.value = `My Click ${count++}`;
}
}
Parent.components = { AutoComplete };
Parent.template = xml`
<button class="myButton" t-on-click="onClick" />
<AutoComplete value="this.state.value"
sources="[]"
onSelect="() => {}"
/>
`;
await mount(Parent, target, { env });
let input = target.querySelector(".o-autocomplete input");
input.value = "";
await triggerEvent(input, null, "input");
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "");
await click(target, ".myButton");
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "");
// Leave inEdition mode when blur the input
input = target.querySelector(".o-autocomplete input");
await triggerEvent(input, null, "blur");
await nextTick();
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "");
await click(target, ".myButton");
assert.strictEqual(target.querySelector(".o-autocomplete input").value, "My Click 1");
});
QUnit.test("correct sequence of blur, focus and select [REQUIRE FOCUS]", async (assert) => {
class Parent extends Component {
setup() {
@ -469,7 +621,11 @@ QUnit.module("Components", (hooks) => {
assert.strictEqual(mousedownEvent.defaultPrevented, false);
await triggerEvent(input, "", "change");
await triggerEvent(input, "", "blur");
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1], "");
await triggerEvent(
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
"",
"click"
);
assert.verifySteps(["change", "select Hello"]);
assert.strictEqual(input, document.activeElement);
@ -479,8 +635,8 @@ QUnit.module("Components", (hooks) => {
await triggerEvent(input, "", "input");
await triggerEvent(target, "", "pointerdown");
await triggerEvent(input, "", "change");
input.blur();
await click(target, "");
await triggerEvent(input, "", "blur");
await triggerEvent(target, "", "click");
assert.verifySteps(["change", "blur"]);
});

View file

@ -0,0 +1,86 @@
/** @odoo-module **/
import { Cache } from "@web/core/utils/cache";
import { makeDeferred, nextTick } from "../helpers/utils";
QUnit.module("utils", () => {
QUnit.module("cache");
QUnit.test("do not call getValue if already cached", async (assert) => {
const cache = new Cache((key) => {
assert.step(key);
return key.toUpperCase();
});
assert.strictEqual(cache.read("a"), "A");
assert.strictEqual(cache.read("b"), "B");
assert.strictEqual(cache.read("a"), "A");
assert.verifySteps(["a", "b"]);
});
QUnit.test("multiple cache key", async (assert) => {
const cache = new Cache((...keys) => assert.step(keys.join("-")));
cache.read("a", 1);
cache.read("a", 2);
cache.read("a", 1);
assert.verifySteps(["a-1", "a-2"]);
});
QUnit.test("compute key", async (assert) => {
const cache = new Cache(
(key) => assert.step(key),
(key) => key.toLowerCase()
);
cache.read("a");
cache.read("A");
assert.verifySteps(["a"]);
});
QUnit.test("cache promise", async (assert) => {
const cache = new Cache((key) => {
assert.step(`read ${key}`);
return makeDeferred();
});
cache.read("a").then((k) => assert.step(`then ${k}`));
cache.read("b").then((k) => assert.step(`then ${k}`));
cache.read("a").then((k) => assert.step(`then ${k}`));
cache.read("a").resolve("a");
cache.read("b").resolve("b");
await nextTick();
assert.verifySteps(["read a", "read b", "then a", "then a", "then b"]);
});
QUnit.test("clear cache", async (assert) => {
const cache = new Cache((key) => assert.step(key));
cache.read("a");
cache.read("b");
assert.verifySteps(["a", "b"]);
cache.read("a");
cache.read("b");
assert.verifySteps([]);
cache.clear("a");
cache.read("a");
cache.read("b");
assert.verifySteps(["a"]);
cache.clear();
cache.read("a");
cache.read("b");
assert.verifySteps([]);
cache.invalidate();
cache.read("a");
cache.read("b");
assert.verifySteps(["a", "b"]);
});
});

View file

@ -69,22 +69,16 @@ QUnit.module("Components", (hooks) => {
assert.strictEqual(value, false);
});
QUnit.test("does not call onChange prop when disabled", async (assert) => {
QUnit.test("checkbox with props disabled", async (assert) => {
const env = await makeTestEnv();
let onChangeCalled = false;
class Parent extends Component {
onChange(checked) {
onChangeCalled = true;
}
}
class Parent extends Component {}
Parent.template = xml`<CheckBox onChange="onChange" disabled="true"/>`;
Parent.components = { CheckBox };
await mount(Parent, target, { env });
assert.containsOnce(target, ".o-checkbox input");
await click(target.querySelector("input"));
assert.strictEqual(onChangeCalled, false);
assert.ok(target.querySelector(".o-checkbox input").disabled);
});
QUnit.test("can toggle value by pressing ENTER", async (assert) => {
@ -134,7 +128,7 @@ QUnit.module("Components", (hooks) => {
// Click on label
assert.verifySteps([]);
await click(target, ".o-checkbox > .form-check-label", true);
await click(target, ".o-checkbox > .form-check-label", { skipVisibilityCheck: true });
assert.notOk(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["false"]);
@ -160,18 +154,11 @@ QUnit.module("Components", (hooks) => {
// Pressing Space when focus is on the input is a standard behavior
// So we simulate it and verify that it will have its standard behavior.
assert.strictEqual(document.activeElement, target.querySelector(".o-checkbox input"));
const event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Space" },
{ fast: true }
);
const event = await triggerEvent(document.activeElement, null, "keydown", { key: "Space" });
assert.ok(!event.defaultPrevented);
target.querySelector(".o-checkbox input").checked = true;
assert.verifySteps([]);
triggerEvent(target, ".o-checkbox input", "change", {}, { fast: true });
await nextTick();
await triggerEvent(target, ".o-checkbox input", "change");
assert.ok(target.querySelector(".o-checkbox input").checked);
assert.verifySteps(["true"]);
});

View file

@ -0,0 +1,300 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import {
getFixture,
patchWithCleanup,
mount,
nextTick,
editInput,
triggerEvents,
} from "@web/../tests/helpers/utils";
import { Component, markup, useState, xml } from "@odoo/owl";
import { CodeEditor } from "@web/core/code_editor/code_editor";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
QUnit.module("Web Components", (hooks) => {
QUnit.module("Code Editor");
let env;
let target;
hooks.beforeEach(async () => {
env = await makeTestEnv();
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => Promise.resolve().then(fn),
});
});
function getDomValue() {
const lines = [...target.querySelectorAll(".ace_line")];
return lines
.map((line) => {
const spans = [...line.querySelectorAll(":scope > span")];
return spans.map((span) => span.textContent).join("");
})
.join("\n");
}
async function edit(value) {
const textArea = target.querySelector(".ace_editor textarea");
await editInput(textArea, null, value);
return null;
}
function getFakeAceEditor() {
return {
session: {
on: () => {},
setMode: () => {},
setUseWorker: () => {},
setOptions: () => {},
getValue: () => {},
setValue: () => {},
},
renderer: {
setOptions: () => {},
$cursorLayer: { element: { style: {} } },
},
setOptions: () => {},
setValue: () => {},
getValue: () => "",
setTheme: () => {},
resize: () => {},
destroy: () => {},
setSession: () => {},
getSession() {
return this.session;
},
on: () => {},
};
}
QUnit.test("Can be rendered", async (assert) => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor mode="'xml'" />`;
}
await mount(Parent, target, { env });
assert.containsOnce(target, ".ace_editor", "Code editor is rendered");
});
QUnit.test("CodeEditor shouldn't accepts markup values", async (assert) => {
assert.expectErrors();
const _console = window.console;
window.console = Object.assign(Object.create(_console), {
warn(msg) {
assert.step(msg);
},
});
registerCleanup(() => {
window.console = _console;
});
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor value="props.value"/>`;
}
class GrandParent extends Component {
static components = { Parent };
static template = xml`<Parent value="state.value"/>`;
setup() {
this.state = useState({ value: `<div>Some Text</div>` });
}
}
const codeEditor = await mount(GrandParent, target, { env });
const textMarkup = markup`<div>Some Text</div>`;
codeEditor.state.value = textMarkup;
await nextTick(); // wait for the errorService to be called
assert.verifySteps(["[Owl] Unhandled error. Destroying the root component"]);
assert.verifyErrors(["Invalid props for component 'CodeEditor': 'value' is not valid"]);
});
QUnit.test("onChange props called when code is edited", async (assert) => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor onChange.bind="onChange" />`;
onChange(value) {
assert.step(value);
}
}
await mount(Parent, target, { env });
await edit("Some Text");
assert.verifySteps(["Some Text"], "Value properly given to onChange");
});
QUnit.test("onChange props not called when value props is updated", async (assert) => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor value="state.value" onChange.bind="onChange" />`;
state = useState({ value: "initial value" });
onChange(value) {
assert.step(value || "__emptystring__");
}
}
const parent = await mount(Parent, target, { env });
await nextTick();
assert.strictEqual(target.querySelector(".ace_line").textContent, "initial value");
parent.state.value = "new value";
await nextTick();
await nextTick();
assert.strictEqual(target.querySelector(".ace_line").textContent, "new value");
assert.verifySteps([]);
});
QUnit.test("Default value correctly set and updates", async (assert) => {
const textA = "<div>\n<p>A Paragraph</p>\n</div>";
const textB = "<div>\n<p>An Other Paragraph</p>\n</div>";
const textC = "<div>\n<p>A Paragraph</p>\n</div>\n<p>And More</p>";
class Parent extends Component {
static components = { CodeEditor };
static template = xml`
<CodeEditor
mode="'xml'"
value="state.value"
onChange.bind="onChange"
maxLines="200"
/>
`;
setup() {
this.state = useState({ value: textA });
}
onChange(value) {
// Changing the value of the textarea manualy triggers an Ace "remove" event
// of the whole text (the value is thus empty), then an "add" event with the
// actual value, this isn't ideal but we ignore the remove.
if (value.length <= 0) {
return;
}
assert.step(value);
}
changeValue(newValue) {
this.state.value = newValue;
}
}
const codeEditor = await mount(Parent, target, { env });
await nextTick();
assert.equal(getDomValue(), textA, "Default value correctly set");
const aceEditor = window.ace.edit(target.querySelector(".ace_editor"));
aceEditor.selectAll();
await edit(textB);
assert.equal(
getDomValue(),
textB,
"When the textarea is updated the value is correctly changed in the dom"
);
codeEditor.changeValue(textC);
await nextTick();
await nextTick();
assert.equal(
getDomValue(),
textC,
"When the props is updated the value is correctly changed in the dom"
);
assert.verifySteps([textB], "Changes properly given to onChange");
});
QUnit.test("Mode props update imports the mode", async (assert) => {
const fakeAceEditor = getFakeAceEditor();
fakeAceEditor.session.setMode = (mode) => {
assert.step(mode);
};
patchWithCleanup(window.ace, {
edit: () => fakeAceEditor,
});
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor mode="state.mode" />`;
setup() {
this.state = useState({ mode: "xml" });
}
setMode(newMode) {
this.state.mode = newMode;
}
}
const codeEditor = await mount(Parent, target, { env });
assert.verifySteps(["ace/mode/xml"], "XML mode should be loaded");
await codeEditor.setMode("js");
await nextTick();
assert.verifySteps(["ace/mode/js"], "JS mode should be loaded");
});
QUnit.test("Theme props updates imports the theme", async (assert) => {
const fakeAceEditor = getFakeAceEditor();
fakeAceEditor.setTheme = (theme) => {
assert.step(theme ? theme : "default");
};
patchWithCleanup(window.ace, {
edit: () => fakeAceEditor,
});
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor theme="state.theme" />`;
setup() {
this.state = useState({ theme: "" });
}
setTheme(newTheme) {
this.state.theme = newTheme;
}
}
const codeEditor = await mount(Parent, target, { env });
assert.verifySteps(["default"], "Default theme should be loaded");
await codeEditor.setTheme("monokai");
await nextTick();
assert.verifySteps(["ace/theme/monokai"], "Monokai theme should be loaded");
});
QUnit.test("initial value cannot be undone", async (assert) => {
class Parent extends Component {
static components = { CodeEditor };
static template = xml`<CodeEditor mode="'xml'" value="'some value'" />`;
}
await mount(Parent, target, { env });
await nextTick();
assert.containsOnce(target, ".ace_editor", "Code editor is rendered");
assert.strictEqual(
target.querySelector(".ace_editor .ace_content").textContent,
"some value"
);
const editor = window.ace.edit(target.querySelector(".ace_editor"));
const undo = editor.session.$undoManager.undo.bind(editor.session.$undoManager);
editor.session.$undoManager.undo = (...args) => {
assert.step("ace undo");
return undo(...args);
};
await triggerEvents(target, ".ace_editor textarea.ace_text-input", [
["keydown", { key: "Control", keyCode: 17, which: 17 }],
["keypress", { key: "Control", ctrlKey: true, keyCode: 17, which: 17 }],
["keydown", { key: "z", ctrlKey: true, keyCode: 90, which: 90 }],
["keypress", { key: "z", ctrlKey: true, keyCode: 90, which: 90 }],
["keyup", { key: "z", ctrlKey: true, keyCode: 90, which: 90 }],
["keyup", { key: "Control", keyCode: 17, which: 17 }],
]);
await nextTick();
assert.strictEqual(
target.querySelector(".ace_editor .ace_content").textContent,
"some value"
);
assert.verifySteps(["ace undo"]);
});
});

View file

@ -48,8 +48,8 @@ QUnit.module("Components", () => {
const secondBtn = target.querySelectorAll(".o_colorlist button")[1];
assert.strictEqual(
secondBtn.attributes.title.value,
"Fuchsia",
"second button color is Fuchsia"
"Raspberry",
"second button color is Raspberry"
);
assert.hasClass(
secondBtn,

View file

@ -29,14 +29,14 @@ class FooterComponent extends Component {}
FooterComponent.template = xml`<span>My footer</span>`;
class TestComponent extends Component {
get DialogContainer() {
return registry.category("main_components").get("DialogContainer");
get OverlayContainer() {
return registry.category("main_components").get("OverlayContainer");
}
}
TestComponent.template = xml`
<div>
<div class="o_dialog_container"/>
<t t-component="DialogContainer.Component" t-props="DialogContainer.props" />
<t t-component="OverlayContainer.Component" t-props="OverlayContainer.props" />
</div>
`;

View file

@ -9,7 +9,7 @@ import { dialogService } from "@web/core/dialog/dialog_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registry } from "@web/core/registry";
import { uiService, useActiveElement } from "@web/core/ui/ui_service";
import testUtils from "web.test_utils";
import testUtils from "@web/../tests/legacy/helpers/test_utils";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import {
click,
@ -42,13 +42,13 @@ export async function backspaceSearchBar() {
}
class TestComponent extends Component {
get DialogContainer() {
return registry.category("main_components").get("DialogContainer");
get OverlayContainer() {
return registry.category("main_components").get("OverlayContainer");
}
}
TestComponent.template = xml`
<div>
<t t-component="DialogContainer.Component" t-props="DialogContainer.props" />
<t t-component="OverlayContainer.Component" t-props="OverlayContainer.props" />
</div>
`;
@ -236,7 +236,7 @@ QUnit.test("global command with hotkey", async (assert) => {
useActiveElement("active");
}
}
MyComponent.template = xml`<div t-ref="active"></div>`;
MyComponent.template = xml`<div t-ref="active"><button/></div>`;
await mount(MyComponent, target, { env });
triggerHotkey("a");
@ -246,6 +246,131 @@ QUnit.test("global command with hotkey", async (assert) => {
assert.verifySteps([globalHotkey]);
});
QUnit.test("command with hotkey and isAvailable", async (assert) => {
assert.expect(3);
const hotkey = "a";
let isAvailable = false;
env.services.command.add("test", () => assert.step(hotkey), {
hotkey,
isAvailable: () => isAvailable,
});
await nextTick();
triggerHotkey("a");
await nextTick();
assert.verifySteps([]);
isAvailable = true;
triggerHotkey("a");
await nextTick();
assert.verifySteps([hotkey]);
});
QUnit.test("useCommand hook with hotkey and hotkeyOptions", async (assert) => {
const allowRepeatKey = "a";
const disallowRepeatKey = "b";
const defaultBehaviourKey = "c";
class MyComponent extends TestComponent {
setup() {
useCommand("Allow repeat key", () => assert.step(allowRepeatKey), {
hotkey: allowRepeatKey,
hotkeyOptions: {
allowRepeat: true,
},
});
useCommand("Disallow repeat key", () => assert.step(disallowRepeatKey), {
hotkey: disallowRepeatKey,
hotkeyOptions: {
allowRepeat: false,
},
});
useCommand("Default repeat key", () => assert.step(defaultBehaviourKey), {
hotkey: defaultBehaviourKey,
});
}
}
await mount(MyComponent, target, { env });
// Dispatch the three keys without repeat:
triggerHotkey(allowRepeatKey);
triggerHotkey(disallowRepeatKey);
triggerHotkey(defaultBehaviourKey);
await nextTick();
assert.verifySteps([allowRepeatKey, disallowRepeatKey, defaultBehaviourKey]);
// Dispatch the three keys with repeat:
triggerHotkey(allowRepeatKey, false, { repeat: true });
triggerHotkey(disallowRepeatKey, false, { repeat: true });
triggerHotkey(defaultBehaviourKey, false, { repeat: true });
await nextTick();
assert.verifySteps([allowRepeatKey]);
});
QUnit.test("useCommand hook with hotkey and isAvailable", async (assert) => {
const hotkeys = ["a", "b", "c", "d", "e"];
class MyComponent extends TestComponent {
setup() {
useCommand("Command 1", () => assert.step(hotkeys[0]), {
hotkey: hotkeys[0],
isAvailable: () => true,
hotkeyOptions: {
allowRepeat: true,
isAvailable: () => true,
},
});
useCommand("Command 2", () => assert.step(hotkeys[1]), {
hotkey: hotkeys[1],
isAvailable: () => true,
hotkeyOptions: {
allowRepeat: true,
isAvailable: () => false,
},
});
useCommand("Command 3", () => assert.step(hotkeys[2]), {
hotkey: hotkeys[2],
isAvailable: () => false,
hotkeyOptions: {
allowRepeat: true,
isAvailable: () => true,
},
});
useCommand("Command 4", () => assert.step(hotkeys[3]), {
hotkey: hotkeys[3],
isAvailable: () => true,
hotkeyOptions: {
allowRepeat: true,
},
});
useCommand("Command 5", () => assert.step(hotkeys[4]), {
hotkey: hotkeys[4],
isAvailable: () => false,
hotkeyOptions: {
allowRepeat: true,
},
});
}
}
await mount(MyComponent, target, { env });
for (const hotkey of hotkeys) {
triggerHotkey(hotkey);
}
await nextTick();
assert.verifySteps(["a", "d"]);
triggerHotkey("control+k");
await nextTick();
assert.containsOnce(target, ".o_command_palette");
assert.containsN(target, ".o_command", 3);
assert.deepEqual(
[...target.querySelectorAll(".o_command")].map((el) => el.textContent),
["Command 1A", "Command 2B", "Command 4D"]
);
});
QUnit.test("open command palette with command config", async (assert) => {
const hotkey = "alt+a";
const action = () => {};

View file

@ -113,6 +113,8 @@ QUnit.test("opens an app", async (assert) => {
triggerHotkey("enter");
await nextTick();
await nextTick();
// empty screen for now, wait for actual action to show up
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Contact");
assert.strictEqual(
target.querySelector(".test_client_action").textContent,
@ -133,6 +135,8 @@ QUnit.test("opens a menu items", async (assert) => {
click(target, "#o_command_2");
await nextTick();
await nextTick();
// empty screen for now, wait for actual action to show up
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Sales");
assert.strictEqual(
target.querySelector(".test_client_action").textContent,

View file

@ -0,0 +1,293 @@
/** @odoo-module **/
import { getNodesTextContent, editInput, click, editSelect } from "../helpers/utils";
import { getModelFieldSelectorValues } from "./model_field_selector_tests";
import { fieldService } from "@web/core/field_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { ormService } from "@web/core/orm_service";
import { popoverService } from "@web/core/popover/popover_service";
import { uiService } from "@web/core/ui/ui_service";
import { nameService } from "@web/core/name_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
import { registry } from "@web/core/registry";
import { notificationService } from "@web/core/notifications/notification_service";
export function setupConditionTreeEditorServices() {
registry.category("services").add("popover", popoverService);
registry.category("services").add("orm", ormService);
registry.category("services").add("ui", uiService);
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("field", fieldService);
registry.category("services").add("name", nameService);
registry.category("services").add("dialog", dialogService);
registry.category("services").add("datetime_picker", datetimePickerService);
registry.category("services").add("notification", notificationService);
}
export function makeServerData() {
const serverData = {
models: {
partner: {
fields: {
foo: { string: "Foo", type: "char", searchable: true },
bar: { string: "Bar", type: "boolean", searchable: true },
product_id: {
string: "Product",
type: "many2one",
relation: "product",
searchable: true,
},
date: { string: "Date", type: "date", searchable: true },
datetime: { string: "Date Time", type: "datetime", searchable: true },
int: { string: "Integer", type: "integer", searchable: true },
json_field: { string: "Json Field", type: "json", searchable: true },
state: {
string: "State",
type: "selection",
selection: [
["abc", "ABC"],
["def", "DEF"],
["ghi", "GHI"],
],
},
},
records: [
{ id: 1, foo: "yop", bar: true, product_id: 37 },
{ id: 2, foo: "blip", bar: true, product_id: false },
{ id: 4, foo: "abc", bar: false, product_id: 41 },
],
onchanges: {},
},
product: {
fields: {
name: { string: "Product Name", type: "char", searchable: true },
},
records: [
{ id: 37, display_name: "xphone" },
{ id: 41, display_name: "xpad" },
],
},
},
};
return serverData;
}
////////////////////////////////////////////////////////////////////////////////
export const SELECTORS = {
node: ".o_tree_editor_node",
row: ".o_tree_editor_row",
tree: ".o_tree_editor > .o_tree_editor_node",
connector: ".o_tree_editor_connector",
condition: ".o_tree_editor_condition",
addNewRule: ".o_tree_editor_row > a",
buttonAddNewRule: ".o_tree_editor_node_control_panel > button:nth-child(1)",
buttonAddBranch: ".o_tree_editor_node_control_panel > button:nth-child(2)",
buttonDeleteNode: ".o_tree_editor_node_control_panel > button:nth-child(3)",
pathEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(1)",
operatorEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(2)",
valueEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(3)",
editor: ".o_tree_editor_editor",
clearNotSupported: ".o_input .fa-times",
tag: ".o_input .o_tag",
toggleArchive: ".form-switch",
complexCondition: ".o_tree_editor_complex_condition",
complexConditionInput: ".o_tree_editor_complex_condition input",
};
const CHILD_SELECTOR = ["connector", "condition", "complexCondition"]
.map((k) => SELECTORS[k])
.join(",");
export function getTreeEditorContent(target, options = {}) {
const content = [];
const nodes = target.querySelectorAll(SELECTORS.node);
const mapping = new Map();
for (const node of nodes) {
const parent = node.parentElement.closest(SELECTORS.node);
const level = parent ? mapping.get(parent) + 1 : 0;
mapping.set(node, level);
const nodeValue = { level };
const associatedNode = node.querySelector(CHILD_SELECTOR);
const className = associatedNode.className;
if (className.includes("connector")) {
nodeValue.value = getCurrentConnector(node);
} else if (className.includes("complex_condition")) {
nodeValue.value = getCurrentComplexCondition(node);
} else {
nodeValue.value = getCurrentCondition(node);
}
if (options.node) {
nodeValue.node = node;
}
content.push(nodeValue);
}
return content;
}
export function get(target, selector, index = 0) {
if (index) {
return [...target.querySelectorAll(selector)].at(index);
}
return target.querySelector(selector);
}
function getValue(target) {
if (target) {
const el = target.querySelector("input,select,span:not(.o_tag)");
switch (el.tagName) {
case "INPUT":
return el.value;
case "SELECT":
return el.options[el.selectedIndex].label;
case "SPAN":
return el.innerText;
}
}
}
export function getCurrentPath(target, index = 0) {
const pathEditor = get(target, SELECTORS.pathEditor, index);
if (pathEditor) {
if (pathEditor.querySelector(".o_model_field_selector")) {
return getModelFieldSelectorValues(pathEditor).join(" > ");
}
return pathEditor.textContent;
}
}
export function getCurrentOperator(target, index = 0) {
const operatorEditor = get(target, SELECTORS.operatorEditor, index);
return getValue(operatorEditor);
}
export function getCurrentValue(target, index) {
const valueEditor = get(target, SELECTORS.valueEditor, index);
const value = getValue(valueEditor);
if (valueEditor) {
const tags = [...valueEditor.querySelectorAll(".o_tag")];
if (tags.length) {
let text = `${tags.map((t) => t.innerText).join(" ")}`;
if (value) {
text += ` ${value}`;
}
return text;
}
}
return value;
}
export function getOperatorOptions(target, index = 0) {
const el = get(target, SELECTORS.operatorEditor, index);
if (el) {
const select = el.querySelector("select");
return [...select.options].map((o) => o.label);
}
}
export function getValueOptions(target, index = 0) {
const el = get(target, SELECTORS.valueEditor, index);
if (el) {
const select = el.querySelector("select");
return [...select.options].map((o) => o.label);
}
}
function getCurrentComplexCondition(target, index = 0) {
const input = get(target, SELECTORS.complexConditionInput, index);
return input?.value;
}
export function getConditionText(target, index = 0) {
const condition = get(target, SELECTORS.condition, index);
if (condition) {
const texts = [];
for (const t of getNodesTextContent(condition.childNodes)) {
const t2 = t.trim();
if (t2) {
texts.push(t2);
}
}
return texts.join(" ");
}
}
function getCurrentCondition(target, index = 0) {
const values = [getCurrentPath(target, index), getCurrentOperator(target, index)];
const valueEditor = get(target, SELECTORS.valueEditor, index);
if (valueEditor) {
values.push(getCurrentValue(target, index));
}
return values;
}
function getCurrentConnector(target, index = 0) {
const connector = get(
target,
`${SELECTORS.connector} .dropdown-toggle, ${SELECTORS.connector} > span:nth-child(2), ${SELECTORS.connector} > span > strong`,
index
);
return connector?.textContent.search("all") >= 0 ? "all" : connector?.textContent;
}
////////////////////////////////////////////////////////////////////////////////
export function isNotSupportedPath(target, index = 0) {
const pathEditor = get(target, SELECTORS.pathEditor, index);
return Boolean(pathEditor.querySelector(SELECTORS.clearNotSupported));
}
export function isNotSupportedOperator(target, index = 0) {
const operatorEditor = get(target, SELECTORS.operatorEditor, index);
return Boolean(operatorEditor.querySelector(SELECTORS.clearNotSupported));
}
export function isNotSupportedValue(target, index = 0) {
const valueEditor = get(target, SELECTORS.valueEditor, index);
return Boolean(valueEditor.querySelector(SELECTORS.clearNotSupported));
}
////////////////////////////////////////////////////////////////////////////////
export async function selectOperator(target, operator, index = 0) {
const el = get(target, SELECTORS.operatorEditor, index);
await editSelect(el, "select", JSON.stringify(operator));
}
export async function selectValue(target, value, index = 0) {
const el = get(target, SELECTORS.valueEditor, index);
await editSelect(el, "select", JSON.stringify(value));
}
export async function editValue(target, value, index = 0) {
const el = get(target, SELECTORS.valueEditor, index);
await editInput(el, "input", value);
}
export async function clickOnButtonAddNewRule(target, index = 0) {
await click(get(target, SELECTORS.buttonAddNewRule, index));
}
export async function clickOnButtonAddBranch(target, index = 0) {
await click(get(target, SELECTORS.buttonAddBranch, index));
}
export async function clickOnButtonDeleteNode(target, index = 0) {
await click(get(target, SELECTORS.buttonDeleteNode, index));
}
export async function clearNotSupported(target, index = 0) {
await click(get(target, SELECTORS.clearNotSupported, index));
}
export async function addNewRule(target) {
await click(target, SELECTORS.addNewRule);
}
export async function toggleArchive(target) {
await click(target, SELECTORS.toggleArchive);
}
////////////////////////////////////////////////////////////////////////////////

View file

@ -0,0 +1,885 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { evaluateBooleanExpr } from "@web/core/py_js/py";
import {
complexCondition,
condition,
connector,
domainFromExpression,
domainFromTree,
expression,
Expression,
expressionFromDomain,
expressionFromTree,
treeFromDomain,
treeFromExpression,
} from "@web/core/tree_editor/condition_tree";
QUnit.module("condition tree", {});
QUnit.test("domainFromTree", function (assert) {
const toTest = [
{
tree: condition("foo", "=", false),
result: `[("foo", "=", False)]`,
},
{
tree: condition("foo", "=", false, true),
result: `["!", ("foo", "=", False)]`,
},
{
tree: condition("foo", "=?", false),
result: `[("foo", "=?", False)]`,
},
{
tree: condition("foo", "=?", false, true),
result: `["!", ("foo", "=?", False)]`,
},
{
tree: condition("foo", "between", [1, 3]),
result: `["&", ("foo", ">=", 1), ("foo", "<=", 3)]`,
},
{
tree: condition("foo", "between", [1, expression("uid")], true),
result: `["!", "&", ("foo", ">=", 1), ("foo", "<=", uid)]`,
},
];
for (const { tree, result } of toTest) {
assert.strictEqual(domainFromTree(tree), result);
}
});
QUnit.test("domainFromTree . treeFromDomain", function (assert) {
const toTest = [
{
domain: `[("foo", "=", False)]`,
result: `[("foo", "=", False)]`,
},
{
domain: `[("foo", "=", true)]`,
result: `[("foo", "=", True)]`,
},
{
domain: `["!", ("foo", "=", False)]`,
result: `[("foo", "!=", False)]`,
},
{
domain: `[("foo", "=?", False)]`,
result: `[("foo", "=?", False)]`,
},
{
domain: `["!", ("foo", "=?", False)]`,
result: `["!", ("foo", "=?", False)]`,
},
{
domain: `["&", ("foo", ">=", 1), ("foo", "<=", 3)]`,
result: `["&", ("foo", ">=", 1), ("foo", "<=", 3)]`,
},
{
domain: `["&", ("foo", ">=", 1), ("foo", "<=", uid)]`,
result: `["&", ("foo", ">=", 1), ("foo", "<=", uid)]`,
},
];
for (const { domain, result } of toTest) {
assert.deepEqual(domainFromTree(treeFromDomain(domain)), result);
}
});
QUnit.test("domainFromExpression", function (assert) {
const options = {
getFieldDef: (name) => {
if (["foo", "bar"].includes(name)) {
return {}; // any field
}
if (name === "foo_ids") {
return { type: "many2many" };
}
return null;
},
};
const toTest = [
{
expression: `not foo`,
result: `[("foo", "=", False)]`,
},
{
expression: `foo == False`,
result: `[("foo", "=", False)]`,
},
{
expression: `foo`,
result: `[("foo", "!=", False)]`,
},
{
expression: `foo == True`,
result: `[("foo", "=", True)]`,
},
{
expression: `foo is True`,
result: `[(bool(foo is True), "=", 1)]`,
},
{
expression: `not (foo == False)`,
result: `[("foo", "!=", False)]`,
},
{
expression: `not (not foo)`,
result: `[("foo", "!=", False)]`,
},
{
expression: `foo >= 1 and foo <= 3`,
result: `["&", ("foo", ">=", 1), ("foo", "<=", 3)]`,
},
{
expression: `foo >= 1 and foo <= uid`,
result: `["&", ("foo", ">=", 1), ("foo", "<=", uid)]`,
},
{
expression: `foo >= 1 if foo else foo <= uid`,
result: `["|", "&", ("foo", "!=", False), ("foo", ">=", 1), "&", ("foo", "=", False), ("foo", "<=", uid)]`,
},
{
expression: `context.get('toto')`,
result: `[(bool(context.get("toto")), "=", 1)]`,
},
{
expression: `foo >= 1 if context.get('toto') else bar == 42`,
result: `["|", "&", (bool(context.get("toto")), "=", 1), ("foo", ">=", 1), "&", (not context.get("toto"), "=", 1), ("bar", "=", 42)]`,
},
{
expression: `not context.get('toto')`,
result: `[(not context.get("toto"), "=", 1)]`,
},
{
expression: `True`,
result: `[(1, "=", 1)]`,
},
{
expression: `False`,
result: `[(0, "=", 1)]`,
},
{
expression: `A`,
result: `[(bool(A), "=", 1)]`,
},
{
expression: `foo`,
result: `[("foo", "!=", False)]`,
},
{
expression: `not A`,
result: `[(not A, "=", 1)]`,
},
{
expression: `not not A`,
result: `[(bool(A), "=", 1)]`,
},
{
expression: `y == 2`,
result: `[(bool(y == 2), "=", 1)]`,
},
{
expression: `not (y == 2)`,
result: `[(bool(y != 2), "=", 1)]`,
},
{
expression: `foo == 2`,
result: `[("foo", "=", 2)]`,
},
{
expression: `not (foo == 2)`,
result: `[("foo", "!=", 2)]`,
},
{
expression: `2 == foo`,
result: `[("foo", "=", 2)]`,
},
{
expression: `not (2 == foo)`,
result: `[("foo", "!=", 2)]`,
},
{
expression: `foo < 2`,
result: `[("foo", "<", 2)]`,
},
{
expression: `not (foo < 2)`,
result: `[("foo", ">=", 2)]`,
},
{
expression: `2 < foo`,
result: `[("foo", ">", 2)]`,
},
{
expression: `not (2 < foo)`,
result: `[("foo", "<=", 2)]`,
},
{
expression: `not(y == 1)`,
result: `[(bool(y != 1), "=", 1)]`,
},
{
expression: `A if B else C`,
result: `["|", "&", (bool(B), "=", 1), (bool(A), "=", 1), "&", (not B, "=", 1), (bool(C), "=", 1)]`,
},
{
expression: `not bool(A)`,
result: `[(not A, "=", 1)]`,
},
{
expression: `not(A and not B)`,
result: `["!", "&", (bool(A), "=", 1), (not B, "=", 1)]`,
},
{
expression: `not (A and not B)`,
result: `["|", (not A, "=", 1), (bool(B), "=", 1)]`,
extraOptions: { distributeNot: true },
},
];
for (const { expression, result, extraOptions } of toTest) {
const o = { ...options, ...extraOptions };
assert.deepEqual(domainFromExpression(expression, o), result);
}
});
QUnit.test("expressionFromTree", function (assert) {
const options = {
getFieldDef: (name) => {
if (["foo", "bar"].includes(name)) {
return {}; // any field
}
if (["foo_ids", "bar_ids"].includes(name)) {
return { type: "many2many" };
}
return null;
},
};
const toTest = [
{
expressionTree: condition("foo", "=", false),
result: `not foo`,
},
{
expressionTree: condition("foo", "=", false, true),
result: `foo`,
},
{
expressionTree: condition("foo", "!=", false),
result: `foo`,
},
{
expressionTree: condition("foo", "!=", false, true),
result: `not foo`,
},
{
expressionTree: condition("y", "=", false),
result: `not "y"`,
},
{
expressionTree: condition("foo", "between", [1, 3]),
result: `foo >= 1 and foo <= 3`,
},
{
expressionTree: condition("foo", "between", [1, expression("uid")], true),
result: `not ( foo >= 1 and foo <= uid )`,
},
{
expressionTree: complexCondition("uid"),
result: `uid`,
},
{
expressionTree: condition("foo_ids", "in", []),
result: `set(foo_ids).intersection([])`,
},
{
expressionTree: condition("foo_ids", "in", [1]),
result: `set(foo_ids).intersection([1])`,
},
{
expressionTree: condition("foo_ids", "in", 1),
result: `set(foo_ids).intersection([1])`,
},
{
expressionTree: condition("foo", "in", []),
result: `foo in []`,
},
{
expressionTree: condition(expression("expr"), "in", []),
result: `expr in []`,
},
{
expressionTree: condition("foo", "in", [1]),
result: `foo in [1]`,
},
{
expressionTree: condition("foo", "in", 1),
result: `foo in [1]`,
},
{
expressionTree: condition("foo", "in", expression("expr")),
result: `foo in expr`,
},
{
expressionTree: condition("foo_ids", "in", expression("expr")),
result: `set(foo_ids).intersection(expr)`,
},
{
expressionTree: condition("y", "in", []),
result: `"y" in []`,
},
{
expressionTree: condition("y", "in", [1]),
result: `"y" in [1]`,
},
{
expressionTree: condition("y", "in", 1),
result: `"y" in [1]`,
},
{
expressionTree: condition("foo_ids", "not in", []),
result: `not set(foo_ids).intersection([])`,
},
{
expressionTree: condition("foo_ids", "not in", [1]),
result: `not set(foo_ids).intersection([1])`,
},
{
expressionTree: condition("foo_ids", "not in", 1),
result: `not set(foo_ids).intersection([1])`,
},
{
expressionTree: condition("foo", "not in", []),
result: `foo not in []`,
},
{
expressionTree: condition("foo", "not in", [1]),
result: `foo not in [1]`,
},
{
expressionTree: condition("foo", "not in", 1),
result: `foo not in [1]`,
},
{
expressionTree: condition("y", "not in", []),
result: `"y" not in []`,
},
{
expressionTree: condition("y", "not in", [1]),
result: `"y" not in [1]`,
},
{
expressionTree: condition("y", "not in", 1),
result: `"y" not in [1]`,
},
];
for (const { expressionTree, result, extraOptions } of toTest) {
const o = { ...options, ...extraOptions };
assert.strictEqual(expressionFromTree(expressionTree, o), result);
}
});
QUnit.test("treeFromExpression", function (assert) {
const options = {
getFieldDef: (name) => {
if (["foo", "bar"].includes(name)) {
return {}; // any field
}
if (["foo_ids", "bar_ids"].includes(name)) {
return { type: "many2many" };
}
return null;
},
};
const toTest = [
{
expression: `not foo`,
result: condition("foo", "not_set", false),
},
{
expression: `foo == False`,
result: condition("foo", "not_set", false),
},
{
expression: `foo`,
result: condition("foo", "set", false),
},
{
expression: `foo == True`,
result: condition("foo", "=", true),
},
{
expression: `foo is True`,
result: complexCondition(`foo is True`),
},
{
expression: `not (foo == False)`,
result: condition("foo", "set", false),
},
{
expression: `not (not foo)`,
result: condition("foo", "set", false),
},
{
expression: `foo >= 1 and foo <= 3`,
result: condition("foo", "between", [1, 3]),
},
{
expression: `foo >= 1 and foo <= uid`,
result: condition("foo", "between", [1, expression("uid")]),
},
{
expression: `foo >= 1 if bar else foo <= uid`,
result: connector("|", [
connector("&", [condition("bar", "set", false), condition("foo", ">=", 1)]),
connector("&", [
condition("bar", "not_set", false),
condition("foo", "<=", new Expression("uid")),
]),
]),
},
{
expression: `context.get('toto')`,
result: complexCondition(`context.get("toto")`),
},
{
expression: `not context.get('toto')`,
result: complexCondition(`not context.get("toto")`),
},
{
expression: `foo >= 1 if context.get('toto') else bar == 42`,
result: connector("|", [
connector("&", [
complexCondition(`context.get("toto")`),
condition("foo", ">=", 1),
]),
connector("&", [
complexCondition(`not context.get("toto")`),
condition("bar", "=", 42),
]),
]),
},
{
expression: `set()`,
result: complexCondition(`set()`),
},
{
expression: `set([1, 2])`,
result: complexCondition(`set([1, 2])`),
},
{
expression: `set(foo_ids).intersection([1, 2])`,
result: condition("foo_ids", "in", [1, 2]),
},
{
expression: `set(foo_ids).intersection(set([1, 2]))`,
result: condition("foo_ids", "in", [1, 2]),
},
{
expression: `set(foo_ids).intersection(set((1, 2)))`,
result: condition("foo_ids", "in", [1, 2]),
},
{
expression: `set(foo_ids).intersection("ab")`,
result: complexCondition(`set(foo_ids).intersection("ab")`),
},
{
expression: `set([1, 2]).intersection(foo_ids)`,
result: condition("foo_ids", "in", [1, 2]),
},
{
expression: `set(set([1, 2])).intersection(foo_ids)`,
result: condition("foo_ids", "in", [1, 2]),
},
{
expression: `set((1, 2)).intersection(foo_ids)`,
result: condition("foo_ids", "in", [1, 2]),
},
{
expression: `set("ab").intersection(foo_ids)`,
result: complexCondition(`set("ab").intersection(foo_ids)`),
},
{
expression: `set([2, 3]).intersection([1, 2])`,
result: complexCondition(`set([2, 3]).intersection([1, 2])`),
},
{
expression: `set(foo_ids).intersection(bar_ids)`,
result: complexCondition(`set(foo_ids).intersection(bar_ids)`),
},
{
expression: `set().intersection(foo_ids)`,
result: condition(0, "=", 1),
},
{
expression: `set(foo_ids).intersection()`,
result: condition("foo_ids", "set", false),
},
{
expression: `not set().intersection(foo_ids)`,
result: condition(1, "=", 1),
},
{
expression: `not set(foo_ids).intersection()`,
result: condition("foo_ids", "not_set", false),
},
{
expression: `not set(foo_ids).intersection([1, 2])`,
result: condition("foo_ids", "not in", [1, 2]),
},
{
expression: `not set(foo_ids).intersection(set([1, 2]))`,
result: condition("foo_ids", "not in", [1, 2]),
},
{
expression: `not set(foo_ids).intersection(set((1, 2)))`,
result: condition("foo_ids", "not in", [1, 2]),
},
{
expression: `not set(foo_ids).intersection("ab")`,
result: complexCondition(`not set(foo_ids).intersection("ab")`),
},
{
expression: `not set([1, 2]).intersection(foo_ids)`,
result: condition("foo_ids", "not in", [1, 2]),
},
{
expression: `not set(set([1, 2])).intersection(foo_ids)`,
result: condition("foo_ids", "not in", [1, 2]),
},
{
expression: `not set((1, 2)).intersection(foo_ids)`,
result: condition("foo_ids", "not in", [1, 2]),
},
{
expression: `not set("ab").intersection(foo_ids)`,
result: complexCondition(`not set("ab").intersection(foo_ids)`),
},
{
expression: `not set([2, 3]).intersection([1, 2])`,
result: complexCondition(`not set([2, 3]).intersection([1, 2])`),
},
{
expression: `not set(foo_ids).intersection(bar_ids)`,
result: complexCondition(`not set(foo_ids).intersection(bar_ids)`),
},
{
expression: `set(foo_ids).difference([1, 2])`,
result: complexCondition(`set(foo_ids).difference([1, 2])`),
},
{
expression: `set(foo_ids).union([1, 2])`,
result: complexCondition(`set(foo_ids).union([1, 2])`),
},
{
expression: `expr in []`,
result: complexCondition(`expr in []`),
},
];
for (const { expression, result, extraOptions } of toTest) {
const o = { ...options, ...extraOptions };
assert.deepEqual(treeFromExpression(expression, o), result);
}
});
QUnit.test("expressionFromTree . treeFromExpression", function (assert) {
const options = {
getFieldDef: (name) => {
if (["foo", "bar"].includes(name)) {
return {}; // any field
}
if (name === "foo_ids") {
return { type: "many2many" };
}
return null;
},
};
const toTest = [
{
expression: `not foo`,
result: `not foo`,
},
{
expression: `foo == False`,
result: `not foo`,
},
{
expression: `foo == None`,
result: `foo == None`,
},
{
expression: `foo is None`,
result: `foo is None`,
},
{
expression: `foo`,
result: `foo`,
},
{
expression: `foo == True`,
result: `foo == True`,
},
{
expression: `foo is True`,
result: `foo is True`,
},
{
expression: `not (foo == False)`,
result: `foo`,
},
{
expression: `not (not foo)`,
result: `foo`,
},
{
expression: `foo >= 1 and foo <= 3`,
result: `foo >= 1 and foo <= 3`,
},
{
expression: `foo >= 1 and foo <= uid`,
result: `foo >= 1 and foo <= uid`,
},
{
expression: `foo >= 1 if glob else foo <= uid`,
result: `foo >= 1 if glob else foo <= uid`,
},
{
expression: `context.get("toto")`,
result: `context.get("toto")`,
},
{
expression: `foo >= 1 if context.get("toto") else bar == 42`,
result: `foo >= 1 if context.get("toto") else bar == 42`,
},
{
expression: `not context.get("toto")`,
result: `not context.get("toto")`,
},
{
expression: `set()`,
result: `set()`,
},
{
expression: `set([1, 2])`,
result: `set([1, 2])`,
},
{
expression: `set(foo_ids).intersection([1, 2])`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set(foo_ids).intersection(set([1, 2]))`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set(foo_ids).intersection(set((1, 2)))`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set(foo_ids).intersection("ab")`,
result: `set(foo_ids).intersection("ab")`,
},
{
expression: `set([1, 2]).intersection(foo_ids)`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set(set([1, 2])).intersection(foo_ids)`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set((1, 2)).intersection(foo_ids)`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set("ab").intersection(foo_ids)`,
result: `set("ab").intersection(foo_ids)`,
},
{
expression: `set([2, 3]).intersection([1, 2])`,
result: `set([2, 3]).intersection([1, 2])`,
},
{
expression: `set(foo_ids).intersection(bar_ids)`,
result: `set(foo_ids).intersection(bar_ids)`,
},
{
expression: `set().intersection(foo_ids)`,
result: `False`,
},
{
expression: `set(foo_ids).intersection()`,
result: `foo_ids`,
},
{
expression: `not set(foo_ids).intersection([1, 2])`,
result: `not set(foo_ids).intersection([1, 2])`,
},
{
expression: `not set(foo_ids).intersection(set([1, 2]))`,
result: `not set(foo_ids).intersection([1, 2])`,
},
{
expression: `not set(foo_ids).intersection(set((1, 2)))`,
result: `not set(foo_ids).intersection([1, 2])`,
},
{
expression: `not set(foo_ids).intersection("ab")`,
result: `not set(foo_ids).intersection("ab")`,
},
{
expression: `not set([1, 2]).intersection(foo_ids)`,
result: `not set(foo_ids).intersection([1, 2])`,
},
{
expression: `not set(set([1, 2])).intersection(foo_ids)`,
result: `not set(foo_ids).intersection([1, 2])`,
},
{
expression: `not set((1, 2)).intersection(foo_ids)`,
result: `not set(foo_ids).intersection([1, 2])`,
},
{
expression: `not set("ab").intersection(foo_ids)`,
result: `not set("ab").intersection(foo_ids)`,
},
{
expression: `not set([2, 3]).intersection([1, 2])`,
result: `not set([2, 3]).intersection([1, 2])`,
},
{
expression: `not set(foo_ids).intersection(bar_ids)`,
result: `not set(foo_ids).intersection(bar_ids)`,
},
{
expression: `set(foo_ids).difference([1, 2])`,
result: `set(foo_ids).difference([1, 2])`,
},
{
expression: `set(foo_ids).intersection([1, 2])`,
result: `set(foo_ids).intersection([1, 2])`,
},
{
expression: `set([foo]).intersection()`,
result: `foo`,
},
{
expression: `set([foo]).intersection([1, 2])`,
result: `foo in [1, 2]`,
},
{
expression: `set().intersection([foo])`,
result: `False`,
},
{
expression: `set([1, 2]).intersection([foo])`,
result: `foo in [1, 2]`,
},
{
expression: `not set([foo]).intersection()`,
result: `not foo`,
},
{
expression: `not set([foo]).intersection([1, 2])`,
result: `foo not in [1, 2]`,
},
{
expression: `not set().intersection([foo])`,
result: `True`,
},
{
expression: `not set([1, 2]).intersection([foo])`,
result: `foo not in [1, 2]`,
},
];
for (const { expression, result, extraOptions } of toTest) {
const o = { ...options, ...extraOptions };
assert.deepEqual(expressionFromTree(treeFromExpression(expression, o), o), result);
}
});
QUnit.test("expressionFromDomain", function (assert) {
const options = {
getFieldDef: (name) => (name === "x" ? {} : null),
};
const toTest = [
{
domain: `[(1, "=", 1)]`,
result: `True`,
},
{
domain: `[(0, "=", 1)]`,
result: `False`,
},
{
domain: `[("A", "=", 1)]`,
result: `"A" == 1`,
},
{
domain: `[(bool(A), "=", 1)]`,
result: `bool(A)`,
},
{
domain: `[("x", "=", 2)]`,
result: `x == 2`,
},
];
for (const { domain, result, extraOptions } of toTest) {
const o = { ...options, ...extraOptions };
assert.deepEqual(expressionFromDomain(domain, o), result);
}
});
QUnit.test("evaluation . expressionFromTree = contains . domainFromTree", function (assert) {
const options = {
getFieldDef: (name) => {
if (name === "foo") {
return {}; // any field
}
if (name === "foo_ids") {
return { type: "many2many" };
}
return null;
},
};
const record = { foo: 1, foo_ids: [1, 2], uid: 7, expr: "abc", expr2: [1] };
const toTest = [
condition("foo", "=", false),
condition("foo", "=", false, true),
condition("foo", "!=", false),
condition("foo", "!=", false, true),
condition("y", "=", false),
condition("foo", "between", [1, 3]),
condition("foo", "between", [1, expression("uid")], true),
condition("foo_ids", "in", []),
condition("foo_ids", "in", [1]),
condition("foo_ids", "in", 1),
condition("foo", "in", []),
condition(expression("expr"), "in", []),
condition("foo", "in", [1]),
condition("foo", "in", 1),
condition("y", "in", []),
condition("y", "in", [1]),
condition("y", "in", 1),
condition("foo_ids", "not in", []),
condition("foo_ids", "not in", [1]),
condition("foo_ids", "not in", 1),
condition("foo", "not in", []),
condition("foo", "not in", [1]),
condition("foo", "not in", 1),
condition("y", "not in", []),
condition("y", "not in", [1]),
condition("y", "not in", 1),
condition("foo", "in", expression("expr2")),
condition("foo_ids", "in", expression("expr2")),
];
for (const tree of toTest) {
assert.strictEqual(
evaluateBooleanExpr(expressionFromTree(tree, options), record),
new Domain(domainFromTree(tree)).contains(record)
);
}
});

View file

@ -4,22 +4,22 @@ import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, makeDeferred, mount, nextTick, triggerHotkey } from "../helpers/utils";
import { makeDialogTestEnv } from "../helpers/mock_env";
import {
click,
destroy,
getFixture,
makeDeferred,
mount,
nextTick,
triggerHotkey,
} from "../helpers/utils";
import { makeFakeDialogService } from "../helpers/mock_services";
import { Component, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let target;
async function makeDialogTestEnv() {
const env = await makeTestEnv();
env.dialogData = {
isActive: true,
close: () => {},
};
return env;
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async (assert) => {
target = getFixture();
@ -34,30 +34,129 @@ QUnit.module("Components", (hooks) => {
QUnit.module("ConfirmationDialog");
QUnit.test("pressing escape to close the dialog", async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
QUnit.test(
"Without dismiss callback pressing escape to close the dialog",
async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
assert.step("Cancel action");
},
},
confirm: () => {},
cancel: () => {
assert.step("Cancel action");
});
assert.verifySteps([]);
triggerHotkey("escape");
await nextTick();
assert.verifySteps(
["Cancel action", "Close action"],
"dialog has called its cancel method before its closure"
);
}
);
QUnit.test(
"With dismiss callback: pressing escape to close the dialog",
async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
throw new Error("should not be called");
},
dismiss: () => {
assert.step("Dismiss action");
},
},
},
});
assert.verifySteps([]);
triggerHotkey("escape");
await nextTick();
assert.verifySteps(
["Cancel action", "Close action"],
"dialog has called its cancel method before its closure"
);
});
});
assert.verifySteps([]);
triggerHotkey("escape");
await nextTick();
assert.verifySteps(
["Dismiss action", "Close action"],
"dialog has called its dismiss method before its closure"
);
}
);
QUnit.test(
"Without dismiss callback: clicking on 'X' to close the dialog",
async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
assert.step("Cancel action");
},
},
});
assert.verifySteps([]);
await click(target, ".modal-header .btn-close");
assert.verifySteps(
["Cancel action", "Close action"],
"dialog has called its cancel method before its closure"
);
}
);
QUnit.test(
"With dismiss callback: clicking on 'X' to close the dialog",
async function (assert) {
const env = await makeDialogTestEnv();
await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
close: () => {
assert.step("Close action");
},
confirm: () => {
throw new Error("should not be called");
},
cancel: () => {
throw new Error("should not be called");
},
dismiss: () => {
assert.step("Dismiss action");
},
},
});
assert.verifySteps([]);
await click(target, ".modal-header .btn-close");
assert.verifySteps(
["Dismiss action", "Close action"],
"dialog has called its dismiss method before its closure"
);
}
);
QUnit.test("clicking on 'Ok'", async function (assert) {
const env = await makeDialogTestEnv();
@ -75,6 +174,9 @@ QUnit.module("Components", (hooks) => {
cancel: () => {
throw new Error("should not be called");
},
dismiss: () => {
throw new Error("should not be called");
},
},
});
assert.verifySteps([]);
@ -98,6 +200,9 @@ QUnit.module("Components", (hooks) => {
cancel: () => {
assert.step("Cancel action");
},
dismiss: () => {
throw new Error("should not be called");
},
},
});
assert.verifySteps([]);
@ -202,4 +307,42 @@ QUnit.module("Components", (hooks) => {
await nextTick();
assert.verifySteps(["close"]);
});
QUnit.test("Focus is correctly restored after confirmation", async function (assert) {
const env = await makeDialogTestEnv();
class MyComp extends Component {}
MyComp.template = xml`<div class="my-comp"><input type="text" class="my-input"/></div>`;
await mount(MyComp, target, { env });
target.querySelector(".my-input").focus();
assert.strictEqual(document.activeElement, target.querySelector(".my-input"));
const comp = await mount(ConfirmationDialog, target, {
env,
props: {
body: "Some content",
title: "Confirmation",
confirm: () => {},
close: () => {},
},
});
assert.strictEqual(
document.activeElement,
target.querySelector(".modal-footer .btn-primary")
);
await click(target, ".modal-footer .btn-primary");
assert.strictEqual(
document.activeElement,
document.body,
"As the button is disabled, the focus is now on the body"
);
destroy(comp);
await Promise.resolve();
assert.strictEqual(
document.activeElement,
target.querySelector(".my-input"),
"After destruction of the dialog, the focus is restored to the input"
);
});
});

View file

@ -1,8 +1,8 @@
/** @odoo-module **/
import { makeContext } from "@web/core/context";
import { evalPartialContext, makeContext } from "@web/core/context";
QUnit.module("utils", {}, () => {
QUnit.module("Context", {}, () => {
QUnit.module("makeContext");
QUnit.test("return empty context", (assert) => {
@ -42,4 +42,38 @@ QUnit.module("utils", {}, () => {
assert.deepEqual(makeContext(["{'a': a + 1}"], { a: 1 }), { a: 2 });
assert.deepEqual(makeContext(["{'b': a + 1}"], { a: 1 }), { b: 2 });
});
QUnit.module("evalPartialContext");
QUnit.test("static contexts", (assert) => {
assert.deepEqual(evalPartialContext("{}", {}), {});
assert.deepEqual(evalPartialContext("{'a': 1}", {}), { a: 1 });
assert.deepEqual(evalPartialContext("{'a': 'b'}", {}), { a: "b" });
assert.deepEqual(evalPartialContext("{'a': true}", {}), { a: true });
assert.deepEqual(evalPartialContext("{'a': None}", {}), { a: null });
});
QUnit.test("complete dynamic contexts", (assert) => {
assert.deepEqual(evalPartialContext("{'a': a, 'b': 1}", { a: 2 }), { a: 2, b: 1 });
});
QUnit.test("partial dynamic contexts", (assert) => {
assert.deepEqual(evalPartialContext("{'a': a}", {}), {});
assert.deepEqual(evalPartialContext("{'a': a, 'b': 1}", {}), { b: 1 });
assert.deepEqual(evalPartialContext("{'a': a, 'b': b}", { a: 2 }), { a: 2 });
});
QUnit.test("value of type obj (15)", (assert) => {
assert.deepEqual(evalPartialContext("{'a': a.b.c}", {}), {});
assert.deepEqual(evalPartialContext("{'a': a.b.c}", { a: {} }), {});
assert.deepEqual(evalPartialContext("{'a': a.b.c}", { a: { b: { c: 2 } } }), { a: 2 });
});
QUnit.test("value of type op (14)", (assert) => {
assert.deepEqual(evalPartialContext("{'a': a + 1}", {}), {});
assert.deepEqual(evalPartialContext("{'a': a + b}", {}), {});
assert.deepEqual(evalPartialContext("{'a': a + b}", { a: 2 }), {});
assert.deepEqual(evalPartialContext("{'a': a + 1}", { a: 2 }), { a: 3 });
assert.deepEqual(evalPartialContext("{'a': a + b}", { a: 2, b: 3 }), { a: 5 });
});
});

View file

@ -0,0 +1,78 @@
/** @odoo-module **/
import { defaultLocalization } from "@web/../tests/helpers/mock_services";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { currencies, formatCurrency } from "@web/core/currency";
import { session } from "@web/session";
QUnit.module("utils", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
});
QUnit.module("Currency");
QUnit.test("formatCurrency", function (assert) {
patchWithCleanup(currencies, {
10: {
digits: [69, 2],
position: "after",
symbol: "€",
},
11: {
digits: [69, 2],
position: "before",
symbol: "$",
},
12: {
digits: [69, 2],
position: "after",
symbol: "&",
},
});
assert.strictEqual(formatCurrency(200), "200.00");
assert.deepEqual(formatCurrency(1234567.654, 10), "1,234,567.65\u00a0€");
assert.deepEqual(formatCurrency(1234567.654, 11), "$\u00a01,234,567.65");
assert.deepEqual(formatCurrency(1234567.654, 44), "1,234,567.65");
assert.deepEqual(
formatCurrency(1234567.654, 10, { noSymbol: true }),
"1,234,567.65"
);
assert.deepEqual(
formatCurrency(8.0, 10, { humanReadable: true }),
"8.00\u00a0€"
);
assert.deepEqual(
formatCurrency(1234567.654, 10, { humanReadable: true }),
"1.23M\u00a0€"
);
assert.deepEqual(
formatCurrency(1990000.001, 10, { humanReadable: true }),
"1.99M\u00a0€"
);
assert.deepEqual(
formatCurrency(1234567.654, 44, { digits: [69, 1] }),
"1,234,567.7"
);
assert.deepEqual(
formatCurrency(1234567.654, 11, { digits: [69, 1] }),
"$\u00a01,234,567.7",
"options digits should take over currency digits when both are defined"
);
});
QUnit.test("formatCurrency without currency", function (assert) {
patchWithCleanup(session, {
currencies: {},
});
assert.deepEqual(
formatCurrency(1234567.654, 10, { humanReadable: true }),
"1.23M"
);
assert.deepEqual(formatCurrency(1234567.654, 10), "1,234,567.65");
});
});

View file

@ -1,806 +0,0 @@
/** @odoo-module **/
import { Component, useState, xml } from "@odoo/owl";
import { applyFilter, toggleMenu } from "@web/../tests/search/helpers";
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import ActionModel from "web.ActionModel";
import CustomFilterItem from "web.CustomFilterItem";
import { createComponent } from "web.test_utils";
import { editSelect } from "web.test_utils_fields";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import {
click,
editInput,
getFixture,
mount,
nextTick,
patchWithCleanup,
triggerEvent,
} from "../helpers/utils";
const { DateTime, Settings } = luxon;
const serviceRegistry = registry.category("services");
let target;
/**
* @param {typeof DatePicker} Picker
* @param {Object} props
* @returns {Promise<DatePicker>}
*/
async function mountPicker(Picker, props) {
serviceRegistry
.add(
"localization",
makeFakeLocalizationService({
dateFormat: "dd/MM/yyyy",
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
})
)
.add("ui", uiService)
.add("hotkey", hotkeyService);
class Parent extends Component {
setup() {
this.state = useState(props);
}
onDateChange(date) {
if (props.onDateTimeChanged) {
props.onDateTimeChanged(date);
}
this.state.date = date;
}
}
Parent.template = xml/* xml */ `
<t t-component="props.Picker" t-props="state" onDateTimeChanged.bind="onDateChange" />
`;
const env = await makeTestEnv();
return await mount(Parent, target, { env, props: { Picker } });
}
function useFRLocale() {
if (!window.moment.locales().includes("fr")) {
// Mocks the FR locale if not loaded
const originalLocale = window.moment.locale();
window.moment.defineLocale("fr", {
months: "janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split(
"_"
),
monthsShort: "janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split(
"_"
),
code: "fr",
monthsParseExact: true,
week: { dow: 1, doy: 4 },
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("fr", null));
}
return "fr";
}
var symbolMap = {
1: "૧",
2: "૨",
3: "૩",
4: "૪",
5: "૫",
6: "૬",
7: "૭",
8: "૮",
9: "૯",
0: "",
};
var numberMap = {
"૧": "1",
"૨": "2",
"૩": "3",
"૪": "4",
"૫": "5",
"૬": "6",
"૭": "7",
"૮": "8",
"૯": "9",
"": "0",
};
function useGULocale() {
if (!window.moment.locales().includes("gu")) {
const originalLocale = window.moment.locale();
window.moment.defineLocale("gu", {
months: "જાન્યુઆરી_ફેબ્રુઆરી_માર્ચ_એપ્રિલ_મે_જૂન_જુલાઈ_ઑગસ્ટ_સપ્ટેમ્બર_ઑક્ટ્બર_નવેમ્બર_ડિસેમ્બર".split(
"_"
),
monthsShort: "જાન્યુ._ફેબ્રુ._માર્ચ_એપ્રિ._મે_જૂન_જુલા._ઑગ._સપ્ટે._ઑક્ટ્._નવે._ડિસે.".split(
"_"
),
monthsParseExact: true,
week: {
dow: 0, // Sunday is the first day of the week.
doy: 6, // The week that contains Jan 1st is the first week of the year.
},
preparse: function (string) {
return string.replace(/[૧૨૩૪૫૬૭૮૯૦]/g, function (match) {
return numberMap[match];
});
},
postformat: function (string) {
return string.replace(/\d/g, function (match) {
return symbolMap[match];
});
},
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("gu", null));
}
return "gu";
}
function useNOLocale() {
if (!window.moment.locales().includes("nb")) {
const originalLocale = window.moment.locale();
window.moment.defineLocale("nb", {
months: "januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split(
"_"
),
monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"),
monthsParseExact: true,
week: {
dow: 1, // Monday is the first day of the week.
doy: 4, // The week that contains Jan 4th is the first week of the year.
},
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("nb", null));
}
return "nb";
}
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(() => {
target = getFixture();
});
QUnit.module("DatePicker");
QUnit.test("basic rendering", async function (assert) {
assert.expect(8);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
});
assert.containsOnce(target, "input.o_input.o_datepicker_input");
assert.containsOnce(target, "span.o_datepicker_button");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
const datePicker = target.querySelector(".o_datepicker");
const input = datePicker.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997", "Value should be the one given");
assert.strictEqual(
datePicker.dataset.targetInput,
`#${datePicker.querySelector("input[type=hidden]").id}`,
"DatePicker id should match its input target"
);
await click(input);
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"01/09/1997",
"Datepicker should have set the correct day"
);
});
QUnit.test("pick a date", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
await click(input);
await click(document.querySelector(".datepicker th.next")); // next month
assert.verifySteps([]);
await click(document.querySelectorAll(".datepicker table td")[15]); // previous day
assert.strictEqual(input.value, "08/02/1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale given in props)", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd MMM, yyyy",
locale: useFRLocale(),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
assert.strictEqual(input.value, "01 sept., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale from date props)", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
zone: "utc",
locale: useFRLocale(),
}),
format: "dd MMM, yyyy",
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
assert.strictEqual(input.value, "01 sept., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale with different symbols)", async function (assert) {
assert.expect(6);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
zone: "utc",
locale: useGULocale(),
}),
format: "dd MMM, yyyy",
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
await click(input);
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelectorAll(".datepicker .day")[1]); // first day of september
assert.strictEqual(input.value, "01 સપ્ટે, 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a date value", async function (assert) {
assert.expect(5);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.verifySteps([]);
input.value = "08/02/1997";
await triggerEvent(target, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"02/08/1997",
"Datepicker should have set the correct day"
);
});
QUnit.test("Date format is correctly set", async function (assert) {
assert.expect(2);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "yyyy/MM/dd",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "1997/01/09");
});
QUnit.test("Validate input date with 'Enter'", async (assert) => {
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd/MM/yyyy",
});
const input = target.querySelector(".o_datepicker_input");
await click(input);
assert.strictEqual(input.value, "09/01/1997");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
input.value = "23/03/2022";
await triggerEvent(input, null, "keydown", { key: "Enter" });
assert.strictEqual(input.value, "23/03/2022");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
});
QUnit.test("Validate input date with 'Escape'", async (assert) => {
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd/MM/yyyy",
});
const input = target.querySelector(".o_datepicker_input");
await click(input);
assert.strictEqual(input.value, "09/01/1997");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
input.value = "23/03/2022";
await triggerEvent(input, null, "keydown", { key: "Escape" });
assert.strictEqual(input.value, "23/03/2022");
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
});
QUnit.module("DateTimePicker");
QUnit.test("basic rendering", async function (assert) {
assert.expect(11);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
});
assert.containsOnce(target, "input.o_input.o_datepicker_input");
assert.containsOnce(target, "span.o_datepicker_button");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
const datePicker = target.querySelector(".o_datepicker");
const input = datePicker.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given");
assert.strictEqual(
datePicker.dataset.targetInput,
`#${datePicker.querySelector("input[type=hidden]").id}`,
"DateTimePicker id should match its input target"
);
await click(input);
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"01/09/1997",
"Datepicker should have set the correct day"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
"12",
"Datepicker should have set the correct hour"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
"30",
"Datepicker should have set the correct minute"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
"01",
"Datepicker should have set the correct second"
);
});
QUnit.test("pick a date and time", async function (assert) {
assert.expect(5);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
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("input.o_input.o_datepicker_input");
await click(input);
await click(document.querySelector(".datepicker th.next")); // February
await click(document.querySelectorAll(".datepicker table td")[15]); // 08
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector(".timepicker .timepicker-hour"));
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
await click(document.querySelector(".timepicker .timepicker-minute"));
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
await click(document.querySelector(".timepicker .timepicker-second"));
assert.verifySteps([]);
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date and time with locale", async function (assert) {
assert.expect(6);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "dd MMM, yyyy HH:mm:ss",
locale: useFRLocale(),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"01/09/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997 12:30:01");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector(".timepicker .timepicker-hour"));
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
await click(document.querySelector(".timepicker .timepicker-minute"));
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
await click(document.querySelector(".timepicker .timepicker-second"));
assert.verifySteps([]);
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
assert.strictEqual(input.value, "01 sept., 1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a time with 12 hour format locale", async function (assert) {
assert.expect(6);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy hh:mm:ss"),
format: "dd/MM/yyyy hh:mm:ss",
locale: useFRLocale(),
onDateTimeChanged: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy HH:mm:ss"),
"09/01/1997 20:30:02",
"The new time should be in the afternoon"
);
},
});
const input = target.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997 08:30:01");
await click(input);
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector('a[title="Increment Second"]'));
await click(document.querySelector('button[title="Toggle Period"]'));
assert.verifySteps([]);
await click(document.querySelector('a[title="Close the picker"]'));
assert.strictEqual(input.value, "09/01/1997 08:30:02");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a datetime value", async function (assert) {
assert.expect(9);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
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.verifySteps([]);
input.value = "08/02/1997 15:45:05";
await triggerEvent(target, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"02/08/1997",
"Datepicker should have set the correct day"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
"15",
"Datepicker should have set the correct hour"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
"45",
"Datepicker should have set the correct minute"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
"05",
"Datepicker should have set the correct second"
);
});
QUnit.test("Date time format is correctly set", async function (assert) {
assert.expect(2);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "HH:mm:ss yyyy/MM/dd",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "12:30:01 1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "12:30:01 1997/01/09");
});
QUnit.test("Datepicker works with norwegian locale", async (assert) => {
assert.expect(6);
await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "dd MMM, yyyy",
locale: useNOLocale(),
onDateTimeChanged(date) {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/04/1997",
"Event should transmit the correct date"
);
},
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 apr., 1997");
await click(input);
assert.strictEqual(input.value, "09 apr., 1997");
const days = [...document.querySelectorAll(".datepicker .day")];
await click(days.find((d) => d.innerText.trim() === "1")); // first day of april
assert.strictEqual(input.value, "01 apr., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("Datepicker works with dots and commas in format", async (assert) => {
assert.expect(2);
await mountPicker(DateTimePicker, {
date: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
format: "dd.MM,yyyy",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "10.03,2023");
await click(input);
assert.strictEqual(input.value, "10.03,2023");
});
QUnit.test("custom filter date", async function (assert) {
assert.expect(3);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, "createNewFilters");
const preFilters = args[0];
const preFilter = preFilters[0];
assert.strictEqual(
preFilter.description,
'A date is equal to "05/05/2005"',
"description should be in localized format"
);
assert.deepEqual(
preFilter.domain,
'[["date_field","=","2005-05-05"]]',
"domain should be in UTC format"
);
}
}
const searchModel = new MockedSearchModel();
const date_field = { name: "date_field", string: "A date", type: "date", searchable: true };
await createComponent(CustomFilterItem, {
props: {
fields: { date_field },
},
env: { searchModel },
});
await toggleMenu(target, "Add Custom Filter");
await editSelect(target.querySelector(".o_generator_menu_field"), "date_field");
const valueInput = target.querySelector(".o_generator_menu_value .o_input");
await click(valueInput);
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");
});
QUnit.test("arab locale, latin numbering system as input", async (assert) => {
const dateFormat = "dd MMM, yyyy";
const timeFormat = "hh:mm:ss";
const dateTimeFormat = `${dateFormat} ${timeFormat}`;
patchWithCleanup(localization, { dateFormat, timeFormat, dateTimeFormat });
patchWithCleanup(Settings, {
defaultLocale: "ar-001",
defaultNumberingSystem: "arab",
});
await mountPicker(DateTimePicker, {
format: dateTimeFormat,
});
const input = target.querySelector(".o_datepicker_input");
await editInput(input, null, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
assert.strictEqual(input.value, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
await editInput(input, null, "15 07, 2020 12:30:43");
assert.strictEqual(input.value, "١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
});
QUnit.test("keep date between component and datepicker in sync", async (assert) => {
const parent = await mountPicker(DatePicker, {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
format: "dd/MM/yyyy",
});
const input = target.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997");
await nextTick();
await click(input);
assert.hasClass(document.querySelector("td.day[data-day='01/09/1997']"), "active");
// Change the date of the component externally (not through the
// datepicker interface)
parent.state.date = parent.state.date.plus({ days: 1 });
await nextTick();
assert.strictEqual(input.value, "10/01/1997");
assert.hasClass(document.querySelector("td.day[data-day='01/10/1997']"), "active");
parent.state.date = false;
await nextTick();
assert.strictEqual(input.value, "");
assert.containsN(document.body, "td.day", 42);
assert.containsNone(document.body, "td.day.active");
});
});

View file

@ -0,0 +1,182 @@
/** @odoo-module **/
import { Component, reactive, useState, xml } from "@odoo/owl";
import { clearRegistryWithCleanup, makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { editInput, getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
import { useDateTimePicker } from "@web/core/datetime/datetime_hook";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
const { DateTime } = luxon;
/**
* @param {() => any} setup
*/
const mountInput = async (setup) => {
const env = await makeTestEnv();
await mount(Root, getFixture(), { env, props: { setup } });
return fixture.querySelector(".datetime_hook_input");
};
class Root extends Component {
static components = { DateTimeInput };
static template = xml`
<input type="text" class="datetime_hook_input" t-ref="start-date" />
<t t-foreach="mainComponentEntries" t-as="comp" t-key="comp[0]">
<t t-component="comp[1].Component" t-props="comp[1].props" />
</t>
`;
setup() {
this.mainComponentEntries = mainComponentRegistry.getEntries();
this.props.setup();
}
}
const mainComponentRegistry = registry.category("main_components");
const serviceRegistry = registry.category("services");
let fixture;
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(() => {
clearRegistryWithCleanup(mainComponentRegistry);
serviceRegistry
.add("hotkey", hotkeyService)
.add(
"localization",
makeFakeLocalizationService({
dateFormat: "dd/MM/yyyy",
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
})
)
.add("popover", popoverService)
.add("ui", uiService)
.add("datetime_picker", datetimePickerService);
fixture = getFixture();
});
QUnit.module("DateTime hook");
QUnit.test("reactivity: update inert object", async (assert) => {
const pickerProps = {
value: false,
type: "date",
};
const input = await mountInput(() => {
useDateTimePicker({ pickerProps });
});
assert.strictEqual(input.value, "");
pickerProps.value = DateTime.fromSQL("2023-06-06");
await nextTick();
assert.strictEqual(input.value, "");
});
QUnit.test("reactivity: useState & update getter object", async (assert) => {
const pickerProps = reactive({
value: false,
type: "date",
});
const input = await mountInput(() => {
const state = useState(pickerProps);
state.value; // artificially subscribe to value
useDateTimePicker({
get pickerProps() {
return pickerProps;
},
});
});
assert.strictEqual(input.value, "");
pickerProps.value = DateTime.fromSQL("2023-06-06");
await nextTick();
assert.strictEqual(input.value, "06/06/2023");
});
QUnit.test("reactivity: update reactive object returned by the hook", async (assert) => {
let pickerProps;
const defaultPickerProps = {
value: false,
type: "date",
};
const input = await mountInput(() => {
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
});
assert.strictEqual(input.value, "");
assert.strictEqual(pickerProps.value, false);
pickerProps.value = DateTime.fromSQL("2023-06-06");
await nextTick();
assert.strictEqual(input.value, "06/06/2023");
});
QUnit.test("returned value is updated when input has changed", async (assert) => {
let pickerProps;
const defaultPickerProps = {
value: false,
type: "date",
};
const input = await mountInput(() => {
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
});
assert.strictEqual(input.value, "");
assert.strictEqual(pickerProps.value, false);
await editInput(input, null, "06/06/2023");
assert.strictEqual(pickerProps.value.toSQL().split(" ")[0], "2023-06-06");
});
QUnit.test("value is not updated if it did not change", async (assert) => {
const getShortDate = (date) => date.toSQL().split(" ")[0];
let pickerProps;
const defaultPickerProps = {
value: DateTime.fromSQL("2023-06-06"),
type: "date",
};
const input = await mountInput(() => {
pickerProps = useDateTimePicker({
pickerProps: defaultPickerProps,
onApply: (value) => {
assert.step(getShortDate(value));
},
}).state;
});
assert.strictEqual(input.value, "06/06/2023");
assert.strictEqual(getShortDate(pickerProps.value), "2023-06-06");
await editInput(input, null, "06/06/2023");
assert.strictEqual(getShortDate(pickerProps.value), "2023-06-06");
assert.verifySteps([]);
await editInput(input, null, "07/06/2023");
assert.strictEqual(getShortDate(pickerProps.value), "2023-06-07");
assert.verifySteps(["2023-06-07"]);
});
});

View file

@ -0,0 +1,549 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { clearRegistryWithCleanup, makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import {
click,
editInput,
editSelect,
getFixture,
mount,
patchWithCleanup,
triggerEvent,
} from "@web/../tests/helpers/utils";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { localization } from "@web/core/l10n/localization";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import {
assertDateTimePicker,
getPickerCell,
getTimePickers,
zoomOut,
} from "./datetime_test_helpers";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
const { DateTime } = luxon;
/**
* @typedef {import("@web/core/datetime/datetime_input").DateTimeInputProps} DateTimeInputProps
*/
/**
* @param {DateTimeInputProps} props
*/
const mountInput = async (props) => {
const env = await makeTestEnv();
await mount(Root, getFixture(), { env, props });
return fixture.querySelector(".o_datetime_input");
};
class Root extends Component {
static components = { DateTimeInput };
static template = xml`
<DateTimeInput t-props="props" />
<t t-foreach="mainComponentEntries" t-as="comp" t-key="comp[0]">
<t t-component="comp[1].Component" t-props="comp[1].props" />
</t>
`;
setup() {
this.mainComponentEntries = mainComponentRegistry.getEntries();
}
}
const mainComponentRegistry = registry.category("main_components");
const serviceRegistry = registry.category("services");
let fixture;
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(() => {
clearRegistryWithCleanup(mainComponentRegistry);
serviceRegistry
.add("hotkey", hotkeyService)
.add(
"localization",
makeFakeLocalizationService({
dateFormat: "dd/MM/yyyy",
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
})
)
.add("popover", popoverService)
.add("ui", uiService)
.add("datetime_picker", datetimePickerService);
fixture = getFixture();
});
QUnit.module("DateTimeInput (date)");
QUnit.test("basic rendering", async function (assert) {
await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
});
assert.containsOnce(fixture, ".o_datetime_input");
assertDateTimePicker(false);
const input = fixture.querySelector(".o_datetime_input");
assert.strictEqual(input.value, "09/01/1997", "Value should be the one given");
await click(input);
assertDateTimePicker({
title: "January 1997",
date: [
{
cells: [
[-29, -30, -31, 1, 2, 3, 4],
[5, 6, 7, 8, [9], 10, 11],
[12, 13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24, 25],
[26, 27, 28, 29, 30, 31, -1],
[-2, -3, -4, -5, -6, -7, -8],
],
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
weekNumbers: [1, 2, 3, 4, 5, 6],
},
],
});
});
QUnit.test("pick a date", async function (assert) {
assert.expect(5);
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
onChange: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
await click(input);
await click(getFixture(), ".o_datetime_picker .o_next"); // next month
assert.verifySteps([]);
await click(getPickerCell("8").at(0));
assert.strictEqual(input.value, "08/02/1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with FR locale", async function (assert) {
assert.expect(5);
patchWithCleanup(luxon.Settings, { defaultLocale: "fr-FR" });
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
format: "dd MMM, yyyy",
onChange: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
assert.strictEqual(input.value, "09 janv., 1997");
await click(input);
await zoomOut();
await click(getPickerCell("sept."));
await click(getPickerCell("1").at(0));
assert.strictEqual(input.value, "01 sept., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale (locale with different symbols)", async function (assert) {
assert.expect(6);
patchWithCleanup(luxon.Settings, { defaultLocale: "gu" });
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
format: "dd MMM, yyyy",
onChange: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
await click(input);
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
await zoomOut();
await click(getPickerCell("સપ્ટે"));
await click(getPickerCell("1").at(0));
assert.strictEqual(input.value, "01 સપ્ટે, 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a date value", async function (assert) {
assert.expect(5);
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
onChange: (date) => {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
assert.verifySteps([]);
await editInput(input, null, "08/02/1997");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.hasClass(getPickerCell("8").at(0), "o_selected");
});
QUnit.test("Date format is correctly set", async function (assert) {
assert.expect(2);
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
format: "yyyy/MM/dd",
});
assert.strictEqual(input.value, "1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "1997/01/09");
});
QUnit.module("DateTimeInput (datetime)");
QUnit.test("basic rendering", async function (assert) {
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
});
assert.containsOnce(fixture, ".o_datetime_input");
assertDateTimePicker(false);
assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given");
await click(input);
assertDateTimePicker({
title: "January 1997",
date: [
{
cells: [
[-29, -30, -31, 1, 2, 3, 4],
[5, 6, 7, 8, [9], 10, 11],
[12, 13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24, 25],
[26, 27, 28, 29, 30, 31, -1],
[-2, -3, -4, -5, -6, -7, -8],
],
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
weekNumbers: [1, 2, 3, 4, 5, 6],
},
],
time: [[12, 30]],
});
});
QUnit.test("pick a date and time", async function (assert) {
assert.expect(6);
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
onChange: (date) => assert.step(date.toSQL().split(".")[0]),
});
assert.strictEqual(input.value, "09/01/1997 12:30:01");
await click(input);
// Select February 8th
await click(getFixture(), ".o_datetime_picker .o_next");
await click(getPickerCell("8").at(0));
// Select 15:45
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await editSelect(hourSelect, null, "15");
await editSelect(minuteSelect, null, "45");
assert.strictEqual(input.value, "08/02/1997 15:45:01");
assert.verifySteps(["1997-02-08 12:30:01", "1997-02-08 15:30:01", "1997-02-08 15:45:01"]);
});
QUnit.test("pick a date and time with locale", async function (assert) {
assert.expect(6);
patchWithCleanup(luxon.Settings, { defaultLocale: "fr-FR" });
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd MMM, yyyy HH:mm:ss",
onChange: (date) => assert.step(date.toSQL().split(".")[0]),
});
assert.strictEqual(input.value, "09 janv., 1997 12:30:01");
await click(input);
// Select September 1st
await zoomOut();
await click(getPickerCell("sept."));
await click(getPickerCell("1").at(0));
// Select 15:45
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await editSelect(hourSelect, null, "15");
await editSelect(minuteSelect, null, "45");
assert.strictEqual(input.value, "01 sept., 1997 15:45:01");
assert.verifySteps(["1997-09-01 12:30:01", "1997-09-01 15:30:01", "1997-09-01 15:45:01"]);
});
QUnit.test("pick a time with 12 hour format without meridiem", async function (assert) {
assert.expect(3);
patchWithCleanup(localization, {
dateFormat: "dd/MM/yyyy",
dateTimeFormat: "dd/MM/yyyy hh:mm:ss",
timeFormat: "hh:mm:ss",
});
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
onChange: (date) => assert.step(date.toSQL().split(".")[0]),
});
assert.strictEqual(input.value, "09/01/1997 08:30:01");
await click(input);
const [, minuteSelect] = getTimePickers().at(0);
await editSelect(minuteSelect, null, "15");
assert.verifySteps(["1997-01-09 08:15:01"]);
});
QUnit.test("enter a datetime value", async function (assert) {
assert.expect(7);
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
onChange: (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"
);
},
});
assert.verifySteps([]);
input.value = "08/02/1997 15:45:05";
await triggerEvent(fixture, ".o_datetime_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.hasClass(getPickerCell("8").at(0), "o_selected");
assert.deepEqual(getTimePickers({ parse: true }).at(0), [15, 45]);
});
QUnit.test("Date time format is correctly set", async function (assert) {
assert.expect(2);
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "HH:mm:ss yyyy/MM/dd",
});
assert.strictEqual(input.value, "12:30:01 1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "12:30:01 1997/01/09");
});
QUnit.test("Datepicker works with norwegian locale", async (assert) => {
assert.expect(6);
patchWithCleanup(luxon.Settings, { defaultLocale: "nb-NO" });
const input = await mountInput({
value: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd MMM, yyyy",
onChange(date) {
assert.step("datetime-changed");
assert.strictEqual(
date.toFormat("dd/MM/yyyy"),
"01/04/1997",
"Event should transmit the correct date"
);
},
});
assert.strictEqual(input.value, "09 apr., 1997");
await click(input);
assert.strictEqual(input.value, "09 apr., 1997");
await click(getPickerCell("1").at(0));
assert.strictEqual(input.value, "01 apr., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("Datepicker works with dots and commas in format", async (assert) => {
assert.expect(2);
const input = await mountInput({
value: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd.MM,yyyy",
});
assert.strictEqual(input.value, "10.03,2023");
await click(input);
assert.strictEqual(input.value, "10.03,2023");
});
QUnit.test("start with no value", async function (assert) {
assert.expect(6);
const input = await mountInput({
type: "datetime",
onChange(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"
);
},
});
assert.strictEqual(input.value, "");
assert.verifySteps([]);
await editInput(input, null, "08/02/1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
assert.strictEqual(input.value, "08/02/1997 15:45:05");
});
QUnit.test("Clicking close button closes datetime picker", async function (assert) {
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
type: "datetime",
format: "dd MMM, yyyy HH:mm:ss",
});
await click(input);
await click(getFixture(), ".o_datetime_picker .o_datetime_buttons .btn-secondary");
assert.strictEqual(
getFixture().querySelector(".o_datetime_picker"),
null,
"Datetime picker is closed"
);
});
QUnit.test("arab locale, latin numbering system as input", async (assert) => {
patchWithCleanup(localization, {
dateFormat: "dd MMM, yyyy",
dateTimeFormat: "dd MMM, yyyy hh:mm:ss",
timeFormat: "hh:mm:ss",
});
patchWithCleanup(luxon.Settings, {
defaultLocale: "ar-001",
defaultNumberingSystem: "arab",
});
const input = await mountInput();
await editInput(input, null, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
assert.strictEqual(input.value, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
await editInput(input, null, "15 07, 2020 12:30:43");
assert.strictEqual(input.value, "١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
});
QUnit.test("check datepicker in localization with textual month format", async function (assert) {
assert.expect(3);
let onChangeDate;
Object.assign(localization, {
dateFormat: 'MMM/dd/yyyy',
timeFormat: 'HH:mm:ss',
dateTimeFormat: 'MMM/dd/yyyy HH:mm:ss',
});
const input = await mountInput({
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
type: "date",
onChange: date => onChangeDate = date,
});
assert.strictEqual(input.value, "Jan/09/1997");
await click(input);
await click(getPickerCell("5").at(0));
assert.strictEqual(input.value, "Jan/05/1997");
assert.strictEqual(onChangeDate.toFormat("dd/MM/yyyy"), "05/01/1997");
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
/** @odoo-module **/
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { ensureArray } from "@web/core/utils/arrays";
import { click, getFixture } from "../../helpers/utils";
/**
* @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps
*/
/**
* @param {false | {
* title?: string | string[],
* date?: {
* cells: (number | string | [number] | [string])[][],
* daysOfWeek?: string[],
* weekNumbers?: number[],
* }[],
* time?: ([number, number] | [number, number, "AM" | "PM"])[],
* }} expectedParams
*/
export function assertDateTimePicker(expectedParams) {
const assert = QUnit.assert;
const fixture = getFixture();
// Check for picker in DOM
if (expectedParams) {
assert.containsOnce(fixture, ".o_datetime_picker");
} else {
assert.containsNone(fixture, ".o_datetime_picker");
return;
}
const { title, date, time } = expectedParams;
// Title
if (title) {
const expectedTitle = ensureArray(title);
assert.containsOnce(fixture, ".o_datetime_picker_header");
assert.deepEqual(
getTexts(".o_datetime_picker_header", "strong"),
expectedTitle,
`title should be "${expectedTitle.join(" - ")}"`
);
} else {
assert.containsNone(fixture, ".o_datetime_picker_header");
}
// Time picker
if (time) {
assert.containsN(fixture, ".o_time_picker", time.length);
const timePickers = select(".o_time_picker");
for (let i = 0; i < time.length; i++) {
const expectedTime = time[i];
const values = select(timePickers[i], ".o_time_picker_select").map((sel) => sel.value);
const actual = [...values.slice(0, 2).map(Number), ...values.slice(2)];
assert.deepEqual(actual, expectedTime, `time values should be [${expectedTime}]`);
}
} else {
assert.containsNone(fixture, ".o_time_picker");
}
// Date picker
const datePickerEls = select(".o_date_picker");
assert.containsN(fixture, ".o_date_picker", date.length);
let selectedCells = 0;
let outOfRangeCells = 0;
let todayCells = 0;
for (let i = 0; i < date.length; i++) {
const { cells, daysOfWeek, weekNumbers } = date[i];
const datePickerEl = datePickerEls[i];
const cellEls = select(datePickerEl, ".o_date_item_cell");
assert.strictEqual(
cellEls.length,
PICKER_ROWS * PICKER_COLS,
`picker should have ${
PICKER_ROWS * PICKER_COLS
} cells (${PICKER_ROWS} rows and ${PICKER_COLS} columns)`
);
if (daysOfWeek) {
const actualDow = getTexts(datePickerEl, ".o_day_of_week_cell");
assert.deepEqual(
actualDow,
daysOfWeek,
`picker should display the days of week: ${daysOfWeek
.map((dow) => `"${dow}"`)
.join(", ")}`
);
}
if (weekNumbers) {
assert.deepEqual(
getTexts(datePickerEl, ".o_week_number_cell").map(Number),
weekNumbers,
`picker should display the week numbers (${weekNumbers.join(", ")})`
);
}
// Date cells
const expectedCells = cells.flatMap((row, rowIndex) =>
row.map((cell, colIndex) => {
const cellEl = cellEls[rowIndex * PICKER_COLS + colIndex];
// Check flags
let value = cell;
const isSelected = Array.isArray(cell);
if (isSelected) {
value = value[0];
}
const isToday = typeof value === "string";
if (isToday) {
value = Number(value);
}
const isOutOfRange = value < 0;
if (isOutOfRange) {
value = Math.abs(value);
}
// Assert based on flags
if (isSelected) {
selectedCells++;
assert.hasClass(cellEl, "o_selected");
}
if (isOutOfRange) {
outOfRangeCells++;
assert.hasClass(cellEl, "o_out_of_range");
}
if (isToday) {
todayCells++;
assert.hasClass(cellEl, "o_today");
}
return value;
})
);
assert.deepEqual(
cellEls.map((cell) => Number(getTexts(cell)[0])),
expectedCells,
`cell content should match the expected values: [${expectedCells.join(", ")}]`
);
}
assert.containsN(fixture, ".o_selected", selectedCells);
assert.containsN(fixture, ".o_out_of_range", outOfRangeCells);
assert.containsN(fixture, ".o_today", todayCells);
}
export function getPickerApplyButton() {
return select(".o_datetime_picker .o_datetime_buttons .o_apply").at(0);
}
/**
* @param {RegExp | string} expr
*/
export function getPickerCell(expr) {
const regex = expr instanceof RegExp ? expr : new RegExp(`^${expr}$`, "i");
const cells = select(".o_datetime_picker .o_date_item_cell").filter((cell) =>
regex.test(getTexts(cell)[0])
);
return cells.length === 1 ? cells[0] : cells;
}
/**
* @param {...(string | HTMLElement)} selectors
* @returns {string[]}
*/
export function getTexts(...selectors) {
return select(...selectors).map((e) => e.innerText.trim().replace(/\s+/g, " "));
}
/**
* @param {Object} [options={}]
* @param {boolean} [options.parse=false] whether to directly return the parsed
* values of the select elements
* @returns {HTMLSelectElement[] | (number | string)[]}
*/
export function getTimePickers({ parse = false } = {}) {
return select(".o_time_picker").map((timePickerEl) => {
const selects = select(timePickerEl, ".o_time_picker_select");
if (parse) {
return selects.map((sel) => (isNaN(sel.value) ? sel.value : Number(sel.value)));
} else {
return selects;
}
});
}
/**
* @param {...(string | HTMLElement)} selectors
* @returns {HTMLElement[]}
*/
const select = (...selectors) => {
const root = selectors[0] instanceof Element ? selectors.shift() : getFixture();
return selectors.length ? [...root.querySelectorAll(selectors.join(" "))] : [root];
};
export function useTwelveHourClockFormat() {
const { dateFormat = "dd/MM/yyyy", timeFormat = "HH:mm:ss" } = localization;
const twcTimeFormat = `${timeFormat.replace(/H/g, "h")} a`;
patchWithCleanup(localization, {
dateTimeFormat: `${dateFormat} ${twcTimeFormat}`,
timeFormat: twcTimeFormat,
});
}
export function zoomOut() {
return click(getFixture(), ".o_zoom_out");
}
const PICKER_ROWS = 6;
const PICKER_COLS = 7;

View file

@ -10,7 +10,7 @@ import { uiService } from "@web/core/ui/ui_service";
import { useSetupView } from "@web/views/view_hook";
import { ActionDialog } from "@web/webclient/actions/action_dialog";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv, utils } from "../../helpers/mock_env";
import { makeTestEnv, prepareRegistriesWithCleanup } from "../../helpers/mock_env";
import {
fakeCompanyService,
fakeCommandService,
@ -22,17 +22,23 @@ import {
click,
getFixture,
getNodesTextContent,
legacyExtraNextTick,
mount,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers";
import { openViewItem } from "@web/webclient/debug_items";
import { editSearchView, editView, setDefaults, viewMetadata } from "@web/views/debug_items";
import {
editSearchView,
editView,
getView,
setDefaults,
viewMetadata,
viewRawRecord,
} from "@web/views/debug_items";
import { fieldService } from "@web/core/field_service";
import { Component, xml } from "@odoo/owl";
const { prepareRegistriesWithCleanup } = utils;
export class DebugMenuParent extends Component {
setup() {
@ -56,6 +62,7 @@ QUnit.module("DebugMenu", (hooks) => {
.add("orm", ormService)
.add("dialog", makeFakeDialogService())
.add("localization", makeFakeLocalizationService())
.add("field", fieldService)
.add("command", fakeCommandService);
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
@ -354,6 +361,41 @@ QUnit.module("DebugMenu", (hooks) => {
assert.containsOnce(target, ".some_view");
});
QUnit.test("get view: basic rendering", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("view").add("getView", getView);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
type: "ir.actions.act_window",
views: [[false, "list"]],
};
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
assert.strictEqual(
target.querySelector(".modal-body").innerText,
`<tree><field name="foo" field_id="foo_0"/></tree>`
);
});
QUnit.test("can edit a pivot view", async (assert) => {
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
@ -381,18 +423,25 @@ QUnit.module("DebugMenu", (hooks) => {
serverData.views["pony,18,pivot"] = "<pivot></pivot>";
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 18 }],
records: [{ id: 18, name: "Edit view" }],
};
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
serverData.views["ir.ui.view,false,search"] = `<search></search>`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal .o_form_view");
assert.containsOnce(target, ".breadcrumb-item");
assert.containsOnce(target, ".o_breadcrumb .active");
assert.strictEqual(target.querySelector(".o_breadcrumb .active").textContent, "Edit view");
assert.strictEqual(target.querySelector(".o_field_widget[name=id]").textContent, "18");
await click(target, ".breadcrumb .o_back_button");
assert.containsOnce(target, ".o_breadcrumb .active");
assert.strictEqual(
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
"18"
target.querySelector(".o_breadcrumb .active").textContent,
"Reporting Ponies"
);
});
@ -417,20 +466,19 @@ QUnit.module("DebugMenu", (hooks) => {
serverData.actions[1].search_view_id = [293, "some_search_view"];
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 293 }],
records: [{ id: 293, name: "Edit view" }],
};
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
serverData.views["ir.ui.view,false,search"] = `<search></search>`;
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
await legacyExtraNextTick();
assert.containsOnce(target, ".modal .o_form_view");
assert.strictEqual(
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
"293"
);
assert.containsOnce(target, ".breadcrumb-item");
assert.containsOnce(target, ".o_breadcrumb .active");
assert.strictEqual(target.querySelector(".o_breadcrumb .active").textContent, "Edit view");
assert.strictEqual(target.querySelector(".o_field_widget[name=id]").textContent, "293");
});
QUnit.test("edit search view on action without search_view_id", async (assert) => {
@ -475,10 +523,11 @@ QUnit.module("DebugMenu", (hooks) => {
};
serverData.models["ir.ui.view"] = {
fields: {},
records: [{ id: 293 }],
records: [{ id: 293, name: "Edit view" }],
};
serverData.views = {};
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
serverData.views["ir.ui.view,false,search"] = `<search></search>`;
serverData.views["partner,false,toy"] = `<toy></toy>`;
serverData.views["partner,293,search"] = `<search></search>`;
@ -488,12 +537,10 @@ QUnit.module("DebugMenu", (hooks) => {
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
await legacyExtraNextTick();
assert.containsOnce(target, ".modal .o_form_view");
assert.strictEqual(
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
"293"
);
assert.containsOnce(target, ".breadcrumb-item");
assert.containsOnce(target, ".o_breadcrumb .active");
assert.strictEqual(target.querySelector(".o_breadcrumb .active").textContent, "Edit view");
assert.strictEqual(target.querySelector(".o_field_widget[name=id]").textContent, "293");
});
QUnit.test(
@ -670,6 +717,47 @@ QUnit.module("DebugMenu", (hooks) => {
assert.containsNone(target, ".modal");
});
QUnit.test("fetch raw data: basic rendering", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {
debug: true,
});
registry.category("services").add("user", makeFakeUserService());
registry.category("debug").category("form").add("viewRawRecord", viewRawRecord);
const serverData = getActionManagerServerData();
serverData.actions[1234] = {
id: 1234,
xml_id: "action_1234",
name: "Partners",
res_model: "partner",
res_id: 27,
type: "ir.actions.act_window",
views: [[false, "form"]],
};
serverData.models.partner.records = [{ id: 27, display_name: "p1" }];
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 1234);
await click(target.querySelector(".o_debug_manager button"));
await click(target.querySelector(".o_debug_manager .dropdown-item"));
assert.containsOnce(target, ".modal");
assert.strictEqual(
target.querySelector(".modal-title").textContent,
"Raw Record Data: partner(27)"
);
assert.strictEqual(
target.querySelector(".modal-body pre").textContent,
'{\n "bar": false,\n "display_name": "p1",\n "foo": false,\n "id": 27,\n "m2o": false,\n "name": "name",\n "o2m": [],\n "write_date": false\n}'
);
});
QUnit.test("view metadata: basic rendering", async (assert) => {
prepareRegistriesWithCleanup();
patchWithCleanup(odoo, {

View file

@ -7,7 +7,6 @@ import { registry } from "@web/core/registry";
import { notificationService } from "@web/core/notifications/notification_service";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registerCleanup } from "../helpers/cleanup";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService, makeFakeRPCService } from "../helpers/mock_services";
import {
@ -19,7 +18,9 @@ import {
patchWithCleanup,
} from "../helpers/utils";
import { Dialog } from "../../src/core/dialog/dialog";
import { popoverService } from "@web/core/popover/popover_service";
import { usePopover } from "@web/core/popover/popover_hook";
import { useAutofocus } from "@web/core/utils/hooks";
import { Component, onMounted, xml } from "@odoo/owl";
let env;
@ -60,13 +61,13 @@ QUnit.test("Simple rendering with a single dialog", async (assert) => {
CustomDialog.components = { Dialog };
CustomDialog.template = xml`<Dialog title="'Welcome'">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
assert.containsNone(target, ".o_dialog_container .o_dialog");
assert.containsNone(target, ".o_dialog");
env.services.dialog.add(CustomDialog);
await nextTick();
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Welcome");
await click(target.querySelector(".o_dialog_container .o_dialog footer button"));
assert.containsNone(target, ".o_dialog_container .o_dialog");
await click(target.querySelector(".o_dialog footer button"));
assert.containsNone(target, ".o_dialog");
});
QUnit.test("Simple rendering and close a single dialog", async (assert) => {
@ -77,16 +78,16 @@ QUnit.test("Simple rendering and close a single dialog", async (assert) => {
CustomDialog.template = xml`<Dialog title="'Welcome'">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
assert.containsNone(target, ".o_dialog_container .o_dialog");
assert.containsNone(target, ".o_dialog");
const removeDialog = env.services.dialog.add(CustomDialog);
await nextTick();
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Welcome");
removeDialog();
await nextTick();
assert.containsNone(target, ".o_dialog_container .o_dialog");
assert.containsNone(target, ".o_dialog");
// Call a second time, the close on the dialog.
// As the dialog is already close, this call is just ignored. No error should be raised.
@ -101,20 +102,20 @@ QUnit.test("rendering with two dialogs", async (assert) => {
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
await mount(PseudoWebClient, target, { env });
assert.containsNone(target, ".o_dialog_container .o_dialog");
assert.containsNone(target, ".o_dialog");
env.services.dialog.add(CustomDialog, { title: "Hello" });
await nextTick();
assert.containsOnce(target, ".o_dialog_container .o_dialog");
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Hello");
env.services.dialog.add(CustomDialog, { title: "Sauron" });
await nextTick();
assert.containsN(target, ".o_dialog_container .o_dialog", 2);
assert.containsN(target, ".o_dialog", 2);
assert.deepEqual(
[...target.querySelectorAll("header .modal-title")].map((el) => el.textContent),
["Hello", "Sauron"]
);
await click(target.querySelector(".o_dialog_container .o_dialog footer button"));
assert.containsOnce(target, ".o_dialog_container .o_dialog");
await click(target.querySelector(".o_dialog footer button"));
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Sauron");
});
@ -127,31 +128,63 @@ QUnit.test("multiple dialogs can become the UI active element", async (assert) =
env.services.dialog.add(CustomDialog, { title: "Hello" });
await nextTick();
let dialogModal = target.querySelector(
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
);
let dialogModal = target.querySelector(".o_dialog:not(.o_inactive_modal) .modal");
assert.strictEqual(dialogModal, env.services.ui.activeElement);
env.services.dialog.add(CustomDialog, { title: "Sauron" });
await nextTick();
dialogModal = target.querySelector(
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
);
dialogModal = target.querySelector(".o_dialog:not(.o_inactive_modal) .modal");
assert.strictEqual(dialogModal, env.services.ui.activeElement);
env.services.dialog.add(CustomDialog, { title: "Rafiki" });
await nextTick();
dialogModal = target.querySelector(
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
);
dialogModal = target.querySelector(".o_dialog:not(.o_inactive_modal) .modal");
assert.strictEqual(dialogModal, env.services.ui.activeElement);
});
QUnit.test("a popover with an autofocus child can become the UI active element", async (assert) => {
class TestPopover extends Component {
static template = xml`<input type="text" t-ref="autofocus" />`;
setup() {
useAutofocus();
}
}
class CustomDialog extends Component {
static components = { Dialog };
static template = xml`<Dialog title="props.title">
<button class="btn test" t-on-click="showPopover">show</button>
</Dialog>`;
setup() {
this.popover = usePopover(TestPopover);
}
showPopover(event) {
this.popover.open(event.target, {});
}
}
serviceRegistry.add("popover", popoverService);
await nextTick(); // wait for the popover service to be started
await mount(PseudoWebClient, target, { env });
assert.strictEqual(env.services.ui.activeElement, document);
assert.strictEqual(document.activeElement, document.body);
env.services.dialog.add(CustomDialog, { title: "Hello" });
await nextTick();
const dialogModal = target.querySelector(".o_dialog:not(.o_inactive_modal) .modal");
assert.strictEqual(env.services.ui.activeElement, dialogModal);
assert.strictEqual(document.activeElement, dialogModal.querySelector(".btn.o-default-button"));
await click(dialogModal, ".btn.test");
const popover = target.querySelector(".o_popover");
const input = popover.querySelector("input");
assert.strictEqual(env.services.ui.activeElement, popover);
assert.strictEqual(document.activeElement, input);
});
QUnit.test("Interactions between multiple dialogs", async (assert) => {
assert.expect(14);
assert.expect(10);
function activity(modals) {
const active = [];
const names = [];
@ -174,40 +207,37 @@ QUnit.test("Interactions between multiple dialogs", async (assert) => {
env.services.dialog.add(CustomDialog, { title: "Rafiki" });
await nextTick();
let modals = document.querySelectorAll(".modal");
let modals = document.querySelectorAll(".o_dialog");
assert.containsN(target, ".o_dialog", 3);
let res = activity(modals);
assert.deepEqual(res.active, [false, false, true]);
assert.deepEqual(res.names, ["Hello", "Sauron", "Rafiki"]);
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
let lastDialog = modals[modals.length - 1];
lastDialog.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
await nextTick();
modals = document.querySelectorAll(".modal");
modals = document.querySelectorAll(".o_dialog");
assert.containsN(target, ".o_dialog", 2);
res = activity(modals);
assert.deepEqual(res.active, [false, true]);
assert.deepEqual(res.names, ["Hello", "Sauron"]);
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
lastDialog = modals[modals.length - 1];
await click(lastDialog, "footer button");
modals = document.querySelectorAll(".modal");
modals = document.querySelectorAll(".o_dialog");
assert.containsN(target, ".o_dialog", 1);
res = activity(modals);
assert.deepEqual(res.active, [true]);
assert.deepEqual(res.names, ["Hello"]);
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
lastDialog = modals[modals.length - 1];
await click(lastDialog, "footer button");
assert.containsNone(target, ".o_dialog_container .modal");
assert.containsOnce(target, ".o_dialog_container");
assert.containsNone(target, ".o_dialog");
});
QUnit.test("dialog component crashes", async (assert) => {
assert.expect(4);
assert.expect(3);
assert.expectErrors();
class FailingDialog extends Component {
setup() {
@ -220,27 +250,15 @@ QUnit.test("dialog component crashes", async (assert) => {
const prom = makeDeferred();
patchWithCleanup(ErrorDialog.prototype, {
setup() {
this._super();
super.setup();
onMounted(() => {
prom.resolve();
});
},
});
const handler = (ev) => {
assert.step("error");
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
patchWithCleanup(QUnit, {
onUnhandledRejection: () => {},
});
const rpc = makeFakeRPCService();
serviceRegistry.add("rpc", rpc);
serviceRegistry.add("rpc", rpc, { force: true });
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("error", errorService);
@ -249,7 +267,7 @@ QUnit.test("dialog component crashes", async (assert) => {
env.services.dialog.add(FailingDialog);
await prom;
assert.verifySteps(["error"]);
assert.containsOnce(target, ".modal");
assert.containsOnce(target, ".modal .o_dialog_error");
assert.containsOnce(target, ".modal .o_error_dialog");
assert.verifyErrors(["Some Error"]);
});

View file

@ -5,8 +5,17 @@ import { useService } from "@web/core/utils/hooks";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { Dialog } from "@web/core/dialog/dialog";
import { makeTestEnv } from "../helpers/mock_env";
import { click, destroy, getFixture, mount } from "../helpers/utils";
import { makeDialogTestEnv } from "../helpers/mock_env";
import {
click,
destroy,
getFixture,
mount,
triggerEvent,
triggerHotkey,
dragAndDrop,
nextTick,
} from "../helpers/utils";
import { makeFakeDialogService } from "../helpers/mock_services";
import { Component, useState, onMounted, xml } from "@odoo/owl";
@ -14,15 +23,6 @@ const serviceRegistry = registry.category("services");
let parent;
let target;
async function makeDialogTestEnv() {
const env = await makeTestEnv();
env.dialogData = {
isActive: true,
close: () => {},
};
return env;
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
target = getFixture();
@ -71,6 +71,33 @@ QUnit.module("Components", (hooks) => {
assert.strictEqual(target.querySelector("footer button").textContent, "Ok");
});
QUnit.test("hotkeys work on dialogs", async function (assert) {
class Parent extends Component {}
Parent.components = { Dialog };
Parent.template = xml`
<Dialog title="'Wow(l) Effect'">
Hello!
</Dialog>
`;
const env = await makeDialogTestEnv();
env.dialogData.close = () => assert.step("close");
env.dialogData.dismiss = () => assert.step("dismiss");
parent = await mount(Parent, target, { env });
assert.strictEqual(
target.querySelector("header .modal-title").textContent,
"Wow(l) Effect"
);
assert.strictEqual(target.querySelector("footer button").textContent, "Ok");
// Same effect as clicking on the x button
triggerHotkey("escape");
await nextTick();
assert.verifySteps(["dismiss", "close"]);
// Same effect as clicking on the Ok button
triggerHotkey("control+enter");
assert.verifySteps(["close"]);
});
QUnit.test("simple rendering with two dialogs", async function (assert) {
assert.expect(3);
class Parent extends Component {}
@ -99,9 +126,10 @@ QUnit.module("Components", (hooks) => {
});
QUnit.test("click on the button x triggers the service close", async function (assert) {
assert.expect(3);
assert.expect(4);
const env = await makeDialogTestEnv();
env.dialogData.close = () => assert.step("close");
env.dialogData.dismiss = () => assert.step("dismiss");
class Parent extends Component {}
Parent.template = xml`
@ -113,14 +141,43 @@ QUnit.module("Components", (hooks) => {
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
await click(target, ".o_dialog header button.btn-close");
assert.verifySteps(["close"]);
assert.verifySteps(["dismiss", "close"]);
});
QUnit.test(
"click on the button x triggers the close and dismiss defined by a Child component",
async function (assert) {
assert.expect(4);
const env = await makeDialogTestEnv();
class Child extends Component {
static template = xml`<div>Hello</div>`;
setup() {
this.env.dialogData.close = () => assert.step("close");
this.env.dialogData.dismiss = () => assert.step("dismiss");
}
}
class Parent extends Component {}
Parent.template = xml`
<Dialog>
<Child/>
</Dialog>
`;
Parent.components = { Child, Dialog };
parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_dialog");
await click(target, ".o_dialog header button.btn-close");
assert.verifySteps(["dismiss", "close"]);
}
);
QUnit.test(
"click on the default footer button triggers the service close",
async function (assert) {
const env = await makeDialogTestEnv();
env.dialogData.close = () => assert.step("close");
env.dialogData.dismiss = () => assert.step("dismiss");
assert.expect(3);
class Parent extends Component {}
@ -289,6 +346,7 @@ QUnit.module("Components", (hooks) => {
const parent = await mount(Parent, target, { env });
destroy(parent);
await Promise.resolve();
assert.strictEqual(
env.services.ui.activeElement,
@ -296,4 +354,54 @@ QUnit.module("Components", (hooks) => {
"UI owner should be reset to the default (document)"
);
});
QUnit.test("dialog can be moved", async (assert) => {
class Parent extends Component {
static template = xml`<Dialog>content</Dialog>`;
static components = { Dialog };
}
await mount(Parent, target, { env: await makeDialogTestEnv() });
const content = target.querySelector(".modal-content");
assert.strictEqual(content.style.top, "0px");
assert.strictEqual(content.style.left, "0px");
const header = content.querySelector(".modal-header");
const headerRect = header.getBoundingClientRect();
await dragAndDrop(header, document.body, {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
});
assert.strictEqual(content.style.top, "50px");
assert.strictEqual(content.style.left, "20px");
});
QUnit.test("dialog's position is reset on resize", async (assert) => {
class Parent extends Component {
static template = xml`<Dialog>content</Dialog>`;
static components = { Dialog };
}
await mount(Parent, target, { env: await makeDialogTestEnv() });
const content = target.querySelector(".modal-content");
assert.strictEqual(content.style.top, "0px");
assert.strictEqual(content.style.left, "0px");
const header = content.querySelector(".modal-header");
const headerRect = header.getBoundingClientRect();
await dragAndDrop(header, document.body, {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
});
assert.strictEqual(content.style.top, "50px");
assert.strictEqual(content.style.left, "20px");
await triggerEvent(window, null, "resize");
assert.strictEqual(content.style.top, "0px");
assert.strictEqual(content.style.left, "0px");
});
});

View file

@ -0,0 +1,181 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
import { click, dragAndDrop, getFixture, mount } from "../helpers/utils";
import { makeDialogTestEnv } from "../helpers/mock_env";
import { registry } from "@web/core/registry";
import { notificationService } from "@web/core/notifications/notification_service";
import { ormService } from "@web/core/orm_service";
import { uiService } from "@web/core/ui/ui_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { fieldService } from "@web/core/field_service";
import { popoverService } from "@web/core/popover/popover_service";
import { nameService } from "@web/core/name_service";
/**
* @typedef {Record<keyof DomainSelectorDialog.props, any>} Props
*/
/**
* @param {Partial<Props> & { mockRPC: Function }} [params]
*/
async function makeDomainSelectorDialog(params = {}) {
const props = { ...params };
const mockRPC = props.mockRPC;
delete props.mockRPC;
class Parent extends Component {
static components = { DomainSelectorDialog };
static template = xml`<DomainSelectorDialog t-props="domainSelectorProps"/>`;
setup() {
this.domainSelectorProps = {
resModel: "partner",
readonly: false,
domain: "[]",
close: () => {},
onConfirm: () => {},
...props,
};
}
}
const env = await makeDialogTestEnv({ serverData, mockRPC });
await mount(MainComponentsContainer, fixture, { env });
return mount(Parent, fixture, { env, props });
}
/** @type {Element} */
let fixture;
let serverData;
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
partner: {
fields: {
foo: { string: "Foo", type: "char", searchable: true },
bar: { string: "Bar", type: "boolean", searchable: true },
product_id: {
string: "Product",
type: "many2one",
relation: "product",
searchable: true,
},
datetime: { string: "Date Time", type: "datetime", searchable: true },
int: { string: "Integer", type: "integer", searchable: true },
json_field: { string: "Json Field", type: "json", searchable: true },
},
records: [
{ id: 1, foo: "yop", bar: true, product_id: 37 },
{ id: 2, foo: "blip", bar: true, product_id: false },
{ id: 4, foo: "abc", bar: false, product_id: 41 },
],
onchanges: {},
},
product: {
fields: {
name: { string: "Product Name", type: "char", searchable: true },
},
records: [
{ id: 37, display_name: "xphone" },
{ id: 41, display_name: "xpad" },
],
},
},
};
registry.category("services").add("notification", notificationService);
registry.category("services").add("orm", ormService);
registry.category("services").add("ui", uiService);
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("popover", popoverService);
registry.category("services").add("field", fieldService);
registry.category("services").add("name", nameService);
fixture = getFixture();
});
QUnit.module("DomainSelectorDialog");
QUnit.test("a domain with a user context dynamic part is valid", async (assert) => {
await makeDomainSelectorDialog({
domain: "[('foo', '=', uid)]",
onConfirm(domain) {
assert.strictEqual(domain, "[('foo', '=', uid)]");
assert.step("confirmed");
},
mockRPC(route) {
if (route === "/web/domain/validate") {
assert.step("validation");
return true;
}
},
});
const confirmButton = fixture.querySelector(".o_dialog footer button");
await click(confirmButton);
assert.verifySteps(["validation", "confirmed"]);
});
QUnit.test("can extend eval context", async (assert) => {
await makeDomainSelectorDialog({
domain: "['&', ('foo', '=', uid), ('bar', '=', var)]",
context: { uid: 99, var: "true" },
onConfirm(domain) {
assert.strictEqual(domain, "['&', ('foo', '=', uid), ('bar', '=', var)]");
assert.step("confirmed");
},
mockRPC(route) {
if (route === "/web/domain/validate") {
assert.step("validation");
return true;
}
},
});
const confirmButton = fixture.querySelector(".o_dialog footer button");
await click(confirmButton);
assert.verifySteps(["validation", "confirmed"]);
});
QUnit.test("a domain with an unknown expression is not valid", async (assert) => {
await makeDomainSelectorDialog({
domain: "[('foo', '=', unknown)]",
onConfirm() {
assert.step("confirmed");
},
mockRPC(route) {
if (route === "/web/domain/validate") {
assert.step("validation");
}
},
});
const confirmButton = fixture.querySelector(".o_dialog footer button");
await click(confirmButton);
assert.verifySteps([]);
});
QUnit.test("model_field_selector should close on dialog drag", async (assert) => {
await makeDomainSelectorDialog({
domain: "[('foo', '=', unknown)]",
});
assert.containsNone(fixture, ".o_model_field_selector_popover");
await click(fixture, ".o_model_field_selector_value");
assert.containsOnce(fixture, ".o_model_field_selector_popover");
const header = fixture.querySelector(".modal-header");
const headerRect = header.getBoundingClientRect();
await dragAndDrop(header, document.body, {
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
// so we need to move the dialog based on these coordinates.
x: headerRect.x + headerRect.width / 2 + 20,
y: headerRect.y + headerRect.height / 2 + 50,
});
assert.containsNone(fixture, ".o_model_field_selector_popover");
});
});

View file

@ -98,9 +98,11 @@ QUnit.module("domain", {}, () => {
assert.ok(new Domain(["!", ["group_method", "=", "count"]]).contains(record));
});
QUnit.test("like, =like, ilike and =ilike", function (assert) {
assert.expect(16);
QUnit.test("like, =like, ilike, =ilike, not like and not ilike", function (assert) {
assert.expect(36);
assert.ok(new Domain([["a", "like", "test%value"]]).contains({ a: "test value" }));
assert.notOk(new Domain([["a", "like", "test%value"]]).contains({ a: "value test" }));
assert.ok(new Domain([["a", "like", "value"]]).contains({ a: "value" }));
assert.ok(new Domain([["a", "like", "value"]]).contains({ a: "some value" }));
assert.notOk(new Domain([["a", "like", "value"]]).contains({ a: "Some Value" }));
@ -111,6 +113,8 @@ QUnit.module("domain", {}, () => {
assert.notOk(new Domain([["a", "=like", "%value"]]).contains({ a: "Some Value" }));
assert.notOk(new Domain([["a", "=like", "%value"]]).contains({ a: false }));
assert.ok(new Domain([["a", "ilike", "test%value"]]).contains({ a: "test value" }));
assert.notOk(new Domain([["a", "ilike", "test%value"]]).contains({ a: "value test" }));
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "value" }));
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "some value" }));
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "Some Value" }));
@ -120,6 +124,24 @@ QUnit.module("domain", {}, () => {
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "some value" }));
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "Some Value" }));
assert.notOk(new Domain([["a", "=ilike", "%value"]]).contains({ a: false }));
assert.notOk(new Domain([["a", "not like", "test%value"]]).contains({ a: "test value" }));
assert.ok(new Domain([["a", "not like", "test%value"]]).contains({ a: "value test" }));
assert.notOk(new Domain([["a", "not like", "value"]]).contains({ a: "value" }));
assert.notOk(new Domain([["a", "not like", "value"]]).contains({ a: "some value" }));
assert.ok(new Domain([["a", "not like", "value"]]).contains({ a: "Some Value" }));
assert.ok(new Domain([["a", "not like", "value"]]).contains({ a: "something" }));
assert.ok(new Domain([["a", "not like", "value"]]).contains({ a: "Something" }));
assert.notOk(new Domain([["a", "not ilike", "test%value"]]).contains({ a: "test value" }));
assert.ok(new Domain([["a", "not ilike", "test%value"]]).contains({ a: "value test" }));
assert.notOk(new Domain([["a", "not like", "value"]]).contains({ a: false }));
assert.notOk(new Domain([["a", "not ilike", "value"]]).contains({ a: "value" }));
assert.notOk(new Domain([["a", "not ilike", "value"]]).contains({ a: "some value" }));
assert.notOk(new Domain([["a", "not ilike", "value"]]).contains({ a: "Some Value" }));
assert.ok(new Domain([["a", "not ilike", "value"]]).contains({ a: "something" }));
assert.ok(new Domain([["a", "not ilike", "value"]]).contains({ a: "Something" }));
assert.notOk(new Domain([["a", "not ilike", "value"]]).contains({ a: false }));
});
QUnit.test("complex domain", function (assert) {
@ -204,6 +226,21 @@ QUnit.module("domain", {}, () => {
);
});
QUnit.test("toJson", function (assert) {
assert.deepEqual(new Domain([]).toJson(), []);
assert.deepEqual(new Domain("[]").toJson(), []);
assert.deepEqual(new Domain([["a", "=", 3]]).toJson(), [["a", "=", 3]]);
assert.deepEqual(new Domain('[("a", "=", 3)]').toJson(), [["a", "=", 3]]);
assert.strictEqual(
new Domain('[("user_id", "=", uid)]').toJson(),
'[("user_id", "=", uid)]'
);
assert.strictEqual(
new Domain('[("date", "=", context_today())]').toJson(),
'[("date", "=", context_today())]'
);
});
QUnit.test("implicit &", function (assert) {
const domain = new Domain([
["a", "=", 3],
@ -354,6 +391,16 @@ QUnit.module("domain", {}, () => {
/invalid domain .* \(missing 1 segment/
);
assert.throws(() => new Domain(["!"]), /invalid domain .* \(missing 1 segment/);
assert.throws(() => new Domain(`[(1, 2)]`), /Invalid domain AST/);
assert.throws(() => new Domain(`[(1, 2, 3, 4)]`), /Invalid domain AST/);
assert.throws(() => new Domain(`["a"]`), /Invalid domain AST/);
assert.throws(() => new Domain(`[1]`), /Invalid domain AST/);
assert.throws(() => new Domain(`[x]`), /Invalid domain AST/);
assert.throws(() => new Domain(`[True]`), /Invalid domain AST/); // will possibly change with CHM work
assert.throws(() => new Domain(`[(x.=, "=", 1)]`), /Invalid domain representation/);
assert.throws(() => new Domain(`[(+, "=", 1)]`), /Invalid domain representation/);
assert.throws(() => new Domain([{}]), /Invalid domain representation/);
assert.throws(() => new Domain([1]), /Invalid domain representation/);
});
QUnit.test("follow relations", function (assert) {
@ -559,4 +606,50 @@ QUnit.module("domain", {}, () => {
assert.throws(() => new Domain(`("&", "&", "|")`), /Invalid domain AST/);
assert.throws(() => new Domain(`("&", "&", 3)`), /Invalid domain AST/);
});
QUnit.module("RemoveDomainLeaf");
QUnit.test("Remove leaf in domain.", function (assert) {
let domain = [
["start_datetime", "!=", false],
["end_datetime", "!=", false],
["sale_line_id", "!=", false],
];
const keysToRemove = ["start_datetime", "end_datetime"];
let newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
let expectedDomain = new Domain([
"&",
...Domain.TRUE.toList({}),
...Domain.TRUE.toList({}),
["sale_line_id", "!=", false],
]);
assert.deepEqual(newDomain.toList({}), expectedDomain.toList({}));
domain = [
"|",
["role_id", "=", false],
"&",
["resource_id", "!=", false],
["start_datetime", "=", false],
["sale_line_id", "!=", false],
];
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
expectedDomain = new Domain([
"|",
["role_id", "=", false],
"&",
["resource_id", "!=", false],
...Domain.TRUE.toList({}),
["sale_line_id", "!=", false],
]);
assert.deepEqual(newDomain.toList({}), expectedDomain.toList({}));
domain = [
"|",
["start_datetime", "=", false],
["end_datetime", "=", false],
["sale_line_id", "!=", false],
];
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
expectedDomain = new Domain([...Domain.TRUE.toList({}), ["sale_line_id", "!=", false]]);
assert.deepEqual(newDomain.toList({}), expectedDomain.toList({}));
});
});

View file

@ -0,0 +1,123 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { AccordionItem } from "@web/core/dropdown/accordion_item";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { makeTestEnv } from "../helpers/mock_env";
import {
click,
getFixture,
mount,
nextTick,
patchWithCleanup,
triggerHotkey,
} from "../helpers/utils";
const serviceRegistry = registry.category("services");
let env;
let target;
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(async () => {
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("ui", uiService);
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
});
QUnit.module("Dropdown Accordion Item");
QUnit.test("accordion can be rendered", async (assert) => {
class Parent extends Component {}
Parent.template = xml`<AccordionItem description="'Test'" class="'text-primary'" selected="false"><h5>In accordion</h5></AccordionItem>`;
Parent.components = { AccordionItem };
env = await makeTestEnv();
await mount(Parent, target, { env });
assert.strictEqual(
target.querySelector(".o_accordion").outerHTML,
`<div class="o_accordion position-relative"><button class="o_menu_item o_accordion_toggle dropdown-item text-primary" tabindex="0" aria-expanded="false">Test</button></div>`
);
assert.containsOnce(target, "button.o_accordion_toggle");
assert.containsNone(target, ".o_accordion_values");
await click(target, "button.o_accordion_toggle");
assert.containsOnce(target, ".o_accordion_values");
assert.strictEqual(
target.querySelector(".o_accordion_values").innerHTML,
`<h5>In accordion</h5>`
);
});
QUnit.test("dropdown with accordion keyboard navigation", async (assert) => {
class Parent extends Component {}
Parent.template = xml`
<Dropdown>
<DropdownItem>item 1</DropdownItem>
<AccordionItem description="'item 2'" selected="false">
<DropdownItem>item 2-1</DropdownItem>
<DropdownItem>item 2-2</DropdownItem>
</AccordionItem>
<DropdownItem>item 3</DropdownItem>
</Dropdown>
`;
Parent.components = { Dropdown, DropdownItem, AccordionItem };
env = await makeTestEnv();
await mount(Parent, target, { env });
await click(target, ".o-dropdown .dropdown-toggle");
// Navigate with arrows
assert.containsNone(
target,
".dropdown-menu > .focus",
"menu should not have any active items"
);
const scenarioSteps = [
{ key: "arrowdown", expected: "item 1" },
{ key: "arrowdown", expected: "item 2" },
{ key: "arrowdown", expected: "item 3" },
{ key: "arrowdown", expected: "item 1" },
{ key: "tab", expected: "item 2" },
{ key: "enter", expected: "item 2" },
{ key: "tab", expected: "item 2-1" },
{ key: "tab", expected: "item 2-2" },
{ key: "tab", expected: "item 3" },
{ key: "tab", expected: "item 1" },
{ key: "arrowup", expected: "item 3" },
{ key: "arrowup", expected: "item 2-2" },
{ key: "arrowup", expected: "item 2-1" },
{ key: "arrowup", expected: "item 2" },
{ key: "enter", expected: "item 2" },
{ key: "arrowup", expected: "item 1" },
{ key: "shift+tab", expected: "item 3" },
{ key: "shift+tab", expected: "item 2" },
{ key: "shift+tab", expected: "item 1" },
{ key: "end", expected: "item 3" },
{ key: "home", expected: "item 1" },
];
for (const step of scenarioSteps) {
triggerHotkey(step.key);
await nextTick();
assert.strictEqual(
target.querySelector(".dropdown-menu .focus").innerText,
step.expected,
`selected menu should be ${step.expected}`
);
assert.strictEqual(
document.activeElement.innerText,
step.expected,
`document.activeElement should be ${step.expected}`
);
}
});
});

View file

@ -1,14 +1,17 @@
/** @odoo-module **/
import { App, Component, onMounted, onPatched, useRef, useState, xml } from "@odoo/owl";
import { templates } from "@web/core/assets";
import { browser } from "@web/core/browser/browser";
import { DateTimePicker } from "@web/core/datepicker/datepicker";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { CheckboxItem } from "@web/core/dropdown/checkbox_item";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import {
click,
@ -17,15 +20,18 @@ import {
mockTimeout,
mount,
mouseEnter,
mouseLeave,
nextTick,
patchWithCleanup,
triggerEvent,
triggerHotkey,
} from "../helpers/utils";
import { makeParent } from "./tooltip/tooltip_service_tests";
import { templates } from "@web/core/assets";
import { getPickerCell } from "./datetime/datetime_test_helpers";
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
import { Dialog } from "@web/core/dialog/dialog";
import { dialogService } from "@web/core/dialog/dialog_service";
import { App, Component, onMounted, onPatched, useRef, useState, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
let env;
@ -52,7 +58,7 @@ QUnit.module("Components", ({ beforeEach }) => {
await mount(Parent, target, { env });
assert.strictEqual(
target.querySelector(".dropdown").outerHTML,
'<div class="o-dropdown dropdown o-dropdown--no-caret"><button class="dropdown-toggle" tabindex="0" aria-expanded="false"></button></div>'
'<div class="o-dropdown dropdown o-dropdown--no-caret"><button type="button" class="dropdown-toggle" tabindex="0" aria-expanded="false"></button></div>'
);
assert.containsOnce(target, "button.dropdown-toggle");
assert.containsNone(target, ".dropdown-menu");
@ -89,7 +95,7 @@ QUnit.module("Components", ({ beforeEach }) => {
patchWithCleanup(DropdownItem.prototype, {
onClick(ev) {
assert.ok(!ev.defaultPrevented);
this._super(...arguments);
super.onClick(...arguments);
const href = ev.target.getAttribute("href");
// defaultPrevented only if props.href is defined
assert.ok(href !== null ? ev.defaultPrevented : !ev.defaultPrevented);
@ -184,7 +190,7 @@ QUnit.module("Components", ({ beforeEach }) => {
patchWithCleanup(Dropdown.prototype, {
close() {
assert.step("dropdown will close");
this._super();
super.close();
},
});
class Parent extends Component {
@ -225,6 +231,74 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.containsNone(target, ".dropdown-menu");
});
QUnit.test("hold position on hover", async (assert) => {
let parentState;
class Parent extends Component {
setup() {
this.state = useState({ filler: false });
parentState = this.state;
}
static template = xml`
<div t-if="state.filler" class="filler" style="height: 100px;"/>
<Dropdown holdOnHover="true">
</Dropdown>
`;
static components = { Dropdown };
}
env = await makeTestEnv();
await mount(Parent, target, { env });
assert.containsNone(target, ".dropdown-menu");
await click(target, "button.dropdown-toggle");
assert.containsOnce(target, ".dropdown-menu");
const menuBox1 = target.querySelector(".dropdown-menu").getBoundingClientRect();
// Pointer enter the dropdown menu
await mouseEnter(target, ".dropdown-menu");
// Add a filler to the parent
assert.containsNone(target, ".filler");
parentState.filler = true;
await nextTick();
assert.containsOnce(target, ".filler");
const menuBox2 = target.querySelector(".dropdown-menu").getBoundingClientRect();
assert.strictEqual(menuBox2.top - menuBox1.top, 0);
// Pointer leave the dropdown menu
await mouseLeave(target, ".dropdown-menu");
const menuBox3 = target.querySelector(".dropdown-menu").getBoundingClientRect();
assert.strictEqual(menuBox3.top - menuBox1.top, 100);
});
QUnit.test("unlock position after close", async (assert) => {
class Parent extends Component {
static template = xml`
<div style="margin-left: 200px;">
<Dropdown holdOnHover="true" position="'bottom-end'">
</Dropdown>
</div>
`;
static components = { Dropdown };
}
env = await makeTestEnv();
await mount(Parent, target, { env });
assert.containsNone(target, ".dropdown-menu");
await click(target, "button.dropdown-toggle");
assert.containsOnce(target, ".dropdown-menu");
const menuBox1 = target.querySelector(".dropdown-menu").getBoundingClientRect();
// Pointer enter the dropdown menu to lock the menu
await mouseEnter(target, ".dropdown-menu");
// close the menu
await click(target);
assert.containsNone(target, ".dropdown-menu");
// and reopen it
await click(target, "button.dropdown-toggle");
assert.containsOnce(target, ".dropdown-menu");
const menuBox2 = target.querySelector(".dropdown-menu").getBoundingClientRect();
assert.strictEqual(menuBox2.left - menuBox1.left, 0);
});
QUnit.test("payload received on item selection", async (assert) => {
class Parent extends Component {
onItemSelected(value) {
@ -560,13 +634,14 @@ QUnit.module("Components", ({ beforeEach }) => {
}
);
QUnit.test("siblings dropdowns with manualOnly props", async (assert) => {
assert.expect(7);
QUnit.test("siblings dropdowns with autoOpen", async (assert) => {
class Parent extends Component {}
Parent.template = xml`
<div>
<Dropdown class="'one'" manualOnly="true"/>
<Dropdown class="'two'" manualOnly="true"/>
<Dropdown class="'one'" autoOpen="false"/>
<Dropdown class="'two'" autoOpen="false"/>
<Dropdown class="'three'"/>
<Dropdown class="'four'"/>
<div class="outside">OUTSIDE</div>
</div>
`;
@ -576,22 +651,37 @@ QUnit.module("Components", ({ beforeEach }) => {
// Click on one
await click(target, ".one button");
assert.containsOnce(target, ".dropdown-menu");
// Click on two
await click(target, ".two button");
assert.containsN(target, ".dropdown-menu", 2);
// Click on one again
await click(target, ".one button");
assert.containsOnce(target, ".dropdown-menu");
assert.containsNone(target.querySelector(".one"), ".dropdown-menu");
// Hover on one
const one = target.querySelector(".one");
one.querySelector("button").dispatchEvent(new MouseEvent("mouseenter"));
assert.containsOnce(target, ".one .dropdown-menu");
// Hover on two
const two = target.querySelector(".two");
two.querySelector("button").dispatchEvent(new MouseEvent("mouseenter"));
await nextTick();
assert.containsOnce(target, ".dropdown-menu");
assert.containsNone(target.querySelector(".one"), ".dropdown-menu");
assert.containsOnce(target, ".one .dropdown-menu");
// Hover on three
const three = target.querySelector(".three");
three.querySelector("button").dispatchEvent(new MouseEvent("mouseenter"));
await nextTick();
assert.containsOnce(target, ".dropdown-menu");
assert.containsOnce(target, ".one .dropdown-menu");
// Click outside
await click(target, "div.outside");
assert.containsNone(target, ".dropdown-menu");
// Click on three
await click(target, ".three button");
assert.containsOnce(target, ".dropdown-menu");
assert.containsOnce(target, ".three .dropdown-menu");
// Hover on two
two.querySelector("button").dispatchEvent(new MouseEvent("mouseenter"));
await nextTick();
assert.containsOnce(target, ".dropdown-menu");
assert.containsOnce(target, ".three .dropdown-menu");
// Hover on four
const four = target.querySelector(".four");
four.querySelector("button").dispatchEvent(new MouseEvent("mouseenter"));
await nextTick();
assert.containsOnce(target, ".dropdown-menu");
assert.containsOnce(target, ".four .dropdown-menu");
});
QUnit.test("siblings dropdowns: toggler focused on mouseenter", async (assert) => {
@ -737,9 +827,6 @@ QUnit.module("Components", ({ beforeEach }) => {
// Define the ArrowDown key with standard API (for hotkey_service)
key: "ArrowDown",
code: "ArrowDown",
// Define the ArrowDown key with deprecated API (for bootstrap)
keyCode: 40,
which: 40,
});
select.dispatchEvent(ev);
await nextTick();
@ -749,9 +836,6 @@ QUnit.module("Components", ({ beforeEach }) => {
// Define the ESC key with standard API (for hotkey_service)
key: "Escape",
code: "Escape",
// Define the ESC key with deprecated API (for bootstrap)
keyCode: 27,
which: 27,
});
select.dispatchEvent(ev);
await nextTick();
@ -980,7 +1064,7 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.expect(9);
patchWithCleanup(Dropdown.prototype, {
setup() {
this._super(...arguments);
super.setup(...arguments);
const isSubmenu = Boolean(this.parentDropdown);
if (isSubmenu) {
onMounted(() => {
@ -1064,6 +1148,46 @@ QUnit.module("Components", ({ beforeEach }) => {
);
});
QUnit.test("caret should be repositioned to default direction when closed", async (assert) => {
class Parent extends Component {
static components = { Dropdown };
static template = xml`
<div style="height: 384px;"/> <!-- filler: takes half the runbot's browser_size -->
<Dropdown showCaret="true">
<t t-set-slot="toggler">🍋</t>
<div style="height: 400px; width: 50px;"/> <!-- menu filler -->
</Dropdown>
`;
}
// The fixture should be shown for this test, as the positioning container is the html node
target.style.position = "fixed";
target.style.top = "0";
target.style.left = "0";
env = await makeTestEnv();
await mount(Parent, target, { env });
const dropdown = target.querySelector(".o-dropdown");
assert.doesNotHaveClass(dropdown, "show");
assert.hasClass(dropdown, "dropdown");
// open
await click(target, ".dropdown-toggle");
await nextTick(); // awaits for the caret to get patched
assert.hasClass(dropdown, "show");
assert.hasClass(dropdown, "dropend");
// close
await click(target, ".dropdown-toggle");
assert.doesNotHaveClass(dropdown, "show");
assert.hasClass(dropdown, "dropdown");
// open
await click(target, ".dropdown-toggle");
await nextTick(); // awaits for the caret to get patched
assert.hasClass(dropdown, "show");
assert.hasClass(dropdown, "dropend");
});
QUnit.test(
"multi-level dropdown: mouseentering a dropdown item should close any subdropdown",
async (assert) => {
@ -1109,7 +1233,7 @@ QUnit.module("Components", ({ beforeEach }) => {
let hotkeyRegistrationsCount = 0;
patchWithCleanup(env.services.hotkey, {
add() {
const remove = this._super(...arguments);
const remove = super.add(...arguments);
hotkeyRegistrationsCount += 1;
return () => {
remove();
@ -1174,8 +1298,8 @@ QUnit.module("Components", ({ beforeEach }) => {
QUnit.test("Dropdown with a tooltip", async (assert) => {
assert.expect(2);
class MyComponent extends owl.Component {}
MyComponent.template = owl.xml`
class MyComponent extends Component {}
MyComponent.template = xml`
<Dropdown tooltip="'My tooltip'">
<DropdownItem/>
</Dropdown>`;
@ -1190,16 +1314,17 @@ QUnit.module("Components", ({ beforeEach }) => {
QUnit.test(
"Dropdown with a date picker inside do not close when a click occurs in date picker",
async (assert) => {
class MyComponent extends owl.Component {}
MyComponent.template = owl.xml`
registry.category("services").add("datetime_picker", datetimePickerService);
class MyComponent extends Component {}
MyComponent.template = xml`
<Dropdown>
<t t-set-slot="toggler">
Dropdown toggler
</t>
<DateTimePicker onDateTimeChanged="() => {}" date="false"/>
<DateTimeInput />
</Dropdown>
`;
MyComponent.components = { Dropdown, DateTimePicker };
MyComponent.components = { DateTimeInput, Dropdown };
await makeParent(MyComponent);
@ -1208,20 +1333,205 @@ QUnit.module("Components", ({ beforeEach }) => {
await click(target, ".dropdown-toggle");
assert.containsOnce(target, ".o-dropdown--menu");
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
assert.strictEqual(target.querySelector(".o_datepicker_input").value, "");
assert.containsNone(target, ".o_datetime_picker");
assert.strictEqual(target.querySelector(".o_datetime_input").value, "");
await click(target, ".o_datepicker_input");
await click(target, ".o_datetime_input");
assert.containsOnce(target, ".o-dropdown--menu");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
assert.strictEqual(target.querySelector(".o_datepicker_input").value, "");
assert.containsOnce(target, ".o_datetime_picker");
assert.strictEqual(target.querySelector(".o_datetime_input").value, "");
await click(document.querySelectorAll(".datepicker table td")[15]); // select some day
await click(getPickerCell("15")); // select some day
assert.containsOnce(target, ".o-dropdown--menu");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
assert.notOk(target.querySelector(".o_datepicker_input").value === "");
assert.containsOnce(target, ".o_datetime_picker");
assert.notOk(target.querySelector(".o_datetime_input").value === "");
}
);
QUnit.test("onOpened callback props called after the menu has been mounted", async (assert) => {
const beforeOpenProm = makeDeferred();
class Parent extends Component {
beforeOpenCallback() {
assert.step("beforeOpened");
return beforeOpenProm;
}
onOpenedCallback() {
assert.step("onOpened");
}
}
Parent.template = xml`
<Dropdown onOpened.bind="onOpenedCallback" beforeOpen.bind="beforeOpenCallback" />
`;
Parent.components = { Dropdown, DropdownItem };
env = await makeTestEnv();
await mount(Parent, target, { env });
await click(target, "button.dropdown-toggle");
assert.verifySteps(["beforeOpened"]);
beforeOpenProm.resolve();
await nextTick();
assert.verifySteps(["onOpened"]);
});
QUnit.test("dropdown button can be disabled", async (assert) => {
class Parent extends Component {}
Parent.template = xml`<Dropdown disabled="true"/>`;
Parent.components = { Dropdown };
env = await makeTestEnv();
await mount(Parent, target, { env });
assert.strictEqual(
target.querySelector(".dropdown").outerHTML,
'<div class="o-dropdown dropdown o-dropdown--no-caret"><button type="button" class="dropdown-toggle" disabled="" tabindex="0" aria-expanded="false"></button></div>'
);
});
QUnit.test("Dropdown with CheckboxItem: toggle value", async (assert) => {
class Parent extends Component {
setup() {
this.state = useState({ checked: false });
}
onSelected() {
this.state.checked = !this.state.checked;
}
}
Parent.template = xml`
<Dropdown>
<t t-set-slot="toggler">Click to open</t>
<CheckboxItem
class="{ selected: state.checked }"
checked="state.checked"
parentClosingMode="'none'"
onSelected.bind="onSelected">
My checkbox item
</CheckboxItem>
</Dropdown>`;
Parent.components = { Dropdown, CheckboxItem };
env = await makeTestEnv();
await mount(Parent, target, { env });
await click(target, ".dropdown-toggle");
assert.strictEqual(
target.querySelector(".dropdown-item").outerHTML,
`<span class="dropdown-item" role="menuitemcheckbox" tabindex="0" aria-checked="false"> My checkbox item </span>`
);
await click(target, ".dropdown-item");
assert.strictEqual(
target.querySelector(".dropdown-item").outerHTML,
`<span class="dropdown-item selected" role="menuitemcheckbox" tabindex="0" aria-checked="true"> My checkbox item </span>`
);
});
QUnit.test("don't close dropdown outside the active element", async (assert) => {
// This test checks that if a dropdown element opens a dialog with a dropdown inside,
// opening this dropdown will not close the first dropdown.
class CustomDialog extends Component {}
CustomDialog.template = xml`
<Dialog title="'Welcome'">
<Dropdown>
<DropdownItem>Item</DropdownItem>
</Dropdown>
<div class="outside_dialog">Outside Dialog</div>
</Dialog>`;
CustomDialog.components = { Dialog, Dropdown, DropdownItem };
const mainComponentRegistry = registry.category("main_components");
clearRegistryWithCleanup(mainComponentRegistry);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("l10n", makeFakeLocalizationService());
class PseudoWebClient extends Component {
setup() {
this.Components = mainComponentRegistry.getEntries();
}
clicked() {
env.services.dialog.add(CustomDialog);
}
}
PseudoWebClient.template = xml`
<div>
<div>
<t t-foreach="Components" t-as="C" t-key="C[0]">
<t t-component="C[1].Component" t-props="C[1].props"/>
</t>
</div>
<div>
<Dropdown>
<button class="click-me" t-on-click="clicked">Click me</button>
</Dropdown>
<div class="outside_parent">Outside Parent</div>
</div>
</div>
`;
PseudoWebClient.components = { Dropdown };
env = await makeTestEnv();
await mount(PseudoWebClient, target, { env });
await click(target, "button.dropdown-toggle");
assert.containsOnce(target, ".dropdown-menu");
await click(target, "button.click-me");
assert.containsOnce(target, ".modal-dialog");
await click(target, ".modal-dialog button.dropdown-toggle");
assert.containsN(target, ".dropdown-menu", 2);
await click(target, ".outside_dialog");
assert.containsOnce(target, ".modal-dialog");
assert.containsN(target, ".dropdown-menu", 1);
await click(target, ".modal-dialog .btn-primary");
assert.containsNone(target, ".modal-dialog");
assert.containsN(target, ".dropdown-menu", 1);
await click(target, ".outside_parent");
assert.containsNone(target, ".dropdown-menu");
});
QUnit.test("multi-level dropdown is well positioned", async (assert) => {
class Parent extends Component {}
Parent.template = xml`
<Dropdown class="'parent'" menuClass="'border-0'">
<DropdownItem>A</DropdownItem>
<Dropdown class="'first'" menuClass="'border-0'">
<t t-set-slot="toggler">
B
</t>
<DropdownItem>B A</DropdownItem>
<DropdownItem>B C</DropdownItem>
</Dropdown>
<Dropdown class="'second'" menuClass="'border-0'">
<t t-set-slot="toggler">
C
</t>
<DropdownItem>C A</DropdownItem>
<DropdownItem>C B</DropdownItem>
</Dropdown>
</Dropdown>
`;
Parent.components = { Dropdown, DropdownItem };
env = await makeTestEnv();
await mount(Parent, target, { env });
await click(target, "button.dropdown-toggle:last-child");
const parentMenu = target.querySelector(".parent .dropdown-menu");
const parentMenuBox = parentMenu.getBoundingClientRect();
// Hover first sub-dropdown
await mouseEnter(target, ".o-dropdown.first > button");
await nextTick();
let currentDropdown = target.querySelector(".first .dropdown-menu");
let currentDropdownBox = currentDropdown.getBoundingClientRect();
//Check X position of the first subdropdown, should not overlap parent dropdown
assert.ok(currentDropdownBox.x >= parentMenuBox.x + parentMenuBox.width);
//Check Y position of the first subdropdown, should not overlap parent dropdown
let parentTogglerBox = parentMenu.querySelector(".show").getBoundingClientRect();
let currentTogglerBox = currentDropdown.querySelector(".focus").getBoundingClientRect();
assert.ok(Math.abs(parentTogglerBox.y - currentTogglerBox.y) < 5);
// Hover second sub-dropdown
await mouseEnter(target, ".o-dropdown.second > button");
await nextTick();
currentDropdown = target.querySelector(".second .dropdown-menu");
currentDropdownBox = currentDropdown.getBoundingClientRect();
//Check X position of the second subdropdown, should not overlap parent dropdown
assert.ok(currentDropdownBox.x >= parentMenuBox.x + parentMenuBox.width);
//Check Y position of the first subdropdown, should not overlap parent dropdown
parentTogglerBox = parentMenu.querySelector(".show").getBoundingClientRect();
currentTogglerBox = currentDropdown.querySelector(".focus").getBoundingClientRect();
assert.ok(Math.abs(parentTogglerBox.y - currentTogglerBox.y) < 5);
});
});

View file

@ -17,27 +17,14 @@ import {
} from "../../helpers/utils";
import { Component, markup, xml } from "@odoo/owl";
import { MainComponentsContainer } from "@web/core/main_components_container";
const serviceRegistry = registry.category("services");
const mainComponentRegistry = registry.category("main_components");
let target;
class Parent extends Component {
setup() {
this.EffectContainer = mainComponentRegistry.get("EffectContainer");
this.NotificationContainer = mainComponentRegistry.get("NotificationContainer");
}
}
Parent.template = xml`
<div>
<t t-component="EffectContainer.Component" t-props="EffectContainer.props" />
<t t-component="NotificationContainer.Component" t-props="NotificationContainer.props" />
</div>
`;
async function makeParent() {
const env = await makeTestEnv({ serviceRegistry });
const parent = await mount(Parent, target, { env });
const parent = await mount(MainComponentsContainer, target, { env });
return parent;
}

View file

@ -12,7 +12,7 @@ import {
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv } from "../../helpers/mock_env";
import { makeDialogTestEnv } from "../../helpers/mock_env";
import { makeFakeDialogService, makeFakeLocalizationService } from "../../helpers/mock_services";
import { click, getFixture, mount, nextTick, patchWithCleanup } from "../../helpers/utils";
@ -20,15 +20,6 @@ let target;
let env;
const serviceRegistry = registry.category("services");
async function makeDialogTestEnv() {
const env = await makeTestEnv();
env.dialogData = {
isActive: true,
close() {},
};
return env;
}
QUnit.module("Error dialogs", {
async beforeEach() {
target = getFixture();
@ -58,26 +49,26 @@ QUnit.test("ErrorDialog with traceback", async (assert) => {
const mainButtons = target.querySelectorAll("main button");
assert.deepEqual(
[...mainButtons].map((el) => el.textContent),
["Copy the full error to clipboard", "See details"]
["See details"]
);
const footerButtons = target.querySelectorAll("footer button");
assert.deepEqual(
[...footerButtons].map((el) => el.textContent),
["Close", "Copy error to clipboard"]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[...target.querySelector("main p").childNodes].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
]
);
assert.containsNone(target, "div.o_error_detail");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
click(mainButtons[1]);
click(mainButtons[0]);
await nextTick();
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
"Something bad happened",
]
["Something bad happened"]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix code")].map((el) => el.textContent),
@ -112,26 +103,26 @@ QUnit.test("Client ErrorDialog with traceback", async (assert) => {
const mainButtons = target.querySelectorAll("main button");
assert.deepEqual(
[...mainButtons].map((el) => el.textContent),
["Copy the full error to clipboard", "See details"]
["See details"]
);
const footerButtons = target.querySelectorAll("footer button");
assert.deepEqual(
[...footerButtons].map((el) => el.textContent),
["Close", "Copy error to clipboard"]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[...target.querySelector("main p").childNodes].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
]
);
assert.containsNone(target, "div.o_error_detail");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
click(mainButtons[1]);
click(mainButtons[0]);
await nextTick();
assert.deepEqual(
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
[
"An error occurred",
"Please use the copy button to report the error to your support service.",
"Something bad happened",
]
["Something bad happened"]
);
assert.deepEqual(
[...target.querySelectorAll("main .clearfix code")].map((el) => el.textContent),
@ -191,10 +182,10 @@ QUnit.test("WarningDialog", async (assert) => {
},
});
assert.containsOnce(target, ".o_dialog");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "User Error");
assert.containsOnce(target, "main .o_dialog_warning");
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Invalid Operation");
assert.containsOnce(target, ".o_error_dialog");
assert.strictEqual(target.querySelector("main").textContent, "Some strange unreadable message");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Close");
});
QUnit.test("RedirectWarningDialog", async (assert) => {
@ -233,7 +224,7 @@ QUnit.test("RedirectWarningDialog", async (assert) => {
const footerButtons = target.querySelectorAll("footer button");
assert.deepEqual(
[...footerButtons].map((el) => el.textContent),
["Buy book on cryptography", "Cancel"]
["Buy book on cryptography", "Close"]
);
await click(footerButtons[0]); // click on "Buy book on cryptography"
assert.verifySteps(["buy_action_id", "dialog-closed"]);
@ -253,7 +244,7 @@ QUnit.test("Error504Dialog", async (assert) => {
target.querySelector("main p").textContent,
" The operation was interrupted. This usually means that the current operation is taking too much time. "
);
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Close");
});
QUnit.test("SessionExpiredDialog", async (assert) => {
@ -278,7 +269,7 @@ QUnit.test("SessionExpiredDialog", async (assert) => {
" Your Odoo session expired. The current page is about to be refreshed. "
);
const footerButton = target.querySelector(".o_dialog footer button");
assert.strictEqual(footerButton.textContent, "Ok");
assert.strictEqual(footerButton.textContent, "Close");
click(footerButton);
assert.verifySteps(["location reload"]);
});

View file

@ -2,10 +2,15 @@
import { browser } from "@web/core/browser/browser";
import { dialogService } from "@web/core/dialog/dialog_service";
import { ClientErrorDialog, RPCErrorDialog } from "@web/core/errors/error_dialogs";
import {
ClientErrorDialog,
RPCErrorDialog,
standardErrorDialogProps,
} from "@web/core/errors/error_dialogs";
import { errorService, UncaughtPromiseError } from "@web/core/errors/error_service";
import { ConnectionLostError, RPCError } from "@web/core/network/rpc_service";
import { notificationService } from "@web/core/notifications/notification_service";
import { overlayService } from "@web/core/overlay/overlay_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { registerCleanup } from "../../helpers/cleanup";
@ -17,17 +22,21 @@ import {
makeFakeRPCService,
} from "../../helpers/mock_services";
import { getFixture, makeDeferred, mount, nextTick, patchWithCleanup } from "../../helpers/utils";
import { omit } from "@web/core/utils/objects";
import { Component, xml, onError, OwlError, onWillStart } from "@odoo/owl";
import { defaultHandler } from "@web/core/errors/error_handlers";
const errorDialogRegistry = registry.category("error_dialogs");
const errorHandlerRegistry = registry.category("error_handlers");
const serviceRegistry = registry.category("services");
let errorCb;
let unhandledRejectionCb;
let preventDefault;
QUnit.module("Error Service", {
async beforeEach() {
serviceRegistry.add("overlay", overlayService);
serviceRegistry.add("error", errorService);
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("notification", notificationService);
@ -35,16 +44,17 @@ QUnit.module("Error Service", {
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("ui", uiService);
const windowAddEventListener = browser.addEventListener;
preventDefault = Event.prototype.preventDefault;
browser.addEventListener = (type, cb) => {
if (type === "unhandledrejection") {
unhandledRejectionCb = (ev) => {
ev.preventDefault();
preventDefault.call(ev);
cb(ev);
};
}
if (type === "error") {
errorCb = (ev) => {
ev.preventDefault();
preventDefault.call(ev);
cb(ev);
};
}
@ -83,7 +93,7 @@ QUnit.test("handle RPC_ERROR of type='server' and no associated dialog class", a
error.subType = "strange_error";
function addDialog(dialogClass, props) {
assert.strictEqual(dialogClass, RPCErrorDialog);
assert.deepEqual(_.omit(props, "traceback"), {
assert.deepEqual(omit(props, "traceback"), {
name: "RPC_ERROR",
type: "server",
code: 701,
@ -94,8 +104,8 @@ QUnit.test("handle RPC_ERROR of type='server' and no associated dialog class", a
message: "Some strange error occured",
exceptionName: null,
});
assert.ok(props.traceback.indexOf("RPC_ERROR")>= 0);
assert.ok(props.traceback.indexOf("Some strange error occured")>= 0);
assert.ok(props.traceback.indexOf("RPC_ERROR") >= 0);
assert.ok(props.traceback.indexOf("Some strange error occured") >= 0);
}
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
await makeTestEnv();
@ -114,6 +124,7 @@ QUnit.test(
class CustomDialog extends Component {}
CustomDialog.template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
CustomDialog.components = { RPCErrorDialog };
CustomDialog.props = { ...standardErrorDialogProps };
const error = new RPCError();
error.code = 701;
error.message = "Some strange error occured";
@ -124,7 +135,7 @@ QUnit.test(
error.data = errorData;
function addDialog(dialogClass, props) {
assert.strictEqual(dialogClass, CustomDialog);
assert.deepEqual(_.omit(props, "traceback"), {
assert.deepEqual(omit(props, "traceback"), {
name: "RPC_ERROR",
type: "server",
code: 701,
@ -133,8 +144,8 @@ QUnit.test(
message: "Some strange error occured",
exceptionName: null,
});
assert.ok(props.traceback.indexOf("RPC_ERROR")>= 0);
assert.ok(props.traceback.indexOf("Some strange error occured")>= 0);
assert.ok(props.traceback.indexOf("RPC_ERROR") >= 0);
assert.ok(props.traceback.indexOf("Some strange error occured") >= 0);
}
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
await makeTestEnv();
@ -168,7 +179,7 @@ QUnit.test(
error.data = errorData;
function addDialog(dialogClass, props) {
assert.strictEqual(dialogClass, NormalDialog);
assert.deepEqual(_.omit(props, "traceback"), {
assert.deepEqual(omit(props, "traceback"), {
name: "RPC_ERROR",
type: "server",
code: 701,
@ -177,8 +188,8 @@ QUnit.test(
message: "A normal error occured",
exceptionName: "normal_error",
});
assert.ok(props.traceback.indexOf("RPC_ERROR")>= 0);
assert.ok(props.traceback.indexOf("A normal error occured")>= 0);
assert.ok(props.traceback.indexOf("RPC_ERROR") >= 0);
assert.ok(props.traceback.indexOf("A normal error occured") >= 0);
}
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
await makeTestEnv();
@ -222,7 +233,7 @@ QUnit.test("handle CONNECTION_LOST_ERROR", async (assert) => {
}
};
await makeTestEnv({ mockRPC });
const error = new ConnectionLostError();
const error = new ConnectionLostError("/fake_url");
const errorEvent = new PromiseRejectionEvent("error", {
reason: error,
promise: null,
@ -246,6 +257,7 @@ QUnit.test("will let handlers from the registry handle errors first", async (ass
assert.strictEqual(originalError, error);
assert.strictEqual(env.someValue, 14);
assert.step("in handler");
return true;
});
const testEnv = await makeTestEnv();
testEnv.someValue = 14;
@ -266,6 +278,7 @@ QUnit.test("originalError is the root cause of the error chain", async (assert)
assert.ok(err.cause instanceof OwlError); // Wrapped by owl
assert.strictEqual(err.cause.cause, originalError); // original error
assert.step("in handler");
return true;
});
const testEnv = await makeTestEnv();
testEnv.someValue = 14;
@ -313,6 +326,7 @@ QUnit.test("originalError is the root cause of the error chain", async (assert)
});
QUnit.test("handle uncaught promise errors", async (assert) => {
assert.expectErrors();
class TestError extends Error {}
const error = new TestError();
error.message = "This is an error test";
@ -320,12 +334,12 @@ QUnit.test("handle uncaught promise errors", async (assert) => {
function addDialog(dialogClass, props) {
assert.strictEqual(dialogClass, ClientErrorDialog);
assert.deepEqual(_.omit(props, "traceback"), {
assert.deepEqual(omit(props, "traceback"), {
name: "UncaughtPromiseError > TestError",
message: "Uncaught Promise > This is an error test",
});
assert.ok(props.traceback.indexOf("TestError")>= 0);
assert.ok(props.traceback.indexOf("This is an error test")>= 0);
assert.ok(props.traceback.indexOf("TestError") >= 0);
assert.ok(props.traceback.indexOf("This is an error test") >= 0);
}
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
await makeTestEnv();
@ -336,9 +350,11 @@ QUnit.test("handle uncaught promise errors", async (assert) => {
cancelable: true,
});
await unhandledRejectionCb(errorEvent);
assert.verifyErrors(["This is an error test"]);
});
QUnit.test("handle uncaught client errors", async (assert) => {
assert.expectErrors();
class TestError extends Error {}
const error = new TestError();
error.message = "This is an error test";
@ -360,6 +376,7 @@ QUnit.test("handle uncaught client errors", async (assert) => {
cancelable: true,
});
await errorCb(errorEvent);
assert.verifyErrors(["This is an error test"]);
});
QUnit.test("don't show dialog for errors in third-party scripts", async (assert) => {
@ -382,6 +399,7 @@ QUnit.test("don't show dialog for errors in third-party scripts", async (assert)
});
QUnit.test("show dialog for errors in third-party scripts in debug mode", async (assert) => {
assert.expectErrors();
class TestError extends Error {}
const error = new TestError();
error.message = "Script error.";
@ -394,55 +412,19 @@ QUnit.test("show dialog for errors in third-party scripts in debug mode", async
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
await makeTestEnv();
// Error events from errors in third-party scripts hav no colno, no lineno and no filename
// Error events from errors in third-party scripts have no colno, no lineno and no filename
// because of CORS.
const errorEvent = new ErrorEvent("error", { error, cancelable: true });
await errorCb(errorEvent);
assert.verifySteps(["Uncaught CORS Error"]);
});
QUnit.test("check retry", async (assert) => {
assert.expect(3);
errorHandlerRegistry.add("__test_handler__", () => {
assert.step("dispatched");
});
const def = makeDeferred();
patchWithCleanup(browser, {
setTimeout(fn) {
def.then(fn);
},
});
serviceRegistry.remove("dialog");
await makeTestEnv();
class TestError extends Error {}
const error = new TestError();
error.message = "This is an error test";
error.name = "TestError";
const errorEvent = new PromiseRejectionEvent("error", {
reason: error,
promise: null,
cancelable: true,
});
await unhandledRejectionCb(errorEvent);
assert.verifySteps([]);
serviceRegistry.add("dialog", dialogService);
await nextTick();
await def.resolve();
assert.verifySteps(["dispatched"]);
assert.verifyErrors(["Script error."]);
});
QUnit.test("lazy loaded handlers", async (assert) => {
assert.expectErrors();
await makeTestEnv();
const errorEvent = new PromiseRejectionEvent("error", {
reason: new Error(),
reason: new Error("error"),
promise: null,
cancelable: true,
});
@ -452,10 +434,12 @@ QUnit.test("lazy loaded handlers", async (assert) => {
errorHandlerRegistry.add("__test_handler__", () => {
assert.step("in handler");
return true;
});
await unhandledRejectionCb(errorEvent);
assert.verifySteps(["in handler"]);
assert.verifyErrors(["error"]); // for the first throw, before registering the handler
});
// The following test(s) do not want the preventDefault to be done automatically.
@ -467,6 +451,10 @@ QUnit.module("Error Service", {
serviceRegistry.add("rpc", makeFakeRPCService());
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("ui", uiService);
// remove the override of the defaultHandler done in qunit.js
registry
.category("error_handlers")
.add("defaultHandler", defaultHandler, { sequence: 100, force: true });
const windowAddEventListener = browser.addEventListener;
browser.addEventListener = (type, cb) => {
if (type === "unhandledrejection") {
@ -539,3 +527,51 @@ QUnit.test("logs the traceback of the full error chain for uncaughterror", async
await errorCb(errorEvent);
assert.ok(errorEvent.defaultPrevented);
});
QUnit.test("error in handlers while handling an error", async (assert) => {
// Scenario: an error occurs at the early stage of the "boot" sequence, error handlers
// that are supposed to spawn dialogs are not ready then and will crash.
// We assert that *exactly one* error message is logged, that contains the original error's traceback
// and an indication that a handler has crashed just for not loosing information.
// The crash of the error handler should merely be seen as a consequence of the early stage at which the error occurs.
errorHandlerRegistry.add(
"__test_handler__",
(env, err, originalError) => {
throw new Error("Boom in handler");
},
{ sequence: 0 }
);
// We want to assert that the error_service code does the preventDefault.
preventDefault = () => {};
patchWithCleanup(console, {
error(errorMessage) {
assert.ok(
errorMessage.startsWith(
`@web/core/error_service: handler "__test_handler__" failed with "Error: Boom in handler" while trying to handle:\nError: Genuine Business Boom`
)
);
assert.step("error logged");
},
});
await makeTestEnv();
let errorEvent = new Event("error", {
promise: null,
cancelable: true,
});
errorEvent.error = new Error("Genuine Business Boom");
errorEvent.error.annotatedTraceback = "annotated";
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
await errorCb(errorEvent);
assert.ok(errorEvent.defaultPrevented);
assert.verifySteps(["error logged"]);
errorEvent = new PromiseRejectionEvent("unhandledrejection", {
promise: null,
cancelable: true,
reason: new Error("Genuine Business Boom"),
});
await unhandledRejectionCb(errorEvent);
assert.ok(errorEvent.defaultPrevented);
assert.verifySteps(["error logged"]);
});

View file

@ -0,0 +1,114 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { ExpressionEditorDialog } from "@web/core/expression_editor_dialog/expression_editor_dialog";
import { makeDialogTestEnv } from "../helpers/mock_env";
import { mount, getFixture, nextTick, click } from "../helpers/utils";
import {
getTreeEditorContent,
makeServerData,
setupConditionTreeEditorServices,
} from "./condition_tree_editor_helpers";
import { registry } from "@web/core/registry";
/**
* @typedef {Record<keyof DomainSelectorDialog.props, any>} Props
*/
/**
* @param {Partial<Props> & { mockRPC: Function }} [params]
*/
async function makeExpressionEditorDialog(params = {}) {
const props = { ...params };
const mockRPC = props.mockRPC;
delete props.mockRPC;
class Parent extends Component {
static components = { ExpressionEditorDialog };
static template = xml`<ExpressionEditorDialog t-props="expressionEditorProps"/>`;
setup() {
this.expressionEditorProps = {
resModel: "partner",
expression: "1",
close: () => {},
onConfirm: () => {},
...props,
};
this.expressionEditorProps.fields =
this.expressionEditorProps.fields ||
serverData.models[this.expressionEditorProps.resModel]?.fields ||
{};
Object.entries(this.expressionEditorProps.fields).forEach(([fieldName, field]) => {
field.name = fieldName;
});
}
async set(expression) {
this.expressionEditorProps.expression = expression;
this.render();
await nextTick();
}
}
const env = await makeDialogTestEnv({ serverData, mockRPC });
return mount(Parent, target, { env, props });
}
/** @type {Element} */
let target;
let serverData;
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serverData = makeServerData();
setupConditionTreeEditorServices();
target = getFixture();
});
QUnit.module("ExpressionEditorDialog");
QUnit.test("expr well sent, onConfirm and onClose", async (assert) => {
const expression = `foo == 'batestr' and bar == True`;
await makeExpressionEditorDialog({
expression,
close: () => {
assert.step("close");
},
onConfirm: (result) => {
assert.step(result);
},
});
assert.containsOnce(target, ".o_technical_modal");
const confirmButton = target.querySelector(".o_dialog footer button");
await click(confirmButton);
assert.verifySteps([expression, "close"]);
});
QUnit.test("expr well sent but wrong, so notification when onConfirm", async (assert) => {
const expression = `foo == 'bar' and bar = True`;
registry.category("services").add(
"notification",
{
start() {
return {
add(message, options) {
assert.strictEqual(message, "Expression is invalid. Please correct it");
assert.deepEqual(options, { type: "danger" });
assert.step("notification");
},
};
},
},
{ force: true }
);
await makeExpressionEditorDialog({
expression,
});
assert.containsOnce(target, ".o_technical_modal");
const confirmButton = target.querySelector(".modal-footer button");
const resetButton = target.querySelector(".modal-body button");
await click(confirmButton);
await click(resetButton);
assert.deepEqual(getTreeEditorContent(target), [{ level: 0, value: "all" }]);
assert.verifySteps(["notification"]);
});
});

View file

@ -0,0 +1,490 @@
/** @odoo-module **/
import {
click,
editInput,
getFixture,
getNodesTextContent,
mount,
nextTick,
patchWithCleanup,
} from "../helpers/utils";
import { Component, xml } from "@odoo/owl";
import { ExpressionEditor } from "@web/core/expression_editor/expression_editor";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { makeTestEnv } from "../helpers/mock_env";
import {
SELECTORS as treeEditorSELECTORS,
clickOnButtonAddBranch,
clickOnButtonAddNewRule,
clickOnButtonDeleteNode,
editValue,
get,
getTreeEditorContent,
makeServerData,
selectOperator,
setupConditionTreeEditorServices,
getOperatorOptions,
getValueOptions,
isNotSupportedPath,
clearNotSupported,
} from "./condition_tree_editor_helpers";
import { openModelFieldSelectorPopover } from "./model_field_selector_tests";
export {
clickOnButtonAddBranch,
clickOnButtonAddNewRule,
clickOnButtonDeleteNode,
editValue,
get,
getTreeEditorContent,
makeServerData,
selectOperator,
setupConditionTreeEditorServices,
isNotSupportedPath,
clearNotSupported,
} from "./condition_tree_editor_helpers";
let serverData;
let target;
export const SELECTORS = {
...treeEditorSELECTORS,
debugArea: ".o_expression_editor_debug_container textarea",
};
async function editExpression(target, value, index = 0) {
const input = get(target, SELECTORS.complexConditionInput, index);
await editInput(input, null, value);
}
async function selectConnector(target, value, index = 0) {
const toggler = get(target, `${SELECTORS.connector} .dropdown-toggle`, index);
await click(toggler);
const dropdownMenu = get(target, `${SELECTORS.connector} .dropdown-menu `, index);
const items = [...dropdownMenu.querySelectorAll(".dropdown-item")];
const item = items.find((i) => i.innerText === value);
await click(item);
}
async function makeExpressionEditor(params = {}) {
const props = { ...params };
const mockRPC = props.mockRPC;
delete props.mockRPC;
class Parent extends Component {
setup() {
this.expressionEditorProps = {
resModel: "partner",
expression: "1",
...props,
update: (expression) => {
if (props.update) {
props.update(expression);
}
this.expressionEditorProps.expression = expression;
this.render();
},
};
this.expressionEditorProps.fields =
this.expressionEditorProps.fields ||
serverData.models[this.expressionEditorProps.resModel]?.fields ||
{};
Object.entries(this.expressionEditorProps.fields).forEach(([fieldName, field]) => {
field.name = fieldName;
});
}
async set(expression) {
this.expressionEditorProps.expression = expression;
this.render();
await nextTick();
}
}
Parent.components = { ExpressionEditor };
Parent.template = xml`<ExpressionEditor t-props="expressionEditorProps"/>`;
const env = await makeTestEnv({ serverData, mockRPC });
await mount(MainComponentsContainer, target, { env });
return mount(Parent, target, { env, props });
}
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
serverData = makeServerData();
setupConditionTreeEditorServices();
target = getFixture();
patchWithCleanup(odoo, { debug: true });
});
QUnit.module("ExpressionEditor");
QUnit.test("rendering of truthy values", async (assert) => {
const toTests = [`True`, `true`, `1`, `-1`, `"a"`];
const parent = await makeExpressionEditor();
for (const expr of toTests) {
await parent.set(expr);
const tree = getTreeEditorContent(target);
assert.deepEqual(tree, [{ level: 0, value: "all" }]);
}
});
QUnit.test("rendering of falsy values", async (assert) => {
const toTests = [`False`, `false`, `0`, `""`];
const parent = await makeExpressionEditor();
for (const expr of toTests) {
await parent.set(expr);
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: ["0", "=", "1"], level: 1 },
]);
}
});
QUnit.test("rendering of 'expr'", async (assert) => {
patchWithCleanup(odoo, { debug: false });
await makeExpressionEditor({ expression: "expr" });
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
assert.strictEqual(target.querySelector(SELECTORS.complexConditionInput).readOnly, true);
});
QUnit.test("rendering of 'expr' in dev mode", async (assert) => {
await makeExpressionEditor({ expression: "expr" });
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
assert.strictEqual(target.querySelector(SELECTORS.complexConditionInput).readOnly, false);
});
QUnit.test("edit a complex condition in dev mode", async (assert) => {
await makeExpressionEditor({ expression: "expr" });
assert.containsNone(target, SELECTORS.condition);
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await editExpression(target, "uid");
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "uid", level: 1 },
]);
});
QUnit.test("delete a complex condition", async (assert) => {
await makeExpressionEditor({ expression: "expr" });
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await clickOnButtonDeleteNode(target);
assert.deepEqual(getTreeEditorContent(target), [{ value: "all", level: 0 }]);
});
QUnit.test("copy a complex condition", async (assert) => {
await makeExpressionEditor({ expression: "expr" });
assert.containsNone(target, SELECTORS.condition);
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await clickOnButtonAddNewRule(target);
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
{ value: "expr", level: 1 },
]);
});
QUnit.test("change path, operator and value", async (assert) => {
patchWithCleanup(odoo, { debug: false });
await makeExpressionEditor({ expression: `bar != "blabla"` });
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is not", "blabla"] },
]);
const tree = getTreeEditorContent(target, { node: true });
await openModelFieldSelectorPopover(target);
await click(target.querySelectorAll(".o_model_field_selector_popover_item_name")[4]);
await selectOperator(tree[1].node, "not in");
await editValue(target, ["Doku", "Lukaku", "KDB"]);
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: ["Foo", "is not in", "Doku,Lukaku,KDB"] },
]);
});
QUnit.test("create a new branch from a complex condition control panel", async (assert) => {
await makeExpressionEditor({ expression: "expr" });
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
]);
await clickOnButtonAddBranch(target);
assert.deepEqual(getTreeEditorContent(target), [
{ value: "all", level: 0 },
{ value: "expr", level: 1 },
{ level: 1, value: "any" },
{ level: 2, value: ["ID", "=", "1"] },
{ level: 2, value: ["ID", "=", "1"] },
]);
});
QUnit.test("rendering of a valid fieldName in fields", async (assert) => {
const fields = { foo: { string: "Foo", type: "char", searchable: true } };
const parent = await makeExpressionEditor({ fields });
const toTests = [
{ expr: `foo`, condition: ["Foo", "is set"] },
{ expr: `foo == "a"`, condition: ["Foo", "=", "a"] },
{ expr: `foo != "a"`, condition: ["Foo", "!=", "a"] },
// { expr: `foo is "a"`, complexCondition: `foo is "a"` },
// { expr: `foo is not "a"`, complexCondition: `foo is not "a"` },
{ expr: `not foo`, condition: ["Foo", "is not set"] },
{ expr: `foo + "a"`, complexCondition: `foo + "a"` },
];
for (const { expr, condition, complexCondition } of toTests) {
await parent.set(expr);
const tree = getTreeEditorContent(target);
if (condition) {
assert.deepEqual(tree, [
{ value: "all", level: 0 },
{ value: condition, level: 1 },
]);
} else if (complexCondition) {
assert.deepEqual(tree, [
{ value: "all", level: 0 },
{ value: complexCondition, level: 1 },
]);
}
}
});
QUnit.test("rendering of simple conditions", async (assert) => {
const fields = {
foo: { string: "Foo", type: "char", searchable: true },
bar: { string: "Bar", type: "char", searchable: true },
};
const parent = await makeExpressionEditor({ fields });
const toTests = [
{ expr: `bar == "a"`, condition: ["Bar", "=", "a"] },
{ expr: `foo == expr`, condition: ["Foo", "=", "expr"] },
{ expr: `"a" == foo`, condition: ["Foo", "=", "a"] },
{ expr: `expr == foo`, condition: ["Foo", "=", "expr"] },
{ expr: `foo == bar`, complexCondition: `foo == bar` },
{ expr: `"a" == "b"`, complexCondition: `"a" == "b"` },
{ expr: `expr1 == expr2`, complexCondition: `expr1 == expr2` },
{ expr: `foo < "a"`, condition: ["Foo", "<", "a"] },
{ expr: `foo < expr`, condition: ["Foo", "<", "expr"] },
{ expr: `"a" < foo`, condition: ["Foo", ">", "a"] },
{ expr: `expr < foo`, condition: ["Foo", ">", "expr"] },
{ expr: `foo < bar`, complexCondition: `foo < bar` },
{ expr: `"a" < "b"`, complexCondition: `"a" < "b"` },
{ expr: `expr1 < expr2`, complexCondition: `expr1 < expr2` },
{ expr: `foo in ["a"]`, condition: ["Foo", "is in", "a"] },
{ expr: `foo in [expr]`, condition: ["Foo", "is in", "expr"] },
{ expr: `"a" in foo`, complexCondition: `"a" in foo` },
{ expr: `expr in foo`, complexCondition: `expr in foo` },
{ expr: `foo in bar`, complexCondition: `foo in bar` },
{ expr: `"a" in "b"`, complexCondition: `"a" in "b"` },
{ expr: `expr1 in expr2`, complexCondition: `expr1 in expr2` },
];
for (const { expr, condition, complexCondition } of toTests) {
await parent.set(expr);
const tree = getTreeEditorContent(target);
if (condition) {
assert.deepEqual(tree, [
{ value: "all", level: 0 },
{ value: condition, level: 1 },
]);
} else if (complexCondition) {
assert.deepEqual(tree, [
{ value: "all", level: 0 },
{ value: complexCondition, level: 1 },
]);
}
}
});
QUnit.test("rendering of connectors", async (assert) => {
await makeExpressionEditor({ expression: `expr and foo == "abc" or not bar` });
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(`${SELECTORS.connector} .dropdown-toggle`)),
["any", "all"]
);
const tree = getTreeEditorContent(target);
assert.deepEqual(tree, [
{ level: 0, value: "any" },
{ level: 1, value: "all" },
{ level: 2, value: "expr" },
{ level: 2, value: ["Foo", "=", "abc"] },
{ level: 1, value: ["Bar", "is", "not set"] },
]);
});
QUnit.test("rendering of connectors (2)", async (assert) => {
await makeExpressionEditor({
expression: `not (expr or foo == "abc")`,
update(expression) {
assert.step(expression);
},
});
assert.strictEqual(
target.querySelector(`${SELECTORS.connector} .dropdown-toggle`).textContent,
"none"
);
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "none" },
{ level: 1, value: "expr" },
{ level: 1, value: ["Foo", "=", "abc"] },
]);
assert.verifySteps([]);
assert.strictEqual(
target.querySelector(SELECTORS.debugArea).value,
`not (expr or foo == "abc")`
);
await selectConnector(target, "all");
assert.strictEqual(
target.querySelector(`${SELECTORS.connector} .dropdown-toggle`).textContent,
"all"
);
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: "expr" },
{ level: 1, value: ["Foo", "=", "abc"] },
]);
assert.verifySteps([`expr and foo == "abc"`]);
assert.strictEqual(
target.querySelector(SELECTORS.debugArea).value,
`expr and foo == "abc"`
);
});
QUnit.test("rendering of if else", async (assert) => {
await makeExpressionEditor({ expression: `True if False else False` });
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "any" },
{ level: 1, value: "all" },
{ level: 2, value: ["0", "=", "1"] },
{ level: 2, value: ["1", "=", "1"] },
{ level: 1, value: "all" },
{ level: 2, value: ["1", "=", "1"] },
{ level: 2, value: ["0", "=", "1"] },
]);
});
QUnit.test("check condition by default when creating a new rule", async (assert) => {
patchWithCleanup(odoo, { debug: false });
serverData.models.partner.fields.country_id = { string: "Country ID", type: "char" };
await makeExpressionEditor({ expression: "expr" });
await click(target, "a[role='button']");
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: "expr" },
{ level: 1, value: ["Country ID", "=", ""] },
]);
});
QUnit.test("allow selection of boolean field", async (assert) => {
await makeExpressionEditor({ expression: "id" });
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: ["ID", "is set"] },
]);
await openModelFieldSelectorPopover(target);
await click(target.querySelector(".o_model_field_selector_popover_item_name"));
assert.deepEqual(getTreeEditorContent(target), [
{ level: 0, value: "all" },
{ level: 1, value: ["Bar", "is", "set"] },
]);
});
QUnit.test("render false and true leaves", async (assert) => {
await makeExpressionEditor({ expression: `False and True` });
assert.deepEqual(getOperatorOptions(target), ["="]);
assert.deepEqual(getValueOptions(target), ["1"]);
assert.deepEqual(getOperatorOptions(target, -1), ["="]);
assert.deepEqual(getValueOptions(target, -1), ["1"]);
});
QUnit.test("no field of type properties in model field selector", async (assert) => {
patchWithCleanup(odoo, { debug: false });
serverData.models.partner.fields.properties = {
string: "Properties",
type: "properties",
definition_record: "product_id",
definition_record_field: "definitions",
searchable: true,
};
serverData.models.product.fields.definitions = {
string: "Definitions",
type: "properties_definition",
};
await makeExpressionEditor({
expression: `properties`,
fields: Object.fromEntries(
Object.entries(serverData.models.partner.fields).filter(([name]) =>
["bar", "foo", "properties"].includes(name)
)
),
update(expression) {
assert.step(expression);
},
});
assert.deepEqual(getTreeEditorContent(target), [
{
level: 0,
value: "all",
},
{
level: 1,
value: ["Properties", "is set"],
},
]);
assert.ok(isNotSupportedPath(target));
await clearNotSupported(target);
assert.verifySteps([`foo == ""`]);
await openModelFieldSelectorPopover(target);
assert.deepEqual(
getNodesTextContent([
...target.querySelectorAll(".o_model_field_selector_popover_item_name"),
]),
["Bar", "Foo"]
);
});
QUnit.test("between operator", async (assert) => {
await makeExpressionEditor({
expression: `id == 1`,
update(expression) {
assert.step(expression);
},
});
assert.deepEqual(getOperatorOptions(target), [
"=",
"!=",
">",
">=",
"<",
"<=",
"is between",
"is set",
"is not set",
]);
assert.verifySteps([]);
await selectOperator(target, "between");
assert.verifySteps([`id >= 1 and id <= 1`]);
});
});

View file

@ -0,0 +1,337 @@
/** @odoo-module **/
import { fieldService } from "@web/core/field_service";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registry } from "@web/core/registry";
import { Component, useState, xml } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { getFixture, makeDeferred, mount, nextTick } from "@web/../tests/helpers/utils";
const serviceRegistry = registry.category("services");
function getModelInfo(resModel) {
return {
resModel,
fieldDefs: serverData.models[resModel].fields,
};
}
function getDefinitions() {
const records = serverData.models.species.records;
const fieldDefs = {};
for (const record of records) {
for (const definition of record.definitions) {
fieldDefs[definition.name] = {
is_property: true,
searchable: true,
record_name: record.display_name,
record_id: record.id,
...definition,
};
}
}
return { resModel: "*", fieldDefs };
}
let serverData;
QUnit.module("Field Service", {
async beforeEach() {
serverData = {
models: {
tortoise: {
fields: {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
age: { type: "integer", string: "Age" },
location_id: { type: "many2one", string: "Location", relation: "location" },
species: { type: "many2one", string: "Species", relation: "species" },
property_field: {
string: "Properties",
type: "properties",
definition_record: "species",
definition_record_field: "definitions",
},
},
},
location: {
fields: {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
tortoise_ids: { type: "one2many", string: "Turtles", relation: "tortoise" },
},
},
species: {
fields: {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
definitions: { string: "Definitions", type: "properties_definition" },
},
records: [
{
id: 1,
display_name: "Galápagos tortoise",
definitions: [
{
name: "galapagos_lifespans",
string: "Lifespans",
type: "integer",
},
{
name: "location_ids",
string: "Locations",
type: "many2many",
relation: "location",
},
],
},
{
id: 2,
display_name: "Aldabra giant tortoise",
definitions: [
{ name: "aldabra_lifespans", string: "Lifespans", type: "integer" },
{ name: "color", string: "Color", type: "char" },
],
},
],
},
},
};
serviceRegistry.add("field", fieldService);
},
});
QUnit.test("loadPath", async (assert) => {
const toTest = [
{
resModel: "tortoise",
path: "*",
expectedResult: {
names: ["*"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "*.a",
expectedResult: {
isInvalid: "path",
names: ["*", "a"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "location_id.*",
expectedResult: {
names: ["location_id", "*"],
modelsInfo: [getModelInfo("tortoise"), getModelInfo("location")],
},
},
{
resModel: "tortoise",
path: "age",
expectedResult: {
names: ["age"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "location_id",
expectedResult: {
names: ["location_id"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids",
expectedResult: {
names: ["location_id", "tortoise_ids"],
modelsInfo: [getModelInfo("tortoise"), getModelInfo("location")],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids.age",
expectedResult: {
names: ["location_id", "tortoise_ids", "age"],
modelsInfo: [
getModelInfo("tortoise"),
getModelInfo("location"),
getModelInfo("tortoise"),
],
},
},
{
resModel: "tortoise",
path: "location_id.tortoise_ids.age",
expectedResult: {
names: ["location_id", "tortoise_ids", "age"],
modelsInfo: [
getModelInfo("tortoise"),
getModelInfo("location"),
getModelInfo("tortoise"),
],
},
},
{
resModel: "tortoise",
path: "property_field",
expectedResult: {
names: ["property_field"],
modelsInfo: [getModelInfo("tortoise")],
},
},
{
resModel: "tortoise",
path: "property_field.galapagos_lifespans",
expectedResult: {
names: ["property_field", "galapagos_lifespans"],
modelsInfo: [getModelInfo("tortoise"), getDefinitions()],
},
},
{
resModel: "tortoise",
path: "property_field.location_ids.tortoise_ids",
expectedResult: {
isInvalid: "path",
names: ["property_field", "location_ids", "tortoise_ids"],
modelsInfo: [getModelInfo("tortoise"), getDefinitions()],
},
},
];
const env = await makeTestEnv({ serverData });
for (const { resModel, path, expectedResult } of toTest) {
const result = await env.services.field.loadPath(resModel, path);
assert.deepEqual(result, expectedResult);
}
const errorToTest = [
{ resModel: "notAModel" },
{ resModel: "tortoise", path: {} },
{ resModel: "tortoise", path: "" },
];
for (const { resModel, path } of errorToTest) {
try {
await env.services.field.loadPath(resModel, path);
} catch {
assert.step("error");
}
}
assert.verifySteps(errorToTest.map(() => "error"));
});
QUnit.test("store loadFields calls in cache in success", async (assert) => {
assert.expect(2);
const mockRPC = (route) => {
if (route.includes("fields_get")) {
assert.step("fields_get");
}
};
const env = await makeTestEnv({ serverData, mockRPC });
await env.services.field.loadFields("tortoise");
await env.services.field.loadFields("tortoise");
assert.verifySteps(["fields_get"]);
});
QUnit.test("does not store loadFields calls in cache when failed", async (assert) => {
assert.expect(5);
const mockRPC = (route) => {
if (route.includes("fields_get")) {
assert.step("fields_get");
return Promise.reject("my little error");
}
};
const env = await makeTestEnv({ serverData, mockRPC });
try {
await env.services.field.loadFields("take.five");
} catch (error) {
assert.strictEqual(error, "my little error");
}
try {
await env.services.field.loadFields("take.five");
} catch (error) {
assert.strictEqual(error, "my little error");
}
assert.verifySteps(["fields_get", "fields_get"]);
});
QUnit.test("async method loadFields is protected", async (assert) => {
assert.expect(7);
let callFieldService;
class Child extends Component {
static template = xml`
<div class="o_child_component" />
`;
setup() {
this.fieldService = useService("field");
callFieldService = async () => {
assert.step("loadFields called");
await this.fieldService.loadFields("tortoise");
assert.step("loadFields result get");
};
}
}
class Parent extends Component {
static components = { Child };
static template = xml`
<t t-if="state.displayChild">
<Child />
</t>
`;
setup() {
this.state = useState({ displayChild: true });
}
}
const target = getFixture();
const def = makeDeferred();
const env = await makeTestEnv({
serverData,
async mockRPC() {
await def;
},
});
const parent = await mount(Parent, target, { env });
assert.containsOnce(target, ".o_child_component");
callFieldService();
assert.verifySteps(["loadFields called"]);
parent.state.displayChild = false;
await nextTick();
def.resolve();
await nextTick();
assert.verifySteps([]);
try {
await callFieldService();
} catch (e) {
assert.step(e.message);
}
assert.verifySteps(["loadFields called", "Component is destroyed"]);
});

View file

@ -7,6 +7,8 @@ import {
mount,
patchWithCleanup,
triggerEvent,
makeDeferred,
nextTick,
} from "@web/../tests/helpers/utils";
import { FileInput } from "@web/core/file_input/file_input";
import { registry } from "@web/core/registry";
@ -130,6 +132,26 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.isNotVisible(target.querySelector(".o_file_input"));
});
QUnit.test("uploading the same file twice triggers the onChange twice", async (assert) => {
await createFileInput({
props: {
onUpload(files) {
assert.step(files[0].name);
},
},
mockPost: (route, params) => {
return JSON.stringify([{ name: params.ufile[0].name }]);
},
});
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await editInput(target, ".o_file_input input", file);
assert.verifySteps(["fake_file.txt"], "file has been initially uploaded");
await editInput(target, ".o_file_input input", file);
assert.verifySteps(["fake_file.txt"], "file has been uploaded a second time");
});
QUnit.test("uploading a file that is too heavy will send a notification", async (assert) => {
serviceRegistry.add("localization", makeFakeLocalizationService());
patchWithCleanup(session, { max_file_upload_size: 2 });
@ -160,4 +182,29 @@ QUnit.module("Components", ({ beforeEach }) => {
"Only the notification will be triggered and the file won't be uploaded."
);
});
QUnit.test("Upload button is disabled if attachment upload is not finished", async (assert) => {
assert.expect(2);
const uploadedPromise = makeDeferred();
await createFileInput({
mockPost: async (route, params) => {
if (route === "/web/binary/upload_attachment") {
await uploadedPromise;
}
return "[]";
},
props: {},
});
//enable button
const input = target.querySelector(".o_file_input input");
await triggerEvent(input, null, "change", {}, { skipVisibilityCheck: true });
//disable button
assert.ok(input.disabled, "the upload button should be disabled on upload");
uploadedPromise.resolve();
await nextTick();
assert.notOk(input.disabled, "the upload button should be enabled for upload");
});
});

View file

@ -53,7 +53,9 @@ QUnit.module("Components", ({ beforeEach }) => {
Object.assign(xhr, {
upload: new window.EventTarget(),
open() {},
send(data) { customSend && customSend(data); },
send(data) {
customSend && customSend(data);
},
});
return xhr;
},
@ -132,7 +134,7 @@ QUnit.module("Components", ({ beforeEach }) => {
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("abort"));
},
});
await click(target, ".o-file-upload-progress-bar-abort", true);
await click(target, ".o-file-upload-progress-bar-abort", { skipVisibilityCheck: true });
assert.containsNone(target, ".file_upload");
});
@ -149,10 +151,16 @@ QUnit.module("Components", ({ beforeEach }) => {
progressEvent.total = 500000000;
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(target.querySelector(".file_upload_progress_text_left").textContent, "Uploading... (50%)");
assert.strictEqual(
target.querySelector(".file_upload_progress_text_left").textContent,
"Uploading... (50%)"
);
progressEvent.loaded = 350000000;
fileUploadService.uploads[1].xhr.upload.dispatchEvent(progressEvent);
await nextTick();
assert.strictEqual(target.querySelector(".file_upload_progress_text_right").textContent, "(350/500MB)");
assert.strictEqual(
target.querySelector(".file_upload_progress_text_right").textContent,
"(350/500MB)"
);
});
});

View file

@ -0,0 +1,82 @@
/** @odoo-module **/
import { FileModel } from "@web/core/file_viewer/file_model";
QUnit.module("FileModel", () => {
QUnit.module("URL Routing", () => {
QUnit.test("returns correct URL for files with ID", (assert) => {
const imageFile = new FileModel();
Object.assign(imageFile, {
id: 123,
mimetype: "image/png",
name: "test.png",
});
const regularFile = new FileModel();
Object.assign(regularFile, {
id: 456,
mimetype: "application/pdf",
name: "test.pdf",
});
assert.strictEqual(
imageFile.urlRoute,
"/web/image/123",
"Should return correct image URL route with ID"
);
assert.strictEqual(
regularFile.urlRoute,
"/web/content/456",
"Should return correct content URL route with ID"
);
});
QUnit.test("returns direct URL for files without ID", (assert) => {
const fileWithoutId = new FileModel();
const directUrl = "https://example.com/file.pdf";
Object.assign(fileWithoutId, {
id: undefined,
url: directUrl,
mimetype: "application/pdf",
name: "file.pdf",
});
assert.strictEqual(
fileWithoutId.urlRoute,
directUrl,
"Should return direct URL when ID is not present"
);
});
QUnit.test("prioritizes ID over direct URL when both are present", (assert) => {
const imageFile = new FileModel();
Object.assign(imageFile, {
id: 789,
url: "https://example.com/direct-image.jpg",
mimetype: "image/jpeg",
name: "image.jpg",
});
const regularFile = new FileModel();
Object.assign(regularFile, {
id: 101,
url: "https://example.com/direct-file.pdf",
mimetype: "application/pdf",
name: "document.pdf",
});
assert.strictEqual(
imageFile.urlRoute,
"/web/image/789",
"Should use ID-based route for image even when direct URL is present"
);
assert.strictEqual(
regularFile.urlRoute,
"/web/content/101",
"Should use ID-based route for regular file even when direct URL is present"
);
});
});
});

View file

@ -4,7 +4,7 @@ import { browser } from "@web/core/browser/browser";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { registry } from "@web/core/registry";
import { uiService, useActiveElement } from "@web/core/ui/ui_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { getActiveHotkey, hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeTestEnv } from "../../helpers/mock_env";
import {
destroy,
@ -158,7 +158,7 @@ QUnit.test("[accesskey] attrs replaced by [data-hotkey], part 2", async (assert)
useActiveElement("bouh");
}
}
UIOwnershipTakerComponent.template = xml`<p class="owner" t-ref="bouh">bouh</p>`;
UIOwnershipTakerComponent.template = xml`<p class="owner" t-ref="bouh"><button/></p>`;
class MyComponent extends Component {
setup() {
this.state = useState({ foo: true });
@ -766,6 +766,7 @@ QUnit.test("registrations and elements belong to the correct UI owner", async (a
await nextTick();
destroy(comp2);
await Promise.resolve();
triggerHotkey("a");
triggerHotkey("b", true);
await nextTick();
@ -1059,7 +1060,7 @@ QUnit.test("operating area and UI active element", async (assert) => {
useActiveElement("bouh");
}
}
UIOwnershipTakerComponent.template = xml`<p class="owner" t-ref="bouh">bouh</p>`;
UIOwnershipTakerComponent.template = xml`<p class="owner" t-ref="bouh"><button/></p>`;
class C extends Component {
setup() {
this.state = useState({ foo: false });
@ -1119,7 +1120,7 @@ QUnit.test("operating area and UI active element", async (assert) => {
});
QUnit.test("validating option", async (assert) => {
let isValid = false;
let isAvailable = false;
class A extends Component {
setup() {
useHotkey(
@ -1128,10 +1129,7 @@ QUnit.test("validating option", async (assert) => {
assert.step("RGNTDJÛ!");
},
{
validate: (eventTarget) => {
assert.strictEqual(eventTarget, document.activeElement);
return isValid;
},
isAvailable: () => isAvailable,
}
);
}
@ -1143,14 +1141,14 @@ QUnit.test("validating option", async (assert) => {
await nextTick();
assert.verifySteps([]);
isValid = true;
isAvailable = true;
triggerHotkey("Space");
await nextTick();
assert.verifySteps(["RGNTDJÛ!"]);
});
QUnit.test("operation area with validating option", async (assert) => {
let isValid;
let isAvailable;
class A extends Component {
setup() {
const areaRef = useRef("area");
@ -1159,7 +1157,7 @@ QUnit.test("operation area with validating option", async (assert) => {
() => {
assert.step("RGNTDJÛ!");
},
{ area: () => areaRef.el, validate: () => isValid }
{ area: () => areaRef.el, isAvailable: () => isAvailable }
);
}
}
@ -1172,12 +1170,12 @@ QUnit.test("operation area with validating option", async (assert) => {
// Trigger hotkeys from the 'one'
target.querySelector(".one").focus();
isValid = false;
isAvailable = false;
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
isValid = true;
isAvailable = true;
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
@ -1185,12 +1183,12 @@ QUnit.test("operation area with validating option", async (assert) => {
// Trigger hotkeys from the 'two'
target.querySelector(".two").focus();
isValid = false;
isAvailable = false;
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
isValid = true;
isAvailable = true;
triggerHotkey("Space");
await nextTick();
assert.verifySteps(["RGNTDJÛ!"]);
@ -1212,3 +1210,18 @@ QUnit.test("mixing hotkeys with and without operation area", async (assert) => {
await nextTick();
assert.verifySteps(["withArea"]);
});
QUnit.test("native browser space key ' ' is correctly translated to 'space' ", async (assert) => {
class A extends Component {
setup() {
useHotkey("space", () => assert.step("space"));
}
}
A.template = xml``;
assert.strictEqual(getActiveHotkey({ key: " " }), "space");
await mount(A, target, { env });
await triggerHotkey(" "); // event key triggered by the browser
assert.verifySteps(["space"]);
});

View file

@ -6,9 +6,8 @@ import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services
import { getFixture, mount, patchWithCleanup } from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { localizationService } from "@web/core/l10n/localization_service";
import { translatedTerms, _lt } from "@web/core/l10n/translation";
import { translatedTerms, translationLoaded, _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { patch, unpatch } from "@web/core/utils/patch";
import { session } from "@web/session";
import { Component, xml } from "@odoo/owl";
@ -18,19 +17,7 @@ const terms = { Hello: "Bonjour" };
const serviceRegistry = registry.category("services");
class TestComponent extends Component {}
/**
* Patches the 'lang' of the user session and context.
*
* @param {string} lang
* @returns {Promise<void>}
*/
async function patchLang(lang) {
const { defaultLocale, defaultNumberingSystem } = Settings;
registerCleanup(() => {
Settings.defaultLocale = defaultLocale;
Settings.defaultNumberingSystem = defaultNumberingSystem;
});
patchWithCleanup(session.user_context, { lang });
function patchFetch() {
patchWithCleanup(browser, {
fetch: async () => ({
ok: true,
@ -48,72 +35,45 @@ async function patchLang(lang) {
}),
}),
});
}
/**
* Patches the 'lang' of the user session and context.
*
* @param {string} lang
* @returns {Promise<void>}
*/
async function patchLang(lang) {
const { defaultLocale, defaultNumberingSystem } = Settings;
registerCleanup(() => {
Settings.defaultLocale = defaultLocale;
Settings.defaultNumberingSystem = defaultNumberingSystem;
});
patchWithCleanup(session.user_context, { lang });
patchFetch();
serviceRegistry.add("localization", localizationService);
await makeTestEnv();
}
QUnit.module("Translations");
QUnit.test("can translate a text node", async (assert) => {
assert.expect(1);
TestComponent.template = xml`<div>Hello</div>`;
serviceRegistry.add("localization", makeFakeLocalizationService());
const env = await makeTestEnv();
patch(translatedTerms, "add translations", terms);
const target = getFixture();
await mount(TestComponent, target, { env });
assert.strictEqual(target.innerText, "Bonjour");
unpatch(translatedTerms, "add translations");
});
QUnit.test("can lazy translate", async (assert) => {
assert.expect(3);
TestComponent.template = xml`<div><t t-esc="constructor.someLazyText" /></div>`;
TestComponent.someLazyText = _lt("Hello");
assert.strictEqual(TestComponent.someLazyText.toString(), "Hello");
assert.strictEqual(TestComponent.someLazyText.valueOf(), "Hello");
serviceRegistry.add("localization", makeFakeLocalizationService());
const env = await makeTestEnv();
patch(translatedTerms, "add translations", terms);
const target = getFixture();
await mount(TestComponent, target, { env });
assert.strictEqual(target.innerText, "Bonjour");
unpatch(translatedTerms, "add translations");
});
QUnit.test("_t is in env", async (assert) => {
assert.expect(1);
TestComponent.template = xml`<div><t t-esc="env._t('Hello')"/></div>`;
serviceRegistry.add("localization", makeFakeLocalizationService());
const env = await makeTestEnv();
patch(translatedTerms, "add translations", terms);
const target = getFixture();
await mount(TestComponent, target, { env });
assert.strictEqual(target.innerText, "Bonjour");
unpatch(translatedTerms, "add translations");
});
QUnit.test("luxon is configured in the correct lang", async (assert) => {
await patchLang("fr_BE");
assert.strictEqual(DateTime.utc(2021, 12, 10).toFormat("MMMM"), "décembre");
});
QUnit.test("Mismatched locale sr_RS is correctly converted", async (assert) => {
patchLang("sr_RS");
QUnit.test("lang is given by the user context", async (assert) => {
patchWithCleanup(session.user_context, { lang: "fr_FR" });
patchWithCleanup(session, {
cache_hashes: { translations: 1 },
})
patchFetch();
patchWithCleanup(browser, {
fetch(url) {
assert.strictEqual(url, "/web/webclient/translations/1?lang=fr_FR");
return super.fetch(...arguments);
},
});
serviceRegistry.add("localization", localizationService);
await makeTestEnv();
assert.strictEqual(DateTime.local().loc.locale, "sr-cyrl");
});
QUnit.test("Mismatched locale sr@latin is correctly converted", async (assert) => {
patchLang("sr@latin");
await makeTestEnv();
assert.strictEqual(DateTime.local().loc.locale, "sr-Latn-RS");
});
QUnit.test("lang is given by an attribute on the DOM root node", async (assert) => {
assert.expect(1);
patchWithCleanup(session.user_context, { lang: null });
document.documentElement.setAttribute("lang", "fr-FR");
registerCleanup(() => {
@ -122,24 +82,65 @@ QUnit.test("lang is given by an attribute on the DOM root node", async (assert)
patchWithCleanup(session, {
cache_hashes: { translations: 1 },
})
serviceRegistry.add("localization", localizationService);
await makeTestEnv({
mockRPC(route, params) {
assert.strictEqual(route, "/web/webclient/translations/1?lang=fr_FR");
return {
modules: {},
lang_parameters: {
direction: "ltr",
date_format: "%d/%m/%Y",
time_format: "%H:%M:%S",
grouping: "[3,0]",
decimal_point: ",",
thousands_sep: ".",
week_start: 1,
},
};
patchFetch();
patchWithCleanup(browser, {
fetch(url) {
assert.strictEqual(url, "/web/webclient/translations/1?lang=fr_FR");
return super.fetch(...arguments);
},
});
serviceRegistry.add("localization", localizationService);
await makeTestEnv();
});
QUnit.test("url is given by the session", async (assert) => {
patchWithCleanup(session, {
translationURL: "/get_translations",
cache_hashes: { translations: 1 },
})
patchFetch();
patchWithCleanup(browser, {
fetch(url) {
assert.strictEqual(url, "/get_translations/1?lang=en");
return super.fetch(...arguments);
},
});
serviceRegistry.add("localization", localizationService);
await makeTestEnv();
});
QUnit.test("can translate a text node", async (assert) => {
assert.expect(1);
TestComponent.template = xml`<div>Hello</div>`;
serviceRegistry.add("localization", makeFakeLocalizationService());
const env = await makeTestEnv();
patchWithCleanup(translatedTerms, { ...terms });
const target = getFixture();
await mount(TestComponent, target, { env });
assert.strictEqual(target.innerText, "Bonjour");
});
QUnit.test("can lazy translate", async (assert) => {
// Can't use patchWithCleanup cause it doesn't support Symbol
translatedTerms[translationLoaded] = false;
assert.expect(3);
TestComponent.template = xml`<div><t t-esc="constructor.someLazyText" /></div>`;
TestComponent.someLazyText = _t("Hello");
assert.throws(() => TestComponent.someLazyText.toString());
assert.throws(() => TestComponent.someLazyText.valueOf());
serviceRegistry.add("localization", makeFakeLocalizationService());
const env = await makeTestEnv();
patchWithCleanup(translatedTerms, { ...terms });
translatedTerms[translationLoaded] = true;
const target = getFixture();
await mount(TestComponent, target, { env });
assert.strictEqual(target.innerText, "Bonjour");
});
QUnit.test("luxon is configured in the correct lang", async (assert) => {
await patchLang("fr_BE");
assert.strictEqual(DateTime.utc(2021, 12, 10).toFormat("MMMM"), "décembre");
});
QUnit.module("Numbering system");
@ -215,3 +216,23 @@ QUnit.test("tamil has the correct numbering system", async (assert) => {
"௧௦/௧௨/௨௦௨௧ ௧௨::"
);
});
QUnit.test(
"_t fills the format specifiers in translated terms with its extra arguments",
async (assert) => {
patchWithCleanup(translatedTerms, { "Due in %s days": "Échéance dans %s jours" });
const translatedStr = _t("Due in %s days", 513);
assert.strictEqual(translatedStr, "Échéance dans 513 jours");
}
);
QUnit.test(
"_t fills the format specifiers in lazy translated terms with its extra arguments",
async (assert) => {
translatedTerms[translationLoaded] = false;
const translatedStr = _t("Due in %s days", 513);
patchWithCleanup(translatedTerms, { "Due in %s days": "Échéance dans %s jours" });
translatedTerms[translationLoaded] = true;
assert.equal(translatedStr, "Échéance dans 513 jours");
}
);

View file

@ -12,7 +12,6 @@ QUnit.module(
{
beforeEach() {
target = getFixture();
engine = new MacroEngine(target);
mock = mockTimeout();
},
afterEach() {
@ -38,6 +37,10 @@ QUnit.module(
QUnit.test("simple use", async function (assert) {
await mount(TestComponent, target);
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const span = target.querySelector("span.value");
assert.strictEqual(span.textContent, "0");
@ -51,7 +54,6 @@ QUnit.module(
},
],
});
// default interval is 500
await mock.advanceTime(300);
assert.strictEqual(span.textContent, "0");
await mock.advanceTime(300);
@ -60,6 +62,10 @@ QUnit.module(
QUnit.test("multiple steps", async function (assert) {
await mount(TestComponent, target);
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const span = target.querySelector("span.value");
assert.strictEqual(span.textContent, "0");
@ -92,13 +98,19 @@ QUnit.module(
QUnit.test("can use a function as action", async function (assert) {
await mount(TestComponent, target);
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
let flag = false;
engine.activate({
name: "test",
steps: [
{
trigger: "button.inc",
action: () => (flag = true),
action: () => {
flag = true;
},
},
],
});
@ -109,7 +121,11 @@ QUnit.module(
QUnit.test("can input values", async function (assert) {
await mount(TestComponent, target);
const input = target.querySelector("input");
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const input = engine.target.querySelector("input");
engine.activate({
name: "test",
@ -128,7 +144,11 @@ QUnit.module(
QUnit.test("a step can have no trigger", async function (assert) {
await mount(TestComponent, target);
const input = target.querySelector("input");
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const input = engine.target.querySelector("input");
engine.activate({
name: "test",
@ -151,8 +171,11 @@ QUnit.module(
QUnit.test("onStep function is called at each step", async function (assert) {
await mount(TestComponent, target);
const span = target.querySelector("span.value");
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const span = engine.target.querySelector("span.value");
assert.strictEqual(span.textContent, "0");
engine.activate({
@ -177,8 +200,11 @@ QUnit.module(
QUnit.test("trigger can be a function returning an htmlelement", async function (assert) {
await mount(TestComponent, target);
const span = target.querySelector("span.value");
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const span = engine.target.querySelector("span.value");
assert.strictEqual(span.textContent, "0");
engine.activate({
@ -199,9 +225,12 @@ QUnit.module(
QUnit.test("macro does not click on invisible element", async function (assert) {
await mount(TestComponent, target);
const span = target.querySelector("span.value");
const button = target.querySelector("button.inc");
engine = new MacroEngine({
target: target.querySelector(".counter"),
defaultCheckDelay: 500,
});
const span = engine.target.querySelector("span.value");
const button = engine.target.querySelector("button.inc");
assert.strictEqual(span.textContent, "0");
engine.activate({

View file

@ -2,10 +2,10 @@
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import { patch, unpatch } from "@web/core/utils/patch";
import { getFixture, mount, nextTick } from "../helpers/utils";
import { Component, useState, xml } from "@odoo/owl";
import { registerCleanup } from "../helpers/cleanup";
const mainComponentsRegistry = registry.category("main_components");
let target;
@ -38,6 +38,7 @@ QUnit.module("Components", (hooks) => {
});
QUnit.test("unmounts erroring main component", async function (assert) {
assert.expectErrors();
const env = await makeTestEnv();
let compA;
@ -67,24 +68,18 @@ QUnit.module("Components", (hooks) => {
const handler = (ev) => {
assert.step(ev.reason.message);
assert.step(ev.reason.cause.message);
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
window.addEventListener("unhandledrejection", handler);
// fake error service so that the odoo qunit handlers don't think that they need to handle the error
registry.category("services").add("error", { start: () => {} });
patch(QUnit, "MainComponentsContainer QUnit patch", {
onUnhandledRejection: () => {},
registerCleanup(() => {
window.removeEventListener("unhandledrejection", handler);
});
compA.state.shouldThrow = true;
await nextTick();
window.removeEventListener("unhandledrejection", handler);
// unpatch QUnit asap so any other errors can be caught by it
unpatch(QUnit, "MainComponentsContainer QUnit patch");
assert.verifySteps([
'An error occured in the owl lifecycle (see this Error\'s "cause" property)',
"BOOM",
]);
assert.verifyErrors(["BOOM"]);
assert.equal(
target.querySelector(".o-main-components-container").innerHTML,
@ -93,6 +88,8 @@ QUnit.module("Components", (hooks) => {
});
QUnit.test("unmounts erroring main component: variation", async function (assert) {
assert.expectErrors();
const env = await makeTestEnv();
class MainComponentA extends Component {}
@ -122,24 +119,18 @@ QUnit.module("Components", (hooks) => {
const handler = (ev) => {
assert.step(ev.reason.message);
assert.step(ev.reason.cause.message);
// need to preventDefault to remove error from console (so python test pass)
ev.preventDefault();
};
window.addEventListener("unhandledrejection", handler);
// fake error service so that the odoo qunit handlers don't think that they need to handle the error
registry.category("services").add("error", { start: () => {} });
patch(QUnit, "MainComponentsContainer QUnit patch", {
onUnhandledRejection: () => {},
registerCleanup(() => {
window.removeEventListener("unhandledrejection", handler);
});
compB.state.shouldThrow = true;
await nextTick();
window.removeEventListener("unhandledrejection", handler);
// unpatch QUnit asap so any other errors can be caught by it
unpatch(QUnit, "MainComponentsContainer QUnit patch");
assert.verifySteps([
'An error occured in the owl lifecycle (see this Error\'s "cause" property)',
"BOOM",
]);
assert.verifyErrors(["BOOM"]);
assert.equal(
target.querySelector(".o-main-components-container").innerHTML,
"<span>MainComponentA</span>"

View file

@ -163,7 +163,11 @@ QUnit.test("model_selector: with more than 8 models", async function (assert) {
"model_10",
]);
await openAutocomplete();
assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 8);
assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 9);
assert.strictEqual(
fixture.querySelectorAll("li.o-autocomplete--dropdown-item")[8].innerText,
"Start typing..."
);
});
QUnit.test(
@ -227,6 +231,31 @@ QUnit.test("model_selector: select a model", async function (assert) {
assert.verifySteps(["model selected"]);
});
QUnit.test("model_selector: click on start typing", async function (assert) {
await mountModelSelector([
"model_1",
"model_2",
"model_3",
"model_4",
"model_5",
"model_6",
"model_7",
"model_8",
"model_9",
"model_10",
]);
await openAutocomplete();
await click(fixture.querySelectorAll("li.o-autocomplete--dropdown-item")[8]);
assert.equal(fixture.querySelector(".o-autocomplete--input").value, "");
assert.equal(fixture.querySelector(".o-autocomplete.dropdown ul"), null);
//label must be empty
assert.equal(fixture.querySelector(".o_global_filter_label"), null);
//Default value and matching fields should not be available
assert.equal(fixture.querySelector(".o_side_panel_section"), null);
});
QUnit.test("model_selector: with an initial value", async function (assert) {
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1");
assert.equal(fixture.querySelector(".o-autocomplete--input").value, "Model 1");

View file

@ -5,7 +5,16 @@ import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { registry } from "@web/core/registry";
import { NameAndSignature } from "@web/core/signature/name_and_signature";
import { makeTestEnv } from "../helpers/mock_env";
import { click, editInput, getFixture, mount, nextTick } from "../helpers/utils";
import {
click,
editInput,
getFixture,
mount,
mockTimeout,
nextTick,
patchWithCleanup,
triggerEvent,
} from "../helpers/utils";
const serviceRegistry = registry.category("services");
@ -13,6 +22,16 @@ let env;
let target;
let props;
const getNameAndSignatureButtonNames = (target) => {
return [...target.querySelectorAll(".card-header .col-auto")].reduce((names, el) => {
const text = el.textContent.trim();
if (text) {
names.push(text);
}
return names;
}, []);
};
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(async () => {
const mockRPC = async (route, args) => {
@ -37,11 +56,11 @@ QUnit.module("Components", ({ beforeEach }) => {
const defaultName = "Don Toliver";
props.signature.name = defaultName;
await mount(NameAndSignature, target, { env, props });
assert.deepEqual(
[...target.querySelectorAll(".card-header .col-auto")].map((el) =>
el.textContent.trim()
),
["Auto", "Draw", "Load", "Style"]
assert.deepEqual(getNameAndSignatureButtonNames(target), ["Auto", "Draw", "Load"]);
assert.containsOnce(
target,
".o_web_sign_auto_select_style",
"should show font selection dropdown"
);
assert.containsOnce(target, ".card-header .active");
assert.strictEqual(target.querySelector(".card-header .active").textContent.trim(), "Auto");
@ -49,26 +68,14 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.strictEqual(target.querySelector(".o_web_sign_name_group input").value, defaultName);
await click(target, ".o_web_sign_draw_button");
assert.deepEqual(
[...target.querySelectorAll(".card-header .col-auto")].map((el) =>
el.textContent.trim()
),
["Auto", "Draw", "Load", "Clear"]
);
assert.deepEqual(getNameAndSignatureButtonNames(target), ["Auto", "Draw", "Load"]);
assert.containsOnce(target, ".o_web_sign_draw_clear");
assert.containsOnce(target, ".card-header .active");
assert.strictEqual(target.querySelector(".card-header .active").textContent.trim(), "Draw");
await click(target, ".o_web_sign_load_button");
assert.deepEqual(
[...target.querySelectorAll(".card-header .col-auto")].map((el) =>
el.textContent.trim()
),
["Auto", "Draw", "Load", ""]
);
assert.hasClass(
target.querySelectorAll(".card-header .col-auto")[3],
"o_web_sign_load_file"
);
assert.deepEqual(getNameAndSignatureButtonNames(target), ["Auto", "Draw", "Load"]);
assert.containsOnce(target, ".o_web_sign_load_file");
assert.containsOnce(target, ".card-header .active");
assert.strictEqual(target.querySelector(".card-header .active").textContent.trim(), "Load");
});
@ -81,12 +88,8 @@ QUnit.module("Components", ({ beforeEach }) => {
await editInput(target, ".o_web_sign_name_group input", "plop");
await nextTick();
assert.deepEqual(
[...target.querySelectorAll(".card-header .col-auto")].map((el) =>
el.textContent.trim()
),
["Auto", "Draw", "Load", "Style"]
);
assert.deepEqual(getNameAndSignatureButtonNames(target), ["Auto", "Draw", "Load"]);
assert.containsOnce(target, ".o_web_sign_auto_select_style");
assert.strictEqual(target.querySelector(".card-header .active").textContent.trim(), "Auto");
assert.containsOnce(target, ".o_web_sign_name_group input");
assert.strictEqual(target.querySelector(".o_web_sign_name_group input").value, "plop");
@ -106,12 +109,8 @@ QUnit.module("Components", ({ beforeEach }) => {
};
props.signature.name = defaultName;
await mount(NameAndSignature, target, { env, props });
assert.deepEqual(
[...target.querySelectorAll(".card-header .col-auto")].map((el) =>
el.textContent.trim()
),
["Auto", "Draw", "Load", "Style"]
);
assert.deepEqual(getNameAndSignatureButtonNames(target), ["Auto", "Draw", "Load"]);
assert.containsOnce(target, ".o_web_sign_auto_select_style");
assert.containsOnce(target, ".card-header .active");
assert.strictEqual(
target.querySelector(".card-header .active").textContent.trim(),
@ -128,12 +127,8 @@ QUnit.module("Components", ({ beforeEach }) => {
noInputName: true,
};
await mount(NameAndSignature, target, { env, props });
assert.deepEqual(
[...target.querySelectorAll(".card-header .col-auto")].map((el) =>
el.textContent.trim()
),
["Draw", "Load", "Clear"]
);
assert.deepEqual(getNameAndSignatureButtonNames(target), ["Draw", "Load"]);
assert.containsOnce(target, ".o_web_sign_draw_clear");
assert.containsOnce(target, ".card-header .active");
assert.strictEqual(
target.querySelector(".card-header .active").textContent.trim(),
@ -141,4 +136,44 @@ QUnit.module("Components", ({ beforeEach }) => {
);
}
);
QUnit.test(
"test name_and_signature widget update signmode with onSignatureChange prop",
async function (assert) {
const defaultName = "Noi dea";
let currentSignMode = "";
props = {
...props,
onSignatureChange: function (signMode) {
if (currentSignMode !== signMode) {
currentSignMode = signMode;
assert.step(signMode);
}
},
};
props.signature.name = defaultName;
await mount(NameAndSignature, target, { env, props });
await click(target, ".o_web_sign_draw_button");
assert.verifySteps(["auto", "draw"], "should be draw");
}
);
QUnit.test("resize events are handled", async function (assert) {
patchWithCleanup(NameAndSignature.prototype, {
resizeSignature() {
assert.step("resized");
return super.resizeSignature();
},
});
const { advanceTime } = mockTimeout();
await mount(NameAndSignature, target, { env, props });
await editInput(target, ".o_web_sign_name_group input", "plop");
await nextTick();
await click(target, ".o_web_sign_draw_button");
await nextTick();
assert.verifySteps(["resized"]);
await triggerEvent(window, null, "resize");
await advanceTime(300);
assert.verifySteps(["resized"]);
});
});

View file

@ -0,0 +1,142 @@
/** @odoo-module **/
import { ERROR_INACCESSIBLE_OR_MISSING, nameService } from "@web/core/name_service";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { registry } from "@web/core/registry";
const serviceRegistry = registry.category("services");
let serverData;
QUnit.module("Name Service", {
async beforeEach() {
serverData = {
models: {
dev: {
fields: {},
records: [
{ id: 1, display_name: "Julien" },
{ id: 2, display_name: "Pierre" },
],
},
},
};
serviceRegistry.add("name", nameService);
},
});
QUnit.test("single loadDisplayNames", async (assert) => {
const env = await makeTestEnv({ serverData });
const displayNames = await env.services.name.loadDisplayNames("dev", [1, 2]);
assert.deepEqual(displayNames, { 1: "Julien", 2: "Pierre" });
});
QUnit.test("loadDisplayNames is done in silent mode", async (assert) => {
assert.expect(2);
const env = await makeTestEnv({ serverData });
env.bus.addEventListener("RPC:REQUEST", (ev) => {
const silent = ev.detail.settings.silent;
assert.step("RPC:REQUEST" + (silent ? " (silent)" : ""));
});
await env.services.name.loadDisplayNames("dev", [1]);
assert.verifySteps(["RPC:REQUEST (silent)"]);
});
QUnit.test("single loadDisplayNames following addDisplayNames", async (assert) => {
const mockRPC = (_, { method }) => {
assert.step(method);
};
const env = await makeTestEnv({ serverData, mockRPC });
env.services.name.addDisplayNames("dev", { 1: "JUM", 2: "PIPU" });
const displayNames = await env.services.name.loadDisplayNames("dev", [1, 2]);
assert.deepEqual(displayNames, { 1: "JUM", 2: "PIPU" });
assert.verifySteps([]);
});
QUnit.test("single loadDisplayNames following addDisplayNames (2)", async (assert) => {
const mockRPC = (_, { kwargs, method }) => {
assert.step(method);
const ids = kwargs.domain[0][2];
assert.step(`id(s): ${ids.join(", ")}`);
};
const env = await makeTestEnv({ serverData, mockRPC });
env.services.name.addDisplayNames("dev", { 1: "JUM" });
const displayNames = await env.services.name.loadDisplayNames("dev", [1, 2]);
assert.deepEqual(displayNames, { 1: "JUM", 2: "Pierre" });
assert.verifySteps(["web_search_read", "id(s): 2"]);
});
QUnit.test("loadDisplayNames in batch", async (assert) => {
const mockRPC = (_, { kwargs, method }) => {
assert.step(method);
const ids = kwargs.domain[0][2];
assert.step(`id(s): ${ids.join(", ")}`);
};
const env = await makeTestEnv({ serverData, mockRPC });
const prom1 = env.services.name.loadDisplayNames("dev", [1]);
assert.verifySteps([]);
const prom2 = env.services.name.loadDisplayNames("dev", [2]);
assert.verifySteps([]);
const [displayNames1, displayNames2] = await Promise.all([prom1, prom2]);
assert.deepEqual(displayNames1, { 1: "Julien" });
assert.deepEqual(displayNames2, { 2: "Pierre" });
assert.verifySteps(["web_search_read", "id(s): 1, 2"]);
});
QUnit.test("loadDisplayNames on different models", async (assert) => {
serverData.models.PO = {
fields: {},
records: [{ id: 1, display_name: "Damien" }],
};
const mockRPC = (_, { kwargs, method, model }) => {
assert.step(method);
assert.step(model);
const ids = kwargs.domain[0][2];
assert.step(`id(s): ${ids.join(", ")}`);
};
const env = await makeTestEnv({ serverData, mockRPC });
const prom1 = env.services.name.loadDisplayNames("dev", [1]);
assert.verifySteps([]);
const prom2 = env.services.name.loadDisplayNames("PO", [1]);
assert.verifySteps([]);
const [displayNames1, displayNames2] = await Promise.all([prom1, prom2]);
assert.deepEqual(displayNames1, { 1: "Julien" });
assert.deepEqual(displayNames2, { 1: "Damien" });
assert.verifySteps(["web_search_read", "dev", "id(s): 1", "web_search_read", "PO", "id(s): 1"]);
});
QUnit.test("invalid id", async (assert) => {
assert.expect(1);
const env = await makeTestEnv({ serverData });
try {
await env.services.name.loadDisplayNames("dev", ["a"]);
} catch (e) {
assert.strictEqual(e.message, "Invalid ID: a");
}
});
QUnit.test("inaccessible or missing id", async (assert) => {
const mockRPC = (_, { method }) => {
assert.step(method);
};
const env = await makeTestEnv({ serverData, mockRPC });
const displayNames = await env.services.name.loadDisplayNames("dev", [3]);
assert.deepEqual(displayNames, { 3: ERROR_INACCESSIBLE_OR_MISSING });
assert.verifySteps(["web_search_read"]);
});
QUnit.test("batch + inaccessible/missing", async (assert) => {
const mockRPC = (_, { method, kwargs }) => {
assert.step(method);
const ids = kwargs.domain[0][2];
assert.step(`id(s): ${ids.join(", ")}`);
};
const env = await makeTestEnv({ serverData, mockRPC });
const prom1 = env.services.name.loadDisplayNames("dev", [1, 3]);
assert.verifySteps([]);
const prom2 = env.services.name.loadDisplayNames("dev", [2, 4]);
assert.verifySteps([]);
const [displayNames1, displayNames2] = await Promise.all([prom1, prom2]);
assert.deepEqual(displayNames1, { 1: "Julien", 3: ERROR_INACCESSIBLE_OR_MISSING });
assert.deepEqual(displayNames2, { 2: "Pierre", 4: ERROR_INACCESSIBLE_OR_MISSING });
assert.verifySteps(["web_search_read", "id(s): 1, 3, 2, 4"]);
});

View file

@ -18,13 +18,7 @@ QUnit.module("download", (hooks) => {
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
let error;
try {
@ -47,13 +41,7 @@ QUnit.module("download", (hooks) => {
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
let error;
try {
@ -87,13 +75,7 @@ QUnit.module("download", (hooks) => {
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
let error;
try {
@ -122,13 +104,7 @@ QUnit.module("download", (hooks) => {
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
let error;
try {
@ -161,13 +137,7 @@ QUnit.module("download", (hooks) => {
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
assert.containsNone(document.body, "a[download]");

View file

@ -0,0 +1,56 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { patchWithCleanup } from "../../helpers/utils";
import { get, post } from "@web/core/network/http_service";
function onFetch(fetch) {
patchWithCleanup(browser, {
fetch: (route, params) => {
const result = fetch(route, params);
return result ?? new Response("{}");
},
});
}
QUnit.module("HTTP");
QUnit.test("method is correctly set", async (assert) => {
onFetch((_, { method }) => {
assert.step(method);
});
await get("/call_get");
assert.verifySteps(["GET"]);
await post("/call_post");
assert.verifySteps(["POST"]);
});
QUnit.test("check status 502", async (assert) => {
onFetch(() => {
return new Response({}, { status: 502 });
});
try {
await get("/custom_route");
assert.notOk(true);
} catch (e) {
assert.strictEqual(e.message, "Failed to fetch");
}
});
QUnit.test("FormData is built by post", async (assert) => {
onFetch((_, { body }) => {
assert.ok(body instanceof FormData);
assert.strictEqual(body.get("s"), "1");
assert.strictEqual(body.get("a"), "1");
assert.deepEqual(body.getAll("a"), ["1", "2", "3"]);
});
await post("/call_post", { s: 1, a: [1, 2, 3] });
});
QUnit.test("FormData is given to post", async (assert) => {
onFetch((_, { body }) => {
assert.strictEqual(body, formData);
});
const formData = new FormData();
await post("/call_post", formData);
});

View file

@ -5,7 +5,6 @@ import { ConnectionAbortedError, ConnectionLostError, rpcService } from "@web/co
import { notificationService } from "@web/core/notifications/notification_service";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { patch, unpatch } from "@web/core/utils/patch";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { makeMockXHR } from "../../helpers/mock_services";
import {
@ -20,7 +19,6 @@ import { registerCleanup } from "../../helpers/cleanup";
import { Component, xml } from "@odoo/owl";
let isXHRMocked = false;
const serviceRegistry = registry.category("services");
let isDeployed = false;
@ -31,18 +29,7 @@ async function testRPC(route, params) {
request = data;
url = this.url;
});
if (isXHRMocked) {
unpatch(browser, "mock.xhr");
}
patch(
browser,
"mock.xhr",
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
isXHRMocked = true;
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
if (isDeployed) {
clearRegistryWithCleanup(registry.category("main_components"));
@ -66,12 +53,6 @@ QUnit.module("RPC", {
serviceRegistry.add("notification", notificationService);
serviceRegistry.add("rpc", rpcService);
},
afterEach() {
if (isXHRMocked) {
unpatch(browser, "mock.xhr");
isXHRMocked = false;
}
},
});
QUnit.test("can perform a simple rpc", async (assert) => {
@ -82,12 +63,11 @@ QUnit.test("can perform a simple rpc", async (assert) => {
assert.ok(typeof request.id === "number");
});
patch(browser, "mock.xhr", { XMLHttpRequest: MockXHR }, { pure: true });
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
const env = await makeTestEnv({ serviceRegistry });
const result = await env.services.rpc("/test/");
assert.deepEqual(result, { action_id: 123 });
unpatch(browser, "mock.xhr");
});
QUnit.test("trigger an error when response has 'error' key", async (assert) => {
@ -101,17 +81,16 @@ QUnit.test("trigger an error when response has 'error' key", async (assert) => {
},
};
const MockXHR = makeMockXHR({ error });
patch(browser, "mock.xhr", { XMLHttpRequest: MockXHR }, { pure: true });
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
const env = await makeTestEnv({
serviceRegistry,
});
try {
await env.services.rpc("/test/");
} catch (_error) {
} catch {
assert.ok(true);
}
unpatch(browser, "mock.xhr");
});
QUnit.test("rpc with simple routes", async (assert) => {
@ -133,7 +112,7 @@ QUnit.test("rpc coming from destroyed components are left pending", async (asser
MyComponent.template = xml`<div/>`;
const def = makeDeferred();
const MockXHR = makeMockXHR({ result: "1" }, () => {}, def);
patch(browser, "mock.xhr", { XMLHttpRequest: MockXHR }, { pure: true });
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
const env = await makeTestEnv({
serviceRegistry,
@ -157,7 +136,6 @@ QUnit.test("rpc coming from destroyed components are left pending", async (asser
await nextTick();
assert.strictEqual(isResolved, false);
assert.strictEqual(isFailed, false);
unpatch(browser, "mock.xhr");
});
QUnit.test("rpc initiated from destroyed components throw exception", async (assert) => {
@ -183,29 +161,31 @@ QUnit.test("rpc initiated from destroyed components throw exception", async (ass
QUnit.test("check trigger RPC:REQUEST and RPC:RESPONSE for a simple rpc", async (assert) => {
const MockXHR = makeMockXHR({ test: true }, () => 1);
patch(browser, "mock.xhr", { XMLHttpRequest: MockXHR }, { pure: true });
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
const env = await makeTestEnv({
serviceRegistry,
});
const rpcIdsRequest = [];
const rpcIdsResponse = [];
env.bus.addEventListener("RPC:REQUEST", (rpcId) => {
rpcIdsRequest.push(rpcId);
assert.step("RPC:REQUEST");
env.bus.addEventListener("RPC:REQUEST", (ev) => {
rpcIdsRequest.push(ev.detail.data.id);
const silent = ev.detail.settings.silent;
assert.step("RPC:REQUEST" + (silent ? "(silent)" : ""));
});
env.bus.addEventListener("RPC:RESPONSE", (rpcId) => {
rpcIdsResponse.push(rpcId);
assert.step("RPC:RESPONSE");
env.bus.addEventListener("RPC:RESPONSE", (ev) => {
rpcIdsResponse.push(ev.detail.data.id);
const silent = ev.detail.settings.silent ? "(silent)" : "";
const success = "result" in ev.detail ? "(ok)" : "";
const fail = "error" in ev.detail ? "(ko)" : "";
assert.step("RPC:RESPONSE" + silent + success + fail);
});
await env.services.rpc("/test/");
assert.strictEqual(rpcIdsRequest.toString(), rpcIdsResponse.toString());
assert.verifySteps(["RPC:REQUEST", "RPC:RESPONSE"]);
assert.verifySteps(["RPC:REQUEST", "RPC:RESPONSE(ok)"]);
await env.services.rpc("/test/", {}, { silent: true });
assert.verifySteps([]);
unpatch(browser, "mock.xhr");
assert.verifySteps(["RPC:REQUEST(silent)", "RPC:RESPONSE(silent)(ok)"]);
});
QUnit.test("check trigger RPC:REQUEST and RPC:RESPONSE for a rpc with an error", async (assert) => {
@ -218,34 +198,36 @@ QUnit.test("check trigger RPC:REQUEST and RPC:RESPONSE for a rpc with an error",
},
};
const MockXHR = makeMockXHR({ error });
patch(browser, "mock.xhr", { XMLHttpRequest: MockXHR }, { pure: true });
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
const env = await makeTestEnv({
serviceRegistry,
});
const rpcIdsRequest = [];
const rpcIdsResponse = [];
env.bus.addEventListener("RPC:REQUEST", (rpcId) => {
rpcIdsRequest.push(rpcId);
env.bus.addEventListener("RPC:REQUEST", (ev) => {
rpcIdsRequest.push(ev);
assert.step("RPC:REQUEST");
});
env.bus.addEventListener("RPC:RESPONSE", (rpcId) => {
rpcIdsResponse.push(rpcId);
assert.step("RPC:RESPONSE");
env.bus.addEventListener("RPC:RESPONSE", (ev) => {
rpcIdsResponse.push(ev);
const silent = ev.detail.settings.silent ? "(silent)" : "";
const success = "result" in ev.detail ? "(ok)" : "";
const fail = "error" in ev.detail ? "(ko)" : "";
assert.step("RPC:RESPONSE" + silent + success + fail);
});
try {
await env.services.rpc("/test/");
} catch (_e) {
assert.ok(true);
} catch {
assert.step("ok");
}
assert.strictEqual(rpcIdsRequest.toString(), rpcIdsResponse.toString());
assert.verifySteps(["RPC:REQUEST", "RPC:RESPONSE"]);
unpatch(browser, "mock.xhr");
assert.verifySteps(["RPC:REQUEST", "RPC:RESPONSE(ko)", "ok"]);
});
QUnit.test("check connection aborted", async (assert) => {
const def = makeDeferred();
const MockXHR = makeMockXHR({}, () => {}, def);
patchWithCleanup(browser, { XMLHttpRequest: MockXHR }, { pure: true });
patchWithCleanup(browser, { XMLHttpRequest: MockXHR });
const env = await makeTestEnv({ serviceRegistry });
env.bus.addEventListener("RPC:REQUEST", () => {
assert.step("RPC:REQUEST");

View file

@ -334,4 +334,93 @@ QUnit.module("Components", (hooks) => {
assert.containsOnce(target, ".page2");
assert.strictEqual(target.querySelector(".nav-link.active").textContent, "page2");
});
QUnit.test("disabled pages are greyed out and can't be toggled", async (assert) => {
class Parent extends Component {}
Parent.components = { Notebook };
Parent.template = xml`
<Notebook defaultPage="'1'">
<t t-set-slot="1" title="'page1'" isVisible="true">
<div class="page1" />
</t>
<t t-set-slot="2" title="'page2'" isVisible="true" isDisabled="true">
<div class="page2" />
</t>
<t t-set-slot="3" title="'page3'" isVisible="true">
<div class="page3" />
</t>
</Notebook>`;
const env = await makeTestEnv();
await mount(Parent, target, { env });
assert.containsOnce(target, ".page1", "the default page is displayed");
assert.hasClass(
target.querySelector(".nav-item:nth-child(2)"),
"disabled",
"tab of the disabled page is greyed out"
);
await click(target.querySelector(".nav-item:nth-child(2) .nav-link"));
assert.containsOnce(target, ".page1", "the same page is still displayed");
await click(target.querySelector(".nav-item:nth-child(3) .nav-link"));
assert.containsOnce(target, ".page3", "the third page is now displayed");
});
QUnit.test("icons can be given for each page tab", async (assert) => {
class Parent extends Component {
get icons() {
return {
1: "fa-trash",
3: "fa-pencil",
};
}
}
Parent.components = { Notebook };
Parent.template = xml`
<Notebook defaultPage="'1'" icons="icons">
<t t-set-slot="1" title="'page1'" isVisible="true">
<div class="page1" />
</t>
<t t-set-slot="2" title="'page2'" isVisible="true">
<div class="page2" />
</t>
<t t-set-slot="3" title="'page3'" isVisible="true">
<div class="page3" />
</t>
</Notebook>`;
const env = await makeTestEnv();
await mount(Parent, target, { env });
assert.hasClass(
target.querySelector(".nav-item:nth-child(1) i"),
"fa-trash",
"tab of the first page has the given icon"
);
assert.strictEqual(
target.querySelector(".nav-item:nth-child(1)").textContent,
"page1",
"tab of the second page has the right text"
);
assert.containsNone(
target.querySelector(".nav-item:nth-child(2)"),
"i",
"tab of the second page doesn't have an icon"
);
assert.strictEqual(
target.querySelector(".nav-item:nth-child(2)").textContent,
"page2",
"tab of the second page has the right text"
);
assert.hasClass(
target.querySelector(".nav-item:nth-child(3) i"),
"fa-pencil",
"tab of the third page has the given icon"
);
assert.strictEqual(
target.querySelector(".nav-item:nth-child(3)").textContent,
"page3",
"tab of the second page has the right text"
);
});
});

View file

@ -4,7 +4,15 @@ import { browser } from "@web/core/browser/browser";
import { notificationService } from "@web/core/notifications/notification_service";
import { registry } from "@web/core/registry";
import { makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, mount, nextTick, patchWithCleanup } from "../../helpers/utils";
import {
click,
getFixture,
mount,
nextTick,
patchWithCleanup,
mockTimeout,
triggerEvent,
} from "../../helpers/utils";
import { markup } from "@odoo/owl";
@ -315,6 +323,33 @@ QUnit.test("can close a non-sticky notification", async (assert) => {
assert.containsNone(target, ".o_notification");
});
QUnit.test("can refresh the duration of a non-sticky notification", async (assert) => {
const { advanceTime } = mockTimeout();
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 first non-sticky notification");
notifService.add("I'm a second non-sticky notification");
await nextTick();
assert.containsN(target, ".o_notification", 2);
await advanceTime(3000);
await triggerEvent(target, ".o_notification:first-child", "mouseenter");
await advanceTime(5000);
// Both notifications should be visible as long as mouse is over one of them
assert.containsN(target, ".o_notification", 2);
await triggerEvent(target, ".o_notification:first-child", "mouseleave");
await advanceTime(3000);
// Both notifications should be refreshed in duration (4000 ms)
assert.containsN(target, ".o_notification", 2);
await advanceTime(1000);
assert.containsNone(target, ".o_notification");
});
QUnit.test("close a non-sticky notification while another one remains", async (assert) => {
let timeoutCB;
patchWithCleanup(browser, {

View file

@ -91,7 +91,7 @@ QUnit.test("basic method call of model", async (assert) => {
});
});
QUnit.test("create method", async (assert) => {
QUnit.test("create method: one record", async (assert) => {
const [query, rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
const env = await makeTestEnv();
@ -99,9 +99,11 @@ QUnit.test("create method", async (assert) => {
assert.strictEqual(query.route, "/web/dataset/call_kw/partner/create");
assert.deepEqual(query.params, {
args: [
{
color: "red",
},
[
{
color: "red",
},
],
],
kwargs: {
context: {
@ -115,25 +117,32 @@ QUnit.test("create method", async (assert) => {
});
});
QUnit.test("nameGet method", async (assert) => {
QUnit.test("create method: several records", async (assert) => {
const [query, rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
const env = await makeTestEnv();
const context = { complete: true };
await env.services.orm.nameGet("sale.order", [2, 5], { context });
assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/name_get");
await env.services.orm.create("partner", [{ color: "red" }, { color: "green" }]);
assert.strictEqual(query.route, "/web/dataset/call_kw/partner/create");
assert.deepEqual(query.params, {
args: [[2, 5]],
args: [
[
{
color: "red",
},
{
color: "green",
},
],
],
kwargs: {
context: {
complete: true,
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "name_get",
model: "sale.order",
method: "create",
model: "partner",
});
});
@ -266,6 +275,25 @@ QUnit.test("readGroup method", async (assert) => {
});
});
QUnit.test("test readGroup method removes duplicate values from groupby", async (assert) => {
const [query, rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
const env = await makeTestEnv();
await env.services.orm.readGroup(
"sale.order",
[["user_id", "=", 2]],
["amount_total:sum"],
["date_order:month", "date_order:month"],
{ offset: 1 }
);
assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/read_group");
assert.deepEqual(
query.params.kwargs.groupby,
["date_order:month"],
"Duplicate values should be removed from groupby"
);
});
QUnit.test("searchRead method", async (assert) => {
const [query, rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
@ -308,11 +336,38 @@ QUnit.test("searchCount method", async (assert) => {
});
});
QUnit.test("webRead method", async (assert) => {
const [query, rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
const env = await makeTestEnv();
const context = { abc: 3 };
await env.services.orm.webRead("sale.order", [2, 5], {
specification: { name: {}, amount: {} },
context,
});
assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/web_read");
assert.deepEqual(query.params, {
args: [[2, 5]],
kwargs: {
specification: { name: {}, amount: {} },
context: {
abc: 3,
lang: "en",
tz: "taht",
uid: 7,
},
},
method: "web_read",
model: "sale.order",
});
});
QUnit.test("webSearchRead method", async (assert) => {
const [query, rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
const env = await makeTestEnv();
await env.services.orm.webSearchRead("sale.order", [["user_id", "=", 2]], ["amount_total"]);
const specification = { amount_total: {} };
await env.services.orm.webSearchRead("sale.order", [["user_id", "=", 2]], { specification });
assert.strictEqual(query.route, "/web/dataset/call_kw/sale.order/web_search_read");
assert.deepEqual(query.params, {
args: [],
@ -323,14 +378,14 @@ QUnit.test("webSearchRead method", async (assert) => {
uid: 7,
},
domain: [["user_id", "=", 2]],
fields: ["amount_total"],
specification: { amount_total: {} },
},
method: "web_search_read",
model: "sale.order",
});
});
QUnit.test("useModel is specialized for component", async (assert) => {
QUnit.test("orm is specialized for component", async (assert) => {
const [, /* query */ rpc] = makeFakeRPC();
serviceRegistry.add("rpc", rpc);
const env = await makeTestEnv();

View file

@ -0,0 +1,118 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { overlayService } from "@web/core/overlay/overlay_service";
import { registry } from "@web/core/registry";
import { makeTestEnv } from "../helpers/mock_env";
import { getFixture, mount, nextTick } from "../helpers/utils";
let target = null;
let overlay = null;
QUnit.module("overlay service", {
async beforeEach() {
registry.category("services").add("overlay", overlayService);
target = getFixture();
const env = await makeTestEnv();
await mount(MainComponentsContainer, target, { env });
overlay = env.services.overlay;
},
});
QUnit.test("simple case", async (assert) => {
assert.containsOnce(target, ".o-overlay-container");
class MyComp extends Component {
static template = xml`
<div class="overlayed"></div>
`;
}
const remove = overlay.add(MyComp, {});
await nextTick();
assert.containsOnce(target, ".o-overlay-container > .overlayed");
remove();
await nextTick();
assert.containsNone(target, ".o-overlay-container > .overlayed");
});
QUnit.test("onRemove callback", async (assert) => {
class MyComp extends Component {
static template = xml``;
}
const onRemove = () => assert.step("onRemove");
const remove = overlay.add(MyComp, {}, { onRemove });
assert.verifySteps([]);
remove();
assert.verifySteps(["onRemove"]);
});
QUnit.test("multiple overlays", async (assert) => {
class MyComp extends Component {
static template = xml`
<div class="overlayed" t-att-class="props.className"></div>
`;
}
const remove1 = overlay.add(MyComp, { className: "o1" });
const remove2 = overlay.add(MyComp, { className: "o2" });
const remove3 = overlay.add(MyComp, { className: "o3" });
await nextTick();
assert.containsN(target, ".overlayed", 3);
assert.hasClass(target.querySelector(".overlayed:nth-child(1)"), "o1");
assert.hasClass(target.querySelector(".overlayed:nth-child(2)"), "o2");
assert.hasClass(target.querySelector(".overlayed:nth-child(3)"), "o3");
remove1();
await nextTick();
assert.containsN(target, ".overlayed", 2);
assert.hasClass(target.querySelector(".overlayed:nth-child(1)"), "o2");
assert.hasClass(target.querySelector(".overlayed:nth-child(2)"), "o3");
remove2();
await nextTick();
assert.containsOnce(target, ".overlayed");
assert.hasClass(target.querySelector(".overlayed:nth-child(1)"), "o3");
remove3();
await nextTick();
assert.containsNone(target, ".overlayed");
});
QUnit.test("sequence", async (assert) => {
class MyComp extends Component {
static template = xml`
<div class="overlayed" t-att-class="props.className"></div>
`;
}
const remove1 = overlay.add(MyComp, { className: "o1" }, { sequence: 50 });
const remove2 = overlay.add(MyComp, { className: "o2" }, { sequence: 60 });
const remove3 = overlay.add(MyComp, { className: "o3" }, { sequence: 40 });
await nextTick();
assert.containsN(target, ".overlayed", 3);
assert.hasClass(target.querySelector(".overlayed:nth-child(1)"), "o3");
assert.hasClass(target.querySelector(".overlayed:nth-child(2)"), "o1");
assert.hasClass(target.querySelector(".overlayed:nth-child(3)"), "o2");
remove1();
await nextTick();
assert.containsN(target, ".overlayed", 2);
assert.hasClass(target.querySelector(".overlayed:nth-child(1)"), "o3");
assert.hasClass(target.querySelector(".overlayed:nth-child(2)"), "o2");
remove2();
await nextTick();
assert.containsOnce(target, ".overlayed");
assert.hasClass(target.querySelector(".overlayed:nth-child(1)"), "o3");
remove3();
await nextTick();
assert.containsNone(target, ".overlayed");
});

View file

@ -183,8 +183,6 @@ QUnit.module("Components", ({ beforeEach }) => {
});
QUnit.test("pager disabling", async function (assert) {
assert.expect(9);
const reloadPromise = makeDeferred();
const pager = await makePager({
@ -203,9 +201,9 @@ QUnit.module("Components", ({ beforeEach }) => {
});
const pagerButtons = target.querySelectorAll("button");
// Click twice
await click(target.querySelector(`.o_pager button.o_pager_next`));
// Click and check button is disabled
await click(target.querySelector(`.o_pager button.o_pager_next`));
assert.ok(target.querySelector(`.o_pager button.o_pager_next`).disabled);
// Try to edit the pager value
await click(target, ".o_pager_value");

View file

@ -4,9 +4,11 @@ import { usePopover } from "@web/core/popover/popover_hook";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { destroy, getFixture, mount, nextTick } from "../../helpers/utils";
import { click, destroy, getFixture, mount, nextTick } from "../../helpers/utils";
import { Component, xml } from "@odoo/owl";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { makeFakeLocalizationService } from "../../helpers/mock_services";
let env;
let target;
@ -34,7 +36,11 @@ PseudoWebClient.template = xml`
QUnit.module("Popover hook", {
async beforeEach() {
clearRegistryWithCleanup(mainComponents);
registry.category("services").add("popover", popoverService);
registry
.category("services")
.add("popover", popoverService)
.add("localization", makeFakeLocalizationService())
.add("hotkey", hotkeyService);
target = getFixture();
env = await makeTestEnv();
await mount(PseudoWebClient, target, { env });
@ -48,17 +54,17 @@ QUnit.test("close popover when component is unmounted", async (assert) => {
class CompWithPopover extends Component {
setup() {
this.popover = usePopover();
this.popover = usePopover(Comp);
}
}
CompWithPopover.template = xml`<div />`;
const comp1 = await mount(CompWithPopover, target, { env });
comp1.popover.add(popoverTarget, Comp, { id: "comp1" });
comp1.popover.open(popoverTarget, { id: "comp1" });
await nextTick();
const comp2 = await mount(CompWithPopover, target, { env });
comp2.popover.add(popoverTarget, Comp, { id: "comp2" });
comp2.popover.open(popoverTarget, { id: "comp2" });
await nextTick();
assert.containsN(target, ".o_popover", 2);
@ -79,3 +85,54 @@ QUnit.test("close popover when component is unmounted", async (assert) => {
assert.containsNone(target, ".o_popover #comp1");
assert.containsNone(target, ".o_popover #comp2");
});
QUnit.test("popover opened from another", async (assert) => {
class Comp extends Component {
static id = 0;
static template = xml`
<div class="p-4">
<button class="pop-open" t-on-click="(ev) => this.popover.open(ev.target, {})">open popover</button>
</div>
`;
setup() {
this.popover = usePopover(Comp, {
popoverClass: `popover-${++Comp.id}`,
});
}
}
await mount(Comp, target, { env });
await click(target, ".pop-open");
assert.containsOnce(target, ".popover-1", "open first popover");
await click(target, ".popover-1 .pop-open");
assert.containsN(target, ".o_popover", 2, "open second popover from the first one");
assert.containsOnce(target, ".popover-1");
assert.containsOnce(target, ".popover-2");
await click(target, ".popover-2 .pop-open");
assert.containsN(target, ".o_popover", 3, "open third popover from the second one");
assert.containsOnce(target, ".popover-1");
assert.containsOnce(target, ".popover-2");
assert.containsOnce(target, ".popover-3");
await click(target, ".popover-3");
assert.containsN(target, ".o_popover", 3, "clicking inside third popover closes nothing");
assert.containsOnce(target, ".popover-1");
assert.containsOnce(target, ".popover-2");
assert.containsOnce(target, ".popover-3");
await click(target, ".popover-2");
assert.containsN(
target,
".o_popover",
2,
"clicking inside second popover closes third popover"
);
assert.containsOnce(target, ".popover-1");
assert.containsOnce(target, ".popover-2");
await click(target, "#close");
assert.containsNone(target, ".o_popover", "clicking out of any popover closes them all");
});

View file

@ -1,11 +1,12 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, mount, nextTick } from "../../helpers/utils";
import { Component, xml } from "@odoo/owl";
import { makeFakeLocalizationService } from "../../helpers/mock_services";
import { click, getFixture, mount, nextTick, triggerEvent } from "../../helpers/utils";
let env;
let fixture;
@ -35,7 +36,11 @@ PseudoWebClient.template = xml`
QUnit.module("Popover service", {
async beforeEach() {
clearRegistryWithCleanup(mainComponents);
registry.category("services").add("popover", popoverService);
registry
.category("services")
.add("popover", popoverService)
.add("localization", makeFakeLocalizationService())
.add("hotkey", hotkeyService);
fixture = getFixture();
env = await makeTestEnv();
@ -46,8 +51,6 @@ QUnit.module("Popover service", {
});
QUnit.test("simple use", async (assert) => {
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@ -67,8 +70,6 @@ QUnit.test("simple use", async (assert) => {
});
QUnit.test("close on click away", async (assert) => {
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@ -84,9 +85,23 @@ QUnit.test("close on click away", async (assert) => {
assert.containsNone(fixture, ".o_popover #comp");
});
QUnit.test("do not close on click away", async (assert) => {
assert.containsOnce(fixture, ".o_popover_container");
QUnit.test("close on 'Escape' keydown", async (assert) => {
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
popovers.add(popoverTarget, Comp, {});
await nextTick();
assert.containsOnce(fixture, ".o_popover");
assert.containsOnce(fixture, ".o_popover #comp");
await triggerEvent(fixture, null, "keydown", { key: "Escape" });
assert.containsNone(fixture, ".o_popover");
assert.containsNone(fixture, ".o_popover #comp");
});
QUnit.test("do not close on click away", async (assert) => {
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@ -109,10 +124,6 @@ QUnit.test("do not close on click away", async (assert) => {
});
QUnit.test("close callback", async (assert) => {
assert.expect(3);
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@ -129,8 +140,6 @@ QUnit.test("close callback", async (assert) => {
});
QUnit.test("sub component triggers close", async (assert) => {
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp" t-on-click="() => this.props.close()">in popover</div>`;
@ -147,8 +156,6 @@ QUnit.test("sub component triggers close", async (assert) => {
});
QUnit.test("close popover if target is removed", async (assert) => {
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@ -166,14 +173,10 @@ QUnit.test("close popover if target is removed", async (assert) => {
});
QUnit.test("close and do not crash if target parent does not exist", async (assert) => {
assert.expect(3);
// This target does not have any parent, it simulates the case where the element disappeared
// from the DOM before the setup of the component
const dissapearedTarget = document.createElement("div");
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;
@ -188,8 +191,6 @@ QUnit.test("close and do not crash if target parent does not exist", async (asse
});
QUnit.test("keep popover if target sibling is removed", async (assert) => {
assert.containsOnce(fixture, ".o_popover_container");
class Comp extends Component {}
Comp.template = xml`<div id="comp">in popover</div>`;

View file

@ -4,7 +4,11 @@ import { Popover } from "@web/core/popover/popover";
import { usePosition } from "@web/core/position_hook";
import { registerCleanup } from "../../helpers/cleanup";
import { getFixture, makeDeferred, mount, nextTick, triggerEvent } from "../../helpers/utils";
import { makeTestEnv } from "../../helpers/mock_env";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
let env;
let fixture;
let popoverTarget;
@ -19,12 +23,16 @@ QUnit.module("Popover", {
registerCleanup(() => {
popoverTarget.remove();
});
registry.category("services").add("ui", uiService);
env = await makeTestEnv();
},
});
QUnit.test("popover can have custom class", async (assert) => {
await mount(Popover, fixture, {
props: { target: popoverTarget, popoverClass: "custom-popover" },
env,
props: { target: popoverTarget, class: "custom-popover" },
});
assert.containsOnce(fixture, ".o_popover.custom-popover");
@ -32,68 +40,130 @@ QUnit.test("popover can have custom class", async (assert) => {
QUnit.test("popover can have more than one custom class", async (assert) => {
await mount(Popover, fixture, {
props: { target: popoverTarget, popoverClass: "custom-popover popover-custom" },
env,
props: { target: popoverTarget, class: "custom-popover popover-custom" },
});
assert.containsOnce(fixture, ".o_popover.custom-popover.popover-custom");
});
QUnit.test("popover is rendered nearby target (default)", async (assert) => {
assert.expect(1);
const TestPopover = class extends Popover {
onPositioned(el, { direction }) {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "bottom");
assert.equal(variant, "middle");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget },
});
});
QUnit.test("popover is rendered nearby target (bottom)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction }) {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "bottom");
assert.equal(variant, "middle");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "bottom" },
});
});
QUnit.test("popover is rendered nearby target (top)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction }) {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "top");
assert.equal(variant, "middle");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "top" },
});
});
QUnit.test("popover is rendered nearby target (left)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction }) {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "left");
assert.equal(variant, "middle");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "left" },
});
});
QUnit.test("popover is rendered nearby target (right)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction }) {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "right");
assert.equal(variant, "middle");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "right" },
});
});
QUnit.test("popover is rendered nearby target (bottom-start)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "bottom");
assert.equal(variant, "start");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "bottom-start" },
});
});
QUnit.test("popover is rendered nearby target (bottom-middle)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "bottom");
assert.equal(variant, "middle");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "bottom-middle" },
});
});
QUnit.test("popover is rendered nearby target (bottom-end)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "bottom");
assert.equal(variant, "end");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "bottom-end" },
});
});
QUnit.test("popover is rendered nearby target (bottom-fit)", async (assert) => {
const TestPopover = class extends Popover {
onPositioned(el, { direction, variant }) {
assert.equal(direction, "bottom");
assert.equal(variant, "fit");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget, position: "bottom-fit" },
});
});
QUnit.test("reposition popover should properly change classNames", async (assert) => {
// Force some style, to make this test independent of screen size
const container = document.createElement("div");
@ -126,7 +196,7 @@ QUnit.test("reposition popover should properly change classNames", async (assert
const TestPopover = class extends Popover {
setup() {
// Don't call super.setup() in order to replace the use of usePosition hook...
usePosition(this.props.target, {
usePosition("ref", () => this.props.target, {
container,
onPositioned: this.onPositioned.bind(this),
position: this.props.position,
@ -134,14 +204,14 @@ QUnit.test("reposition popover should properly change classNames", async (assert
}
};
await mount(TestPopover, container, { props: { target: popoverTarget } });
await mount(TestPopover, container, { env, props: { target: popoverTarget } });
const popover = container.querySelector("[role=tooltip]");
const arrow = popover.firstElementChild;
// Should have classes for a "bottom-middle" placement
assert.strictEqual(
popover.className,
"o_popover popover mw-25 shadow-sm bs-popover-bottom o-popover-bottom o-popover--bm"
"o_popover popover mw-100 bs-popover-bottom o-popover-bottom o-popover--bm"
);
assert.strictEqual(arrow.className, "popover-arrow start-0 end-0 mx-auto");
@ -154,7 +224,7 @@ QUnit.test("reposition popover should properly change classNames", async (assert
// Should have classes for a "right-end" placement
assert.strictEqual(
popover.className,
"o_popover popover mw-25 shadow-sm bs-popover-end o-popover-right o-popover--re"
"o_popover popover mw-100 bs-popover-end o-popover-right o-popover--re"
);
assert.strictEqual(arrow.className, "popover-arrow top-auto");
});
@ -168,14 +238,17 @@ QUnit.test("within iframe", async (assert) => {
fixture.appendChild(iframe);
await def;
let popoverEl;
const TestPopover = class extends Popover {
onPositioned(el, { direction }) {
popoverEl = el;
assert.step(direction);
}
};
popoverTarget = iframe.contentDocument.getElementById("target");
await mount(TestPopover, fixture, {
env,
props: { target: popoverTarget },
});
assert.verifySteps(["bottom"]);
@ -186,4 +259,98 @@ QUnit.test("within iframe", async (assert) => {
iframe.contentDocument.documentElement.querySelectorAll(".o_popover").length,
0
);
// The popover should be rendered in the correct position
const { top: targetTop, left: targetLeft } = popoverTarget.getBoundingClientRect();
const { top: iframeTop, left: iframeLeft } = iframe.getBoundingClientRect();
let popoverBox = popoverEl.getBoundingClientRect();
let expectedTop = iframeTop + targetTop + popoverTarget.offsetHeight;
let expectedLeft =
iframeLeft + targetLeft + popoverTarget.offsetWidth / 2 - popoverBox.width / 2;
assert.strictEqual(popoverBox.top, expectedTop);
assert.strictEqual(popoverBox.left, expectedLeft);
// Scrolling inside the iframe should reposition the popover accordingly
const scrollOffset = 100;
const scrollable = popoverTarget.ownerDocument.documentElement;
scrollable.scrollTop = scrollOffset;
await nextTick();
assert.verifySteps(["bottom"]);
popoverBox = popoverEl.getBoundingClientRect();
expectedTop = iframeTop + targetTop + popoverTarget.offsetHeight - scrollOffset;
expectedLeft = iframeLeft + targetLeft + popoverTarget.offsetWidth / 2 - popoverBox.width / 2;
assert.strictEqual(popoverBox.top, expectedTop);
assert.strictEqual(popoverBox.left, expectedLeft);
});
QUnit.test("within iframe -- wrong element class", async (assert) => {
/**
* This use case exists in real life, when adding some blocks with the OdooEditor
* in an iframe. The HTML spec discourages it though.
* https://developer.mozilla.org/en-US/docs/Web/API/Document/importNode
*/
const iframe = document.createElement("iframe");
iframe.style.height = "200px";
iframe.srcdoc = `<div id="target" style="height:400px;">Within iframe</div>`;
const def = makeDeferred();
iframe.onload = def.resolve;
fixture.appendChild(iframe);
await def;
const wrongElement = document.createElement("div");
wrongElement.classList.add("wrong-element");
iframe.contentDocument.body.appendChild(wrongElement);
class TestPopover extends Popover {
static props = {
...Popover.props,
target: {
validate: (...args) => {
const val = Popover.props.target.validate(...args);
assert.step(`validate target props: "${val}"`);
return val;
},
},
};
}
await mount(TestPopover, fixture, {
env,
props: { target: wrongElement },
test: true,
});
assert.containsOnce(fixture, ".o_popover");
assert.verifySteps(['validate target props: "true"']);
});
QUnit.test("popover fixed position", async (assert) => {
const container = document.createElement("div");
container.id = "container";
container.style.backgroundColor = "pink";
container.style.height = "450px";
container.style.width = "450px";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.justifyContent = "center";
popoverTarget.style.height = "50px";
container.appendChild(popoverTarget);
fixture.appendChild(container);
const TestPopover = class extends Popover {
onPositioned() {
assert.step("onPositioned");
}
};
await mount(TestPopover, fixture, {
env,
props: { target: container, position: "bottom-fit", fixedPosition: true },
});
assert.verifySteps(["onPositioned"]);
// force the DOM update
container.style.height = "125px";
container.style.alignItems = "flex-end";
triggerEvent(document, null, "scroll");
await nextTick();
assert.verifySteps([]);
});

View file

@ -6,6 +6,7 @@ import { registerCleanup } from "../helpers/cleanup";
import {
destroy,
getFixture,
makeDeferred,
mockAnimationFrame,
mount,
nextTick,
@ -13,25 +14,44 @@ import {
triggerEvent,
} from "../helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { Component, useRef, xml } from "@odoo/owl";
const FLEXBOX_STYLE = {
display: "flex",
alignItems: "center",
justifyContent: "center",
};
const CONTAINER_STYLE = {
...FLEXBOX_STYLE,
backgroundColor: "salmon",
height: "450px",
width: "450px",
margin: "25px",
};
const TARGET_STYLE = {
backgroundColor: "tomato",
height: "50px",
width: "50px",
};
let container;
/**
* @param {import("@web/core/position_hook").Options} popperOptions
* @returns {Component}
*/
function getTestComponent(popperOptions = {}) {
const reference = document.createElement("div");
reference.id = "reference";
reference.style.backgroundColor = "yellow";
reference.style.height = "50px";
reference.style.width = "50px";
container.appendChild(reference);
function getTestComponent(popperOptions = {}, target = document.createElement("div")) {
popperOptions.container = popperOptions.container || container;
target.id = "target";
Object.assign(target.style, TARGET_STYLE);
if (!target.isConnected) {
// If the target is not in any DOM, we append it to the container by default
popperOptions.container.appendChild(target);
}
class TestComp extends Component {
setup() {
usePosition(reference, { container, ...popperOptions });
usePosition("popper", () => target, popperOptions);
}
}
TestComp.template = xml`<div id="popper" t-ref="popper" />`;
@ -43,12 +63,7 @@ QUnit.module("usePosition Hook", {
// Force container style, to make these tests independent of screen size
container = document.createElement("div");
container.id = "container";
container.style.backgroundColor = "pink";
container.style.height = "450px";
container.style.width = "450px";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.justifyContent = "center";
Object.assign(container.style, CONTAINER_STYLE);
getFixture().prepend(container);
registerCleanup(() => {
getFixture().removeChild(container);
@ -57,7 +72,7 @@ QUnit.module("usePosition Hook", {
const sheet = document.createElement("style");
sheet.textContent = `
#popper {
background-color: cyan;
background-color: plum;
height: 100px;
width: 100px;
}
@ -105,35 +120,35 @@ QUnit.test("can add margin", async (assert) => {
const TestComp = getTestComponent(popperOptions);
const popper = await mount(TestComp, container);
const popBox = document.getElementById("popper").getBoundingClientRect();
const refBox = document.getElementById("reference").getBoundingClientRect();
const targetBox = document.getElementById("target").getBoundingClientRect();
destroy(popper);
container.removeChild(document.getElementById("reference"));
return [popBox, refBox];
container.removeChild(document.getElementById("target"));
return [popBox, targetBox];
}
// With/without additional margin (default direction is bottom)
let [popBox, refBox] = await _mountTestComponentAndDestroy();
assert.strictEqual(popBox.top, refBox.bottom + SHEET_MARGINS.top);
[popBox, refBox] = await _mountTestComponentAndDestroy({ margin: 10 });
assert.strictEqual(popBox.top, refBox.bottom + SHEET_MARGINS.top + 10);
let [popBox, targetBox] = await _mountTestComponentAndDestroy();
assert.strictEqual(popBox.top, targetBox.bottom + SHEET_MARGINS.top);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ margin: 10 });
assert.strictEqual(popBox.top, targetBox.bottom + SHEET_MARGINS.top + 10);
// With/without additional margin, direction is top
[popBox, refBox] = await _mountTestComponentAndDestroy({ position: "top" });
assert.strictEqual(popBox.top, refBox.top - popBox.height - SHEET_MARGINS.bottom);
[popBox, refBox] = await _mountTestComponentAndDestroy({ position: "top", margin: 10 });
assert.strictEqual(popBox.top, refBox.top - popBox.height - SHEET_MARGINS.bottom - 10);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ position: "top" });
assert.strictEqual(popBox.top, targetBox.top - popBox.height - SHEET_MARGINS.bottom);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ position: "top", margin: 10 });
assert.strictEqual(popBox.top, targetBox.top - popBox.height - SHEET_MARGINS.bottom - 10);
// With/without additional margin, direction is left
[popBox, refBox] = await _mountTestComponentAndDestroy({ position: "left" });
assert.strictEqual(popBox.left, refBox.left - popBox.width - SHEET_MARGINS.right);
[popBox, refBox] = await _mountTestComponentAndDestroy({ position: "left", margin: 10 });
assert.strictEqual(popBox.left, refBox.left - popBox.width - SHEET_MARGINS.right - 10);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ position: "left" });
assert.strictEqual(popBox.left, targetBox.left - popBox.width - SHEET_MARGINS.right);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ position: "left", margin: 10 });
assert.strictEqual(popBox.left, targetBox.left - popBox.width - SHEET_MARGINS.right - 10);
// With/without additional margin, direction is right
[popBox, refBox] = await _mountTestComponentAndDestroy({ position: "right" });
assert.strictEqual(popBox.left, refBox.right + SHEET_MARGINS.left);
[popBox, refBox] = await _mountTestComponentAndDestroy({ position: "right", margin: 10 });
assert.strictEqual(popBox.left, refBox.right + SHEET_MARGINS.left + 10);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ position: "right" });
assert.strictEqual(popBox.left, targetBox.right + SHEET_MARGINS.left);
[popBox, targetBox] = await _mountTestComponentAndDestroy({ position: "right", margin: 10 });
assert.strictEqual(popBox.left, targetBox.right + SHEET_MARGINS.left + 10);
});
QUnit.test("is restricted to its container, even with margins", async (assert) => {
@ -154,14 +169,14 @@ QUnit.test("is restricted to its container, even with margins", async (assert) =
});
const popper = await mount(TestComp, container);
destroy(popper);
container.removeChild(document.getElementById("reference"));
container.removeChild(document.getElementById("target"));
}
const minSize = 150; // => popper is 100px, ref is 50px
const minSize = 150; // => popper is 100px, target is 50px
const margin = 10; // will serve as additional margin
// === DIRECTION: BOTTOM ===
// Container style changes: push ref to top
// Container style changes: push target to top
Object.assign(container.style, { alignItems: "flex-start" });
// --> Without additional margin
@ -185,7 +200,7 @@ QUnit.test("is restricted to its container, even with margins", async (assert) =
assert.verifySteps(["right-start"]);
// === DIRECTION: TOP ===
// Container style changes: push ref to bottom
// Container style changes: push target to bottom
Object.assign(container.style, { alignItems: "flex-end" });
// --> Without additional margin
@ -213,7 +228,7 @@ QUnit.test("is restricted to its container, even with margins", async (assert) =
// === DIRECTION: LEFT ===
// Container style changes: reset previous changes
Object.assign(container.style, { alignItems: "center", height: "450px" });
// Container style changes: push ref to right
// Container style changes: push target to right
Object.assign(container.style, { justifyContent: "flex-end" });
// --> Without additional margin
@ -239,7 +254,7 @@ QUnit.test("is restricted to its container, even with margins", async (assert) =
assert.verifySteps(["bottom-end"]);
// === DIRECTION: RIGHT ===
// Container style changes: push ref to left
// Container style changes: push target to left
Object.assign(container.style, { justifyContent: "flex-start" });
// --> Without additional margin
@ -279,25 +294,8 @@ QUnit.test("popper is an inner element", async (assert) => {
await mount(TestComp, container);
});
QUnit.test("can change the popper reference name", async (assert) => {
assert.expect(2);
const TestComp = getTestComponent({
popper: "myRef",
onPositioned: (el) => {
assert.notOk(document.getElementById("not-popper") === el);
assert.ok(document.getElementById("popper") === el);
},
});
TestComp.template = xml`
<div id="not-popper">
<div id="popper" t-ref="myRef"/>
</div>
`;
await mount(TestComp, container);
});
QUnit.test("has no effect when component is destroyed", async (assert) => {
const execRegisteredCallbacks = mockAnimationFrame();
mockAnimationFrame();
const TestComp = getTestComponent({
onPositioned: () => {
assert.step("onPositioned called");
@ -306,16 +304,12 @@ QUnit.test("has no effect when component is destroyed", async (assert) => {
const comp = await mount(TestComp, container);
assert.verifySteps(["onPositioned called"], "onPositioned called when component mounted");
triggerEvent(document, null, "scroll");
await nextTick();
assert.verifySteps([]);
execRegisteredCallbacks();
await triggerEvent(document, null, "scroll");
assert.verifySteps(["onPositioned called"], "onPositioned called when document scrolled");
triggerEvent(document, null, "scroll");
await nextTick();
destroy(comp);
execRegisteredCallbacks();
await nextTick();
assert.verifySteps(
[],
"onPositioned not called even if scroll happened right before the component destroys"
@ -334,6 +328,20 @@ QUnit.test("reposition popper when a load event occurs", async (assert) => {
assert.verifySteps(["onPositioned called"], "onPositioned called when load event is triggered");
});
QUnit.test("reposition popper when a scroll event occurs", async (assert) => {
const TestComp = getTestComponent({
onPositioned: () => {
assert.step("onPositioned called");
},
});
await mount(TestComp, container);
assert.verifySteps(["onPositioned called"]);
await document.querySelector("#popper").dispatchEvent(new Event("scroll"));
assert.verifySteps([], "onPositioned not called when scroll event is triggered inside popper");
await document.querySelector("#popper").parentElement.dispatchEvent(new Event("scroll"));
assert.verifySteps(["onPositioned called"]);
});
QUnit.test("is positioned relative to its containing block", async (assert) => {
const fixtureBox = getFixture().getBoundingClientRect();
// offset the container
@ -349,7 +357,7 @@ QUnit.test("is positioned relative to its containing block", async (assert) => {
const popBox1 = document.getElementById("popper").getBoundingClientRect();
destroy(popper);
document.getElementById("reference").remove();
document.getElementById("target").remove();
// make container the containing block instead of the viewport
container.style.contain = "layout";
@ -371,36 +379,283 @@ QUnit.test("is positioned relative to its containing block", async (assert) => {
assert.equal(popBox1.left, popBox2.left);
});
QUnit.test("iframe: popper is outside, target inside", async (assert) => {
// Prepare target inside iframe
const IFRAME_STYLE = {
margin: "25px",
height: "200px",
width: "400px",
};
const iframe = document.createElement("iframe");
Object.assign(iframe.style, IFRAME_STYLE);
iframe.srcdoc = `<div id="target" />`;
const def = makeDeferred();
iframe.onload = def.resolve;
container.appendChild(iframe);
await def;
const iframeBody = iframe.contentDocument.body;
Object.assign(iframeBody.style, {
...FLEXBOX_STYLE,
backgroundColor: "papayawhip",
height: "300px",
width: "400px",
overflowX: "hidden",
});
// Prepare popper outside iframe
const popperTarget = iframe.contentDocument.getElementById("target");
let onPositionedArgs;
const Popper = getTestComponent(
{
onPositioned: (el, solution) => {
onPositionedArgs = { el, solution };
assert.step(`${solution.direction}-${solution.variant}`);
},
},
popperTarget
);
await mount(Popper, container);
assert.verifySteps(["bottom-middle"]);
// Check everything is rendered where it should be
assert.containsOnce(container, "#popper");
assert.containsNone(container, "#target");
assert.strictEqual(iframeBody.querySelectorAll("#target").length, 1);
assert.strictEqual(iframeBody.querySelectorAll("#popper").length, 0);
// Check the expected position
const { top: iframeTop, left: iframeLeft } = iframe.getBoundingClientRect();
let targetBox = popperTarget.getBoundingClientRect();
let popperBox = onPositionedArgs.el.getBoundingClientRect();
let expectedTop = iframeTop + targetBox.top + popperTarget.offsetHeight;
let expectedLeft =
iframeLeft + targetBox.left + popperTarget.offsetWidth / 2 - popperBox.width / 2;
assert.strictEqual(popperBox.top, expectedTop);
assert.strictEqual(popperBox.top, onPositionedArgs.solution.top);
assert.strictEqual(popperBox.left, expectedLeft);
assert.strictEqual(popperBox.left, onPositionedArgs.solution.left);
// Scrolling inside the iframe should reposition the popover accordingly
const previousPositionSolution = onPositionedArgs.solution;
const scrollOffset = 100;
const scrollable = iframe.contentDocument.documentElement;
scrollable.scrollTop = scrollOffset;
await nextTick();
assert.verifySteps(["bottom-middle"]);
assert.strictEqual(previousPositionSolution.top, onPositionedArgs.solution.top + scrollOffset);
// Check the expected position
targetBox = popperTarget.getBoundingClientRect();
popperBox = onPositionedArgs.el.getBoundingClientRect();
expectedTop = iframeTop + targetBox.top + popperTarget.offsetHeight;
expectedLeft = iframeLeft + targetBox.left + popperTarget.offsetWidth / 2 - popperBox.width / 2;
assert.strictEqual(popperBox.top, expectedTop);
assert.strictEqual(popperBox.top, onPositionedArgs.solution.top);
assert.strictEqual(popperBox.left, expectedLeft);
assert.strictEqual(popperBox.left, onPositionedArgs.solution.left);
});
QUnit.test("iframe: both popper and target inside", async (assert) => {
// Prepare target inside iframe
const IFRAME_STYLE = {
height: "300px",
width: "400px",
};
const iframe = document.createElement("iframe");
Object.assign(iframe.style, IFRAME_STYLE);
iframe.srcdoc = `<div id="inner-container" />`;
let def = makeDeferred();
iframe.onload = def.resolve;
container.appendChild(iframe);
await def; // wait for the iframe to be loaded
const iframeBody = iframe.contentDocument.body;
Object.assign(iframeBody.style, {
...FLEXBOX_STYLE,
backgroundColor: "papayawhip",
margin: "25px",
overflowX: "hidden",
});
def = makeDeferred();
const iframeSheet = iframe.contentDocument.createElement("style");
iframeSheet.onload = def.resolve;
iframeSheet.textContent = `
#popper {
background-color: plum;
height: 100px;
width: 100px;
}
`;
iframe.contentDocument.head.appendChild(iframeSheet);
await def; // wait for the iframe's stylesheet to be loaded
const innerContainer = iframe.contentDocument.getElementById("inner-container");
Object.assign(innerContainer.style, {
...CONTAINER_STYLE,
backgroundColor: "khaki",
});
// Prepare popper inside iframe
let onPositionedArgs;
const Popper = getTestComponent({
container: innerContainer,
onPositioned: (el, solution) => {
onPositionedArgs = { el, solution };
assert.step(`${solution.direction}-${solution.variant}`);
},
});
await mount(Popper, innerContainer);
assert.verifySteps(["bottom-middle"]);
// Check everything is rendered where it should be
assert.strictEqual(innerContainer.ownerDocument, iframe.contentDocument);
assert.strictEqual(innerContainer.querySelectorAll("#target").length, 1);
assert.strictEqual(innerContainer.querySelectorAll("#popper").length, 1);
assert.strictEqual(iframeBody.querySelectorAll("#target").length, 1);
assert.strictEqual(iframeBody.querySelectorAll("#popper").length, 1);
// Check the expected position
const popperTarget = innerContainer.querySelector("#target");
// const { top: iframeTop, left: iframeLeft } = iframe.getBoundingClientRect();
let targetBox = popperTarget.getBoundingClientRect();
let popperBox = onPositionedArgs.el.getBoundingClientRect();
let expectedTop = targetBox.top + popperTarget.offsetHeight;
let expectedLeft = targetBox.left + popperTarget.offsetWidth / 2 - popperBox.width / 2;
assert.strictEqual(popperBox.top, expectedTop);
assert.strictEqual(popperBox.top, onPositionedArgs.solution.top);
assert.strictEqual(popperBox.left, expectedLeft);
assert.strictEqual(popperBox.left, onPositionedArgs.solution.left);
// Scrolling inside the iframe should reposition the popover accordingly
const previousPositionSolution = onPositionedArgs.solution;
const scrollOffset = 100;
const scrollable = iframe.contentDocument.documentElement;
scrollable.scrollTop = scrollOffset;
await nextTick();
assert.verifySteps(["bottom-middle"]);
assert.strictEqual(previousPositionSolution.top, onPositionedArgs.solution.top + scrollOffset);
// Check the expected position
targetBox = popperTarget.getBoundingClientRect();
popperBox = onPositionedArgs.el.getBoundingClientRect();
expectedTop = targetBox.top + popperTarget.offsetHeight;
expectedLeft = targetBox.left + popperTarget.offsetWidth / 2 - popperBox.width / 2;
assert.strictEqual(popperBox.top, expectedTop);
assert.strictEqual(popperBox.top, onPositionedArgs.solution.top);
assert.strictEqual(popperBox.left, expectedLeft);
assert.strictEqual(popperBox.left, onPositionedArgs.solution.left);
});
QUnit.test("iframe: default container is the popper owner's document", async (assert) => {
assert.expect(1);
// Prepare an outer iframe, that will hold the popper element
let def = makeDeferred();
const outerIframe = document.createElement("iframe");
Object.assign(outerIframe.style, { height: "450px", width: "450px" });
outerIframe.onload = def.resolve;
getFixture().prepend(outerIframe);
registerCleanup(() => outerIframe.remove());
await def;
Object.assign(outerIframe.contentDocument.body.style, {
...CONTAINER_STYLE,
margin: "0",
justifyContent: "flex-start",
});
def = makeDeferred();
const iframeSheet = outerIframe.contentDocument.createElement("style");
iframeSheet.onload = def.resolve;
iframeSheet.textContent = `
#popper {
background-color: plum;
height: 100px;
width: 100px;
}
`;
outerIframe.contentDocument.head.appendChild(iframeSheet);
await def; // wait for the iframe's stylesheet to be loaded
// Prepare the inner iframe, that will hold the target element
def = makeDeferred();
const innerIframe = document.createElement("iframe");
innerIframe.srcdoc = `<div id="target" />`;
Object.assign(innerIframe.style, {
height: "300px",
width: "120px",
marginLeft: "10px",
});
innerIframe.onload = def.resolve;
outerIframe.contentDocument.body.appendChild(innerIframe);
await def;
Object.assign(innerIframe.contentDocument.body.style, {
...FLEXBOX_STYLE,
height: "300px",
width: "120px",
margin: "0",
});
// Prepare the target element
const target = innerIframe.contentDocument.getElementById("target");
Object.assign(target.style, TARGET_STYLE);
// Mount the popper component and check its position
class Popper extends Component {
static template = xml`<div id="popper" t-ref="popper" />`;
setup() {
usePosition("popper", () => target, {
position: "top-start",
onPositioned: (_, { direction, variant }) => {
assert.strictEqual(`${direction}-${variant}`, "top-start");
// the style setup in this test leaves enough space in the inner iframe
// for the popper to be positioned at top-middle, but this is exactly
// what we want to avoid: the popper's base container should not be the
// inner iframe, but the outer iframe, so the popper should be positioned
// at top-start.
},
});
}
}
await mount(Popper, outerIframe.contentDocument.body);
});
QUnit.test("popper as child of another", async (assert) => {
class Child extends Component {
static template = /* xml */ xml`
<div id="child">
<div class="target" t-ref="ref" />
<div class="popper" t-ref="popper" />
</div>
`;
setup() {
const ref = useRef("ref");
usePosition(() => ref.el, { popper: "popper", container, position: "left" });
usePosition("popper", () => ref.el, { position: "left" });
}
}
Child.template = /* xml */ xml`
<div id="child">
<div class="ref" t-ref="ref" />
<div class="popper" t-ref="popper" />
</div>
`;
const reference = container.querySelector("#reference");
const target = document.createElement("div");
target.id = "target";
Object.assign(target.style, TARGET_STYLE);
container.appendChild(target);
class Parent extends Component {
static components = { Child };
static template = /* xml */ xml`<div id="popper" t-ref="popper"><Child/></div>`;
setup() {
usePosition(reference, { container });
usePosition("popper", () => target);
}
}
Parent.components = { Child };
Parent.template = /* xml */ xml`
<div id="popper">
<Child/>
</div>
`;
const sheet = document.createElement("style");
sheet.textContent = `
#child .ref {
background-color: salmon;
#child .target {
background-color: peachpuff;
height: 100px;
width: 10px;
}
@ -416,7 +671,6 @@ QUnit.test("popper as child of another", async (assert) => {
await mount(Parent, container);
const parentPopBox1 = container.querySelector("#popper").getBoundingClientRect();
const childPopBox1 = container.querySelector("#child .popper").getBoundingClientRect();
const spacer = document.createElement("div");
spacer.id = "foo";
spacer.style.height = "1px";
@ -433,6 +687,35 @@ QUnit.test("popper as child of another", async (assert) => {
assert.strictEqual(childPopBox2.left, childPopBox1.left + spacer.offsetWidth * 0.5);
});
QUnit.test("batch update call", async (assert) => {
const target = document.createElement("div");
target.id = "target";
Object.assign(target.style, TARGET_STYLE);
container.appendChild(target);
let position = null;
class TestComponent extends Component {
static template = xml`<div class="popper" t-ref="popper">Popper</div>`;
setup() {
position = usePosition("popper", () => target, {
onPositioned: () => {
assert.step("positioned");
},
});
}
}
await mount(TestComponent, container);
assert.verifySteps(["positioned"]);
position.unlock();
position.unlock();
position.unlock();
await nextTick();
assert.verifySteps(["positioned"]);
});
function getPositionTest(position, positionToCheck) {
return async (assert) => {
assert.expect(2);
@ -498,8 +781,8 @@ const CONTAINER_STYLE_MAP = {
left: { justifyContent: "flex-start" },
right: { justifyContent: "flex-end" },
slimfit: { height: "100px", width: "100px" }, // height and width of popper
h125: { height: "125px" }, // height of popper + 1/2 reference
w125: { width: "125px" }, // width of popper + 1/2 reference
h125: { height: "125px" }, // height of popper + 1/2 target
w125: { width: "125px" }, // width of popper + 1/2 target
};
function getRepositionTest(from, to, containerStyleChanges) {
@ -1114,3 +1397,32 @@ QUnit.test(
"reposition from right-end to top-end",
getRepositionTest("right-end", "top-end", "w125 right")
);
function getFittingTest(position, styleAttribute) {
return async (assert) => {
const TestComp = getTestComponent({ position });
await mount(TestComp, container);
assert.strictEqual(container.querySelector("#popper").style[styleAttribute], "50px");
};
}
QUnit.test(
"reposition from bottom-fit to top-fit",
getRepositionTest("bottom-fit", "top-fit", "bottom")
);
QUnit.test(
"reposition from top-fit to bottom-fit",
getRepositionTest("top-fit", "bottom-fit", "top")
);
QUnit.test(
"reposition from right-fit to left-fit",
getRepositionTest("right-fit", "left-fit", "right")
);
QUnit.test(
"reposition from left-fit to right-fit",
getRepositionTest("left-fit", "right-fit", "left")
);
QUnit.test("bottom-fit has the same width as the target", getFittingTest("bottom-fit", "width"));
QUnit.test("top-fit has the same width as the target", getFittingTest("top-fit", "width"));
QUnit.test("left-fit has the same height as the target", getFittingTest("left-fit", "height"));
QUnit.test("right-fit has the same height as the target", getFittingTest("right-fit", "height"));

View file

@ -333,7 +333,7 @@ QUnit.module("py", {}, () => {
QUnit.test("throws when period negative", (assert) => {
const matcher = (errorMessage) => {
return function match(err) {
return err.message === errorMessage;
return err.message.includes(errorMessage);
};
};

View file

@ -1,6 +1,6 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { evaluateExpr, evaluateBooleanExpr } from "@web/core/py_js/py";
QUnit.module("py", {}, () => {
QUnit.module("interpreter", () => {
@ -29,6 +29,8 @@ QUnit.module("py", {}, () => {
assert.strictEqual(evaluateExpr('""'), "");
assert.strictEqual(evaluateExpr('"foo"'), "foo");
assert.strictEqual(evaluateExpr("'foo'"), "foo");
assert.strictEqual(evaluateExpr("'FOO'.lower()"), "foo");
assert.strictEqual(evaluateExpr("'foo'.upper()"), "FOO");
});
QUnit.test("boolean", (assert) => {
@ -96,6 +98,7 @@ QUnit.module("py", {}, () => {
assert.strictEqual(evaluateExpr("not False"), true);
assert.strictEqual(evaluateExpr("not foo", { foo: false }), true);
assert.strictEqual(evaluateExpr("not None"), true);
assert.strictEqual(evaluateExpr("not []"), true);
assert.strictEqual(evaluateExpr("True == False or True == True"), true);
assert.strictEqual(evaluateExpr("False == True and False"), false);
});
@ -123,6 +126,7 @@ QUnit.module("py", {}, () => {
assert.strictEqual(evaluateExpr('None or "bar"'), "bar");
assert.strictEqual(evaluateExpr("False or None"), null);
assert.strictEqual(evaluateExpr("0 or 1"), 1);
assert.strictEqual(evaluateExpr("[] or False"), false);
});
QUnit.module("values from context");
@ -286,8 +290,19 @@ QUnit.module("py", {}, () => {
QUnit.module("conversions");
QUnit.test("to bool", (assert) => {
assert.strictEqual(evaluateExpr("bool()"), false);
assert.strictEqual(evaluateExpr("bool(0)"), false);
assert.strictEqual(evaluateExpr("bool(1)"), true);
assert.strictEqual(evaluateExpr("bool(False)"), false);
assert.strictEqual(evaluateExpr("bool(True)"), true);
assert.strictEqual(evaluateExpr("bool({})"), false);
assert.strictEqual(evaluateExpr("bool({ 'a': 1 })"), true);
assert.strictEqual(evaluateExpr("bool([])"), false);
assert.strictEqual(evaluateExpr("bool([1])"), true);
assert.strictEqual(evaluateExpr("bool('')"), false);
assert.strictEqual(evaluateExpr("bool('foo')"), true);
assert.strictEqual(evaluateExpr("bool(set())"), false);
assert.strictEqual(evaluateExpr("bool(set([1]))"), true);
assert.strictEqual(
evaluateExpr("bool(date_deadline)", { date_deadline: "2008" }),
true
@ -321,6 +336,22 @@ QUnit.module("py", {}, () => {
assert.strictEqual(evaluateExpr("{'a': 1}.get('b', 54)"), 54);
});
QUnit.test("can get values from values 'context'", (assert) => {
assert.strictEqual(evaluateExpr("context.get('a')", { context: { a: 123 } }), 123);
const values = { context: { a: { b: { c: 321 } } } };
assert.strictEqual(evaluateExpr("context.get('a').b.c", values), 321);
assert.strictEqual(evaluateExpr("context.get('a', {'e': 5}).b.c", values), 321);
assert.strictEqual(evaluateExpr("context.get('d', 3)", values), 3);
assert.strictEqual(evaluateExpr("context.get('d', {'e': 5})['e']", values), 5);
});
QUnit.test("can check if a key is in the 'context'", (assert) => {
assert.strictEqual(evaluateExpr("'a' in context", { context: { a: 123 } }), true);
assert.strictEqual(evaluateExpr("'a' in context", { context: { b: 123 } }), false);
assert.strictEqual(evaluateExpr("'a' not in context", { context: { a: 123 } }), false);
assert.strictEqual(evaluateExpr("'a' not in context", { context: { b: 123 } }), true);
});
QUnit.module("objects");
QUnit.test("can read values from object", (assert) => {
@ -350,5 +381,184 @@ QUnit.module("py", {}, () => {
QUnit.test("tuple in list", (assert) => {
assert.deepEqual(evaluateExpr("[(1 + 2,'foo', True)]"), [[3, "foo", true]]);
});
QUnit.module("evaluate to boolean");
QUnit.test("simple expression", (assert) => {
assert.strictEqual(evaluateBooleanExpr("12"), true);
assert.strictEqual(evaluateBooleanExpr("0"), false);
assert.strictEqual(evaluateBooleanExpr("0 + 3 - 1"), true);
assert.strictEqual(evaluateBooleanExpr("0 + 3 - 1 - 2"), false);
assert.strictEqual(evaluateBooleanExpr('"foo"'), true);
assert.strictEqual(evaluateBooleanExpr("[1]"), true);
assert.strictEqual(evaluateBooleanExpr("[]"), false);
});
QUnit.test("use contextual values", (assert) => {
assert.strictEqual(evaluateBooleanExpr("a", { a: 12 }), true);
assert.strictEqual(evaluateBooleanExpr("a", { a: 0 }), false);
assert.strictEqual(evaluateBooleanExpr("0 + 3 - a", { a: 1 }), true);
assert.strictEqual(evaluateBooleanExpr("0 + 3 - a - 2", { a: 1 }), false);
assert.strictEqual(evaluateBooleanExpr("0 + 3 - a - b", { a: 1, b: 2 }), false);
assert.strictEqual(evaluateBooleanExpr("a", { a: "foo" }), true);
assert.strictEqual(evaluateBooleanExpr("a", { a: [1] }), true);
assert.strictEqual(evaluateBooleanExpr("a", { a: [] }), false);
});
QUnit.test("throw if has missing value", (assert) => {
assert.throws(() => evaluateBooleanExpr("a", { b: 0 }));
assert.strictEqual(evaluateBooleanExpr("1 or a"), true); // do not throw (lazy value)
assert.throws(() => evaluateBooleanExpr("0 or a"));
assert.throws(() => evaluateBooleanExpr("a or b", { b: true }));
assert.throws(() => evaluateBooleanExpr("a and b", { b: true }));
assert.throws(() => evaluateBooleanExpr("a()"));
assert.throws(() => evaluateBooleanExpr("a[0]"));
assert.throws(() => evaluateBooleanExpr("a.b"));
assert.throws(() => evaluateBooleanExpr("0 + 3 - a", { b: 1 }));
assert.throws(() => evaluateBooleanExpr("0 + 3 - a - 2", { b: 1 }));
assert.throws(() => evaluateBooleanExpr("0 + 3 - a - b", { b: 2 }));
});
QUnit.module("sets");
QUnit.test("static set", (assert) => {
assert.deepEqual(evaluateExpr("set()"), new Set());
assert.deepEqual(evaluateExpr("set([])"), new Set([]));
assert.deepEqual(evaluateExpr("set([0])"), new Set([0]));
assert.deepEqual(evaluateExpr("set([1])"), new Set([1]));
assert.deepEqual(evaluateExpr("set([0, 0])"), new Set([0]));
assert.deepEqual(evaluateExpr("set([0, 1])"), new Set([0, 1]));
assert.deepEqual(evaluateExpr("set([1, 1])"), new Set([1]));
assert.deepEqual(evaluateExpr("set('')"), new Set());
assert.deepEqual(evaluateExpr("set('a')"), new Set(["a"]));
assert.deepEqual(evaluateExpr("set('ab')"), new Set(["a", "b"]));
assert.deepEqual(evaluateExpr("set({})"), new Set());
assert.deepEqual(evaluateExpr("set({ 'a': 1 })"), new Set(["a"]));
assert.deepEqual(evaluateExpr("set({ '': 1, 'a': 1 })"), new Set(["", "a"]));
assert.throws(() => evaluateExpr("set(0)"));
assert.throws(() => evaluateExpr("set(1)"));
assert.throws(() => evaluateExpr("set(None)"));
assert.throws(() => evaluateExpr("set(false)"));
assert.throws(() => evaluateExpr("set(true)"));
assert.throws(() => evaluateExpr("set(1, 2)"));
assert.throws(() => evaluateExpr("set(expr)", { expr: undefined }));
assert.throws(() => evaluateExpr("set(expr)", { expr: null }));
assert.throws(() => evaluateExpr("set([], [])")); // valid but not supported by py_js
assert.throws(() => evaluateExpr("set({ 'a' })")); // valid but not supported by py_js
});
QUnit.test("set intersection", (assert) => {
assert.deepEqual(evaluateExpr("set([1,2,3]).intersection()"), new Set([1, 2, 3]));
assert.deepEqual(
evaluateExpr("set([1,2,3]).intersection(set([2,3]))"),
new Set([2, 3])
);
assert.deepEqual(evaluateExpr("set([1,2,3]).intersection([2,3])"), new Set([2, 3]));
assert.deepEqual(
evaluateExpr("set([1,2,3]).intersection(r)", { r: [2, 3] }),
new Set([2, 3])
);
assert.deepEqual(
evaluateExpr("r.intersection([2,3])", { r: new Set([1, 2, 3, 2]) }),
new Set([2, 3])
);
assert.deepEqual(
evaluateExpr("set(foo_ids).intersection([2,3])", { foo_ids: [1, 2] }),
new Set([2])
);
assert.deepEqual(
evaluateExpr("set(foo_ids).intersection([2,3])", { foo_ids: [1] }),
new Set()
);
assert.deepEqual(
evaluateExpr("set([foo_id]).intersection([2,3])", { foo_id: 1 }),
new Set()
);
assert.deepEqual(
evaluateExpr("set([foo_id]).intersection([2,3])", { foo_id: 2 }),
new Set([2])
);
assert.throws(() => evaluateExpr("set([]).intersection([], [])")); // valid but not supported by py_js
assert.throws(() => evaluateExpr("set([]).intersection([], [], [])")); // valid but not supported by py_js
});
QUnit.test("set difference", (assert) => {
assert.deepEqual(evaluateExpr("set([1,2,3]).difference()"), new Set([1, 2, 3]));
assert.deepEqual(evaluateExpr("set([1,2,3]).difference(set([2,3]))"), new Set([1]));
assert.deepEqual(evaluateExpr("set([1,2,3]).difference([2,3])"), new Set([1]));
assert.deepEqual(
evaluateExpr("set([1,2,3]).difference(r)", { r: [2, 3] }),
new Set([1])
);
assert.deepEqual(
evaluateExpr("r.difference([2,3])", { r: new Set([1, 2, 3, 2, 4]) }),
new Set([1, 4])
);
assert.deepEqual(
evaluateExpr("set(foo_ids).difference([2,3])", { foo_ids: [1, 2] }),
new Set([1])
);
assert.deepEqual(
evaluateExpr("set(foo_ids).difference([2,3])", { foo_ids: [1] }),
new Set([1])
);
assert.deepEqual(
evaluateExpr("set([foo_id]).difference([2,3])", { foo_id: 1 }),
new Set([1])
);
assert.deepEqual(
evaluateExpr("set([foo_id]).difference([2,3])", { foo_id: 2 }),
new Set()
);
assert.throws(() => evaluateExpr("set([]).difference([], [])")); // valid but not supported by py_js
assert.throws(() => evaluateExpr("set([]).difference([], [], [])")); // valid but not supported by py_js
});
QUnit.test("set union", (assert) => {
assert.deepEqual(evaluateExpr("set([1,2,3]).union()"), new Set([1, 2, 3]));
assert.deepEqual(
evaluateExpr("set([1,2,3]).union(set([2,3,4]))"),
new Set([1, 2, 3, 4])
);
assert.deepEqual(evaluateExpr("set([1,2,3]).union([2,4])"), new Set([1, 2, 3, 4]));
assert.deepEqual(
evaluateExpr("set([1,2,3]).union(r)", { r: [2, 4] }),
new Set([1, 2, 3, 4])
);
assert.deepEqual(
evaluateExpr("r.union([2,3])", { r: new Set([1, 2, 2, 4]) }),
new Set([1, 2, 3, 4])
);
assert.deepEqual(
evaluateExpr("set(foo_ids).union([2,3])", { foo_ids: [1, 2] }),
new Set([1, 2, 3])
);
assert.deepEqual(
evaluateExpr("set(foo_ids).union([2,3])", { foo_ids: [1] }),
new Set([1, 2, 3])
);
assert.deepEqual(
evaluateExpr("set([foo_id]).union([2,3])", { foo_id: 1 }),
new Set([1, 2, 3])
);
assert.deepEqual(
evaluateExpr("set([foo_id]).union([2,3])", { foo_id: 2 }),
new Set([2, 3])
);
assert.throws(() => evaluateExpr("set([]).union([], [])")); // valid but not supported by py_js
assert.throws(() => evaluateExpr("set([]).union([], [], [])")); // valid but not supported by py_js
});
});
});

View file

@ -0,0 +1,194 @@
/** @odoo-module */
import { EventBus, reactive } from "@odoo/owl";
import { Reactive, effect, withComputedProperties } from "@web/core/utils/reactive";
QUnit.module("Reactive utils", () => {
QUnit.module("Reactive class", () => {
QUnit.test(
"callback registered without Reactive class constructor will not notify",
(assert) => {
// This test exists to showcase why we need the Reactive class
const bus = new EventBus();
class MyReactiveClass {
constructor() {
this.counter = 0;
bus.addEventListener("change", () => this.counter++);
}
}
const obj = reactive(new MyReactiveClass(), () => {
assert.step(`counter: ${obj.counter}`);
});
obj.counter; // initial subscription to counter
obj.counter++;
assert.verifySteps(["counter: 1"]);
bus.trigger("change");
assert.equal(obj.counter, 2);
assert.verifySteps([
// The mutation in the event handler was missed by the reactivity, this is because
// the `this` in the event handler is captured during construction and is not reactive
]);
}
);
QUnit.test("callback registered in Reactive class constructor will notify", (assert) => {
const bus = new EventBus();
class MyReactiveClass extends Reactive {
constructor() {
super();
this.counter = 0;
bus.addEventListener("change", () => this.counter++);
}
}
const obj = reactive(new MyReactiveClass(), () => {
assert.step(`counter: ${obj.counter}`);
});
obj.counter; // initial subscription to counter
obj.counter++;
assert.verifySteps(["counter: 1"]);
bus.trigger("change");
assert.equal(obj.counter, 2);
assert.verifySteps(["counter: 2"]);
});
});
QUnit.module("effect", () => {
QUnit.test("effect runs once immediately", (assert) => {
const state = reactive({ counter: 0 });
assert.verifySteps([]);
effect(
(state) => {
assert.step(`counter: ${state.counter}`);
},
[state]
);
assert.verifySteps(["counter: 0"]);
});
QUnit.test("effect runs when reactive deps change", (assert) => {
const state = reactive({ counter: 0 });
assert.verifySteps([]);
effect(
(state) => {
assert.step(`counter: ${state.counter}`);
},
[state]
);
assert.verifySteps(["counter: 0"], "effect runs immediately");
state.counter++;
assert.verifySteps(["counter: 1"], "first mutation runs the effect");
state.counter++;
assert.verifySteps(["counter: 2"], "subsequent mutations run the effect");
});
QUnit.test(
"Original reactive callback is not subscribed to keys observed by effect",
(assert) => {
let reactiveCallCount = 0;
const state = reactive(
{
counter: 0,
},
() => reactiveCallCount++
);
assert.verifySteps([]);
assert.equal(reactiveCallCount, 0);
effect(
(state) => {
assert.step(`counter: ${state.counter}`);
},
[state]
);
assert.verifySteps(["counter: 0"]);
assert.equal(reactiveCallCount, 0, "did not call the original reactive's callback");
state.counter = 1;
assert.verifySteps(["counter: 1"]);
assert.equal(reactiveCallCount, 0, "did not call the original reactive's callback");
state.counter; // subscribe the original reactive
state.counter = 2;
assert.verifySteps(["counter: 2"]);
assert.equal(
reactiveCallCount,
1,
"the original callback was called because it is subscribed independently"
);
}
);
QUnit.test("mutating keys not observed by the effect doesn't cause it to run", (assert) => {
const state = reactive({ counter: 0, unobserved: 0 });
effect(
(state) => {
assert.step(`counter: ${state.counter}`);
},
[state]
);
assert.verifySteps(["counter: 0"]);
state.counter = 1;
assert.verifySteps(["counter: 1"]);
state.unobserved = 1;
assert.verifySteps([]);
});
});
QUnit.module("withComputedProperties", () => {
QUnit.test("computed properties are set immediately", (assert) => {
const source = reactive({ counter: 1 });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.counter * 2;
},
});
assert.equal(derived.doubleCounter, 2);
});
QUnit.test("computed properties are recomputed when dependencies change", (assert) => {
const source = reactive({ counter: 1 });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.counter * 2;
},
});
assert.equal(derived.doubleCounter, 2);
source.counter++;
assert.equal(derived.doubleCounter, 4);
});
QUnit.test("can observe computed properties", (assert) => {
const source = reactive({ counter: 1 });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.counter * 2;
},
});
const observed = reactive(derived, () => {
assert.step(`doubleCounter: ${observed.doubleCounter}`);
});
observed.doubleCounter; // subscribe to doubleCounter
assert.verifySteps([]);
source.counter++;
assert.verifySteps(["doubleCounter: 4"]);
});
QUnit.test("computed properties can use nested objects", (assert) => {
const source = reactive({ subObj: { counter: 1 } });
const derived = withComputedProperties(reactive({}), [source], {
doubleCounter(source) {
return source.subObj.counter * 2;
},
});
const observed = reactive(derived, () => {
assert.step(`doubleCounter: ${observed.doubleCounter}`);
});
observed.doubleCounter; // subscribe to doubleCounter
assert.equal(observed.doubleCounter, 2);
assert.verifySteps([]);
source.subObj.counter++;
assert.equal(derived.doubleCounter, 4);
assert.verifySteps(
["doubleCounter: 4"],
"reactive gets notified even for computed properties dervied from nested objects"
);
});
});
});

View file

@ -0,0 +1,245 @@
/** @odoo-module **/
import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector";
import { makeTestEnv } from "../../helpers/mock_env";
import { getFixture, mount, click, triggerEvent, editInput } from "../../helpers/utils";
import { registry } from "@web/core/registry";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { Component, useState, xml } from "@odoo/owl";
import { nameService } from "@web/core/name_service";
import { dialogService } from "@web/core/dialog/dialog_service";
QUnit.module("Web Components", (hooks) => {
QUnit.module("MultiRecordSelector");
let target;
const serverData = {
models: {
partner: {
fields: {
display_name: { string: "Display name", type: "char" },
},
records: [
{ id: 1, display_name: "Alice" },
{ id: 2, display_name: "Bob" },
{ id: 3, display_name: "Charlie" },
],
},
},
};
async function makeMultiRecordSelector(props, { mockRPC } = {}) {
class Parent extends Component {
setup() {
this.state = useState({ resIds: props.resIds });
}
get recordProps() {
return {
...props,
resIds: this.state.resIds,
update: (resIds) => this._update(resIds),
};
}
_update(resIds) {
this.state.resIds = resIds;
}
}
Parent.components = { MultiRecordSelector };
Parent.template = xml`
<MultiRecordSelector t-props="recordProps" />`;
const env = await makeTestEnv({ serverData, mockRPC });
await mount(Parent, target, { env });
}
hooks.beforeEach(async () => {
target = getFixture();
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("dialog", dialogService);
registry.category("services").add("name", nameService);
});
QUnit.test("Can be renderer with no values", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [],
});
const input = target.querySelector(".o_multi_record_selector input");
assert.strictEqual(input.value, "", "The input should be empty");
assert.hasClass(input, "o_input");
});
QUnit.test("Can be renderer with a value", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1],
});
const input = target.querySelector(".o_multi_record_selector input");
assert.strictEqual(input.value, "");
assert.strictEqual(target.querySelectorAll(".o_tag").length, 1);
assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice");
});
QUnit.test("Can be renderer with multiple values", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
const input = target.querySelector(".o_multi_record_selector input");
assert.strictEqual(input.value, "");
assert.strictEqual(target.querySelectorAll(".o_tag").length, 2);
assert.deepEqual(
[...target.querySelectorAll(".o_tag")].map((el) => el.textContent),
["Alice", "Bob"]
);
});
QUnit.test("Can be updated from autocomplete", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [],
});
const input = target.querySelector(".o_multi_record_selector input");
assert.containsNone(target, ".o_tag");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await click(input);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
const secondItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[1];
await click(secondItem);
assert.containsOnce(target, ".o_tag");
assert.strictEqual(target.querySelector(".o_tag").textContent, "Bob");
});
QUnit.test("Display name is correctly fetched", async (assert) => {
await makeMultiRecordSelector(
{
resModel: "partner",
resIds: [1],
},
{
mockRPC: (route, args) => {
if (args.method === "web_search_read") {
assert.step("web_search_read");
assert.strictEqual(args.model, "partner");
assert.deepEqual(args.kwargs.domain, [["id", "in", [1]]]);
}
},
}
);
assert.strictEqual(target.querySelectorAll(".o_tag").length, 1);
assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice");
assert.verifySteps(["web_search_read"]);
});
QUnit.test("Can give domain and context props for the name search", async (assert) => {
await makeMultiRecordSelector(
{
resModel: "partner",
resIds: [1],
domain: [["display_name", "=", "Bob"]],
context: { blip: "blop " },
},
{
mockRPC: (route, args) => {
if (args.method === "name_search") {
assert.step("name_search");
assert.strictEqual(args.model, "partner");
assert.deepEqual(args.kwargs.args, [
"&",
["display_name", "=", "Bob"],
"!",
["id", "in", [1]],
]);
assert.strictEqual(args.kwargs.context.blip, "blop ");
}
},
}
);
const input = target.querySelector(".o_multi_record_selector input");
assert.verifySteps([]);
await click(input);
assert.verifySteps(["name_search"]);
});
QUnit.test("Support placeholder", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [],
placeholder: "Select a partner",
});
const input = target.querySelector(".o_multi_record_selector input");
assert.strictEqual(input.placeholder, "Select a partner");
await click(input);
const firstItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[0];
await click(firstItem);
assert.strictEqual(input.placeholder, "");
});
QUnit.test("Placeholder is not set if values are selected", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1],
placeholder: "Select a partner",
});
const input = target.querySelector(".o_multi_record_selector input");
assert.strictEqual(input.placeholder, "");
});
QUnit.test("Can delete a tag with Backspace", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
await triggerEvent(target, ".o-autocomplete input", "keydown", { key: "Backspace" });
assert.strictEqual(target.querySelectorAll(".o_tag").length, 1);
assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice");
});
QUnit.test("Can focus tags with arrow right and left", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
target.querySelector(".o-autocomplete input").focus();
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowleft" });
assert.strictEqual(document.activeElement.textContent, "Bob");
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowleft" });
assert.strictEqual(document.activeElement.textContent, "Alice");
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowleft" });
assert.hasClass(document.activeElement, "o-autocomplete--input");
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowright" });
assert.strictEqual(document.activeElement.textContent, "Alice");
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowright" });
assert.strictEqual(document.activeElement.textContent, "Bob");
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowright" });
assert.hasClass(document.activeElement, "o-autocomplete--input");
});
QUnit.test("Delete the focused element", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
target.querySelector(".o-autocomplete input").focus();
await triggerEvent(document.activeElement, "", "keydown", { key: "arrowright" });
assert.strictEqual(document.activeElement.textContent, "Alice");
await triggerEvent(document.activeElement, "", "keydown", { key: "Backspace" });
assert.strictEqual(target.querySelectorAll(".o_tag").length, 1);
assert.strictEqual(target.querySelector(".o_tag").textContent, "Bob");
});
QUnit.test("Backspace do nothing when the input is currently edited", async (assert) => {
await makeMultiRecordSelector({
resModel: "partner",
resIds: [1, 2],
});
target.querySelector(".o-autocomplete input").focus();
await editInput(target, ".o-autocomplete input", "a");
assert.strictEqual(document.activeElement.value, "a");
await triggerEvent(document.activeElement, "", "keydown", { key: "Backspace" });
assert.strictEqual(target.querySelectorAll(".o_tag").length, 2);
});
});

View file

@ -0,0 +1,222 @@
/** @odoo-module **/
import { RecordSelector } from "@web/core/record_selectors/record_selector";
import { makeTestEnv } from "../../helpers/mock_env";
import { getFixture, mount, click } from "../../helpers/utils";
import { registry } from "@web/core/registry";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { Component, useState, xml } from "@odoo/owl";
import { nameService } from "@web/core/name_service";
import { dialogService } from "@web/core/dialog/dialog_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { viewService } from "@web/views/view_service";
QUnit.module("Web Components", (hooks) => {
QUnit.module("RecordSelector");
let target;
let serverData;
async function makeRecordSelector(props, { mockRPC } = {}) {
class Parent extends Component {
setup() {
this.state = useState({ resId: props.resId });
}
get recordProps() {
return {
...props,
resId: this.state.resId,
update: (resId) => this._update(resId),
};
}
_update(resId) {
this.state.resId = resId;
}
}
Parent.components = { RecordSelector, MainComponentsContainer };
Parent.template = xml`
<MainComponentsContainer />
<RecordSelector t-props="recordProps" />`;
const env = await makeTestEnv({ serverData, mockRPC });
await mount(Parent, target, { env });
}
hooks.beforeEach(async () => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Display name", type: "char" },
},
records: [
{ id: 1, display_name: "Alice" },
{ id: 2, display_name: "Bob" },
{ id: 3, display_name: "Charlie" },
],
},
},
};
registry.category("services").add("hotkey", hotkeyService);
registry.category("services").add("dialog", dialogService);
registry.category("services").add("name", nameService);
});
QUnit.test("Can be renderer with no values", async (assert) => {
await makeRecordSelector({
resModel: "partner",
resId: false,
});
const input = target.querySelector(".o_record_selector input");
assert.strictEqual(input.value, "", "The input should be empty");
assert.hasClass(input, "o_input");
});
QUnit.test("Can be renderer with a value", async (assert) => {
await makeRecordSelector({
resModel: "partner",
resId: 1,
});
const input = target.querySelector(".o_record_selector input");
assert.strictEqual(input.value, "Alice");
});
QUnit.test("Can be updated from autocomplete", async (assert) => {
await makeRecordSelector({
resModel: "partner",
resId: 1,
});
const input = target.querySelector(".o_record_selector input");
assert.strictEqual(input.value, "Alice");
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
await click(input);
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
const secondItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[1];
await click(secondItem);
assert.strictEqual(input.value, "Bob");
});
QUnit.test("Display name is correctly fetched", async (assert) => {
await makeRecordSelector(
{
resModel: "partner",
resId: 1,
},
{
mockRPC: (route, args) => {
if (args.method === "web_search_read") {
assert.step("web_search_read");
assert.strictEqual(args.model, "partner");
assert.deepEqual(args.kwargs.domain, [["id", "in", [1]]]);
}
},
}
);
const input = target.querySelector(".o_record_selector input");
assert.strictEqual(input.value, "Alice");
assert.verifySteps(["web_search_read"]);
});
QUnit.test("Can give domain and context props for the name search", async (assert) => {
await makeRecordSelector(
{
resModel: "partner",
resId: 1,
domain: [["display_name", "=", "Bob"]],
context: { blip: "blop " },
},
{
mockRPC: (route, args) => {
if (args.method === "name_search") {
assert.step("name_search");
assert.strictEqual(args.model, "partner");
assert.deepEqual(args.kwargs.args, [
"&",
["display_name", "=", "Bob"],
"!",
["id", "in", []],
]);
assert.strictEqual(args.kwargs.context.blip, "blop ");
}
},
}
);
const input = target.querySelector(".o_record_selector input");
assert.strictEqual(input.value, "Alice");
assert.verifySteps([]);
await click(input);
assert.verifySteps(["name_search"]);
});
QUnit.test("Support placeholder", async (assert) => {
await makeRecordSelector({
resModel: "partner",
resId: false,
placeholder: "Select a partner",
});
const input = target.querySelector(".o_record_selector input");
assert.strictEqual(input.placeholder, "Select a partner");
});
QUnit.test("domain is passed to search more", async (assert) => {
serverData.models.partner.records = [...new Array(10)].map((el, i) => {
return {
id: i + 1,
display_name: `a_${i + 1}`,
};
});
serverData.views = {
"partner,false,list": `<tree><field name="display_name" /></tree>`,
"partner,false,search": "<search />",
};
const fakeService = {
start() {},
};
registry.category("services").add("view", viewService);
registry.category("services").add("action", {
start() {
return { doAction: () => {} };
},
});
registry.category("services").add("field", fakeService);
registry.category("services").add("company", {
start() {
return { currentCompany: {} };
},
});
registry.category("services").add("notification", fakeService);
await makeRecordSelector(
{
resModel: "partner",
resId: false,
domain: [["display_name", "!=", "some name"]],
placeholder: "Select a partner",
},
{
mockRPC: (route, args) => {
if (args.method === "has_group") {
return true;
}
if (args.method === "web_search_read") {
assert.step("web_search_read");
assert.deepEqual(args.kwargs.domain, [
"&",
["display_name", "!=", "some name"],
"!",
["id", "in", []],
]);
}
},
}
);
await click(target, ".o-autocomplete--input.o_input");
await click(target, ".o_m2o_dropdown_option a");
assert.containsOnce(target, ".modal .o_list_view");
assert.verifySteps(["web_search_read"]);
});
});

View file

@ -0,0 +1,162 @@
/** @odoo-module **/
import { Component, reactive, xml } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { ResizablePanel } from "@web/core/resizable_panel/resizable_panel";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import {
getFixture,
patchWithCleanup,
mount,
nextTick,
triggerEvents,
} from "@web/../tests/helpers/utils";
QUnit.module("Resizable Panel", ({ beforeEach }) => {
let env;
let target;
beforeEach(async () => {
env = await makeTestEnv();
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => Promise.resolve().then(fn),
});
});
QUnit.test("Width cannot exceed viewport width", async (assert) => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<ResizablePanel>
<p>A</p>
<p>Cool</p>
<p>Paragraph</p>
</ResizablePanel>
`;
}
await mount(Parent, target, { env });
assert.containsOnce(target, ".o_resizable_panel");
assert.containsOnce(target, ".o_resizable_panel_handle");
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const sidepanel = target.querySelector(".o_resizable_panel");
sidepanel.style.width = `${vw + 100}px`;
const sidepanelWidth = sidepanel.getBoundingClientRect().width;
assert.ok(
sidepanelWidth <= vw && sidepanelWidth > vw * 0.95,
"The sidepanel should be smaller or equal to the view width"
);
});
QUnit.test("handles right-to-left", async (assert) => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div class="d-flex parent-el" style="direction: rtl;">
<div style="width: 50px;" />
<ResizablePanel minWidth="20" initialWidth="30">
<div style="width: 10px;" class="text-break">
A cool paragraph
</div>
</ResizablePanel>
</div>
`;
}
await mount(Parent, target, { env });
const parentEl = target.querySelector(".parent-el");
const resizablePanelEl = target.querySelector(".o_resizable_panel");
let resizablePabelRect = resizablePanelEl.getBoundingClientRect();
assert.strictEqual(resizablePabelRect.width, 30);
const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle");
await triggerEvents(handle, null, ["mousedown", ["mousemove", { clientX: 10 }], "mouseup"]);
resizablePabelRect = resizablePanelEl.getBoundingClientRect();
assert.ok(resizablePabelRect.width > parentEl.offsetWidth - 10 - 50);
});
QUnit.test("handles resize handle at start in fixed position", async (assert) => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div class="d-flex parent-el">
<ResizablePanel minWidth="20" initialWidth="30" handleSide="'start'" class="'position-fixed'">
<div style="width: 10px;" class="text-break">
A cool paragraph
</div>
</ResizablePanel>
</div>
`;
}
await mount(Parent, target, { env });
const resizablePanelEl = target.querySelector(".o_resizable_panel");
resizablePanelEl.style.setProperty("right", "100px");
let resizablePabelRect = resizablePanelEl.getBoundingClientRect();
assert.strictEqual(resizablePabelRect.width, 30);
const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle");
await triggerEvents(handle, null, [
"mousedown",
["mousemove", { clientX: window.innerWidth - 200 }],
"mouseup",
]);
resizablePabelRect = resizablePanelEl.getBoundingClientRect();
assert.strictEqual(resizablePabelRect.width, 100 + handle.offsetWidth / 2);
});
QUnit.test("resizing the window adapts the panel", async (assert) => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div style="width: 400px;" class="parent-el position-relative">
<ResizablePanel>
<p>A</p>
<p>Cool</p>
<p>Paragraph</p>
</ResizablePanel>
</div>
`;
}
await mount(Parent, target, { env });
const resizablePanelEl = target.querySelector(".o_resizable_panel");
const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle");
await triggerEvents(handle, null, [
"mousedown",
["mousemove", { clientX: 99999 }],
"mouseup",
]);
assert.strictEqual(resizablePanelEl.offsetWidth, 398);
target.querySelector(".parent-el").style.setProperty("width", "200px");
window.dispatchEvent(new Event("resize"));
await nextTick();
assert.strictEqual(resizablePanelEl.offsetWidth, 198);
});
QUnit.test("minWidth props can be updated", async (assert) => {
class Parent extends Component {
static components = { ResizablePanel };
static template = xml`
<div class="d-flex">
<ResizablePanel minWidth="props.state.minWidth">
<div style="width: 10px;" class="text-break">
A cool paragraph
</div>
</ResizablePanel>
</div>
`;
}
const state = reactive({ minWidth: 20 });
await mount(Parent, target, { env, props: { state } });
const resizablePanelEl = target.querySelector(".o_resizable_panel");
const handle = resizablePanelEl.querySelector(".o_resizable_panel_handle");
await triggerEvents(handle, null, ["mousedown", ["mousemove", { clientX: 15 }], "mouseup"]);
assert.strictEqual(resizablePanelEl.getBoundingClientRect().width, 20);
state.minWidth = 40;
await nextTick();
await triggerEvents(handle, null, ["mousedown", ["mousemove", { clientX: 15 }], "mouseup"]);
assert.strictEqual(resizablePanelEl.getBoundingClientRect().width, 40);
});
});

View file

@ -2,7 +2,6 @@
import { browser } from "@web/core/browser/browser";
import { parseHash, parseSearchQuery, routeToUrl } from "@web/core/browser/router_service";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeRouterService } from "../helpers/mock_services";
import { nextTick, patchWithCleanup } from "../helpers/utils";
@ -101,37 +100,6 @@ QUnit.test("routeToUrl encodes URI compatible strings", (assert) => {
assert.strictEqual(routeToUrl(route), "/asf?a=11&g=summer%20wine#b=2&c=&e=kloug%2Cgloubi");
});
QUnit.test("can redirect an URL", async (assert) => {
patchWithCleanup(browser, {
setTimeout(handler, delay) {
handler();
assert.step(`timeout: ${delay}`);
},
});
let firstCheckServer = true;
const env = await makeTestEnv({
async mockRPC(route) {
if (route === "/web/webclient/version_info") {
if (firstCheckServer) {
firstCheckServer = false;
} else {
return true;
}
}
},
});
const onRedirect = (url) => assert.step(url);
const router = await createRouter({ env, onRedirect });
router.redirect("/my/test/url");
await nextTick();
assert.verifySteps(["/my/test/url"]);
router.redirect("/my/test/url/2", true);
await nextTick();
assert.verifySteps(["timeout: 1000", "timeout: 250", "/my/test/url/2"]);
});
QUnit.module("Router: Push state");
QUnit.test("can push in same timeout", async (assert) => {

View file

@ -199,7 +199,7 @@ QUnit.test("clicking anchor when no scrollable", async (assert) => {
<div class="active-container">
<p>There is no scrollable with only the height of this element</p>
</div>
<div class="inactive-container" style="max-height: 0">
<div class="inactive-container" style="max-height: 0; overflow: hidden">
<h2>There should be no scrollable if this element has 0 height</h2>
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
@ -318,9 +318,9 @@ QUnit.test("clicking anchor when multi levels scrollables", async (assert) => {
});
QUnit.test("Simple scroll to HTML elements", async (assert) => {
assert.expect(6);
assert.expect(13);
const scrollableParent = document.createElement("div");
scrollableParent.style.overflow = "scroll";
scrollableParent.style["overflow-y"] = "scroll";
scrollableParent.style.height = "150px";
scrollableParent.style.width = "400px";
target.append(scrollableParent);
@ -359,6 +359,38 @@ QUnit.test("Simple scroll to HTML elements", async (assert) => {
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue.
</p>
<div id="fake-scrollable">
<div id="o-div-3">A div is an HTML element</div>
</div>
<div id="sub-scrollable">
<p>
Aliquam convallis sollicitudin purus. Praesent aliquam, enim at fermentum mollis,
ligula massa adipiscing nisl, ac euismod nibh nisl eu lectus. Fusce vulputate sem
at sapien. Vivamus leo. Aliquam euismod libero eu enim. Nulla nec felis sed leo
placerat imperdiet. Aenean suscipit nulla in justo. Suspendisse cursus rutrum
augue.
</p>
<div id="o-div-4">A div is an HTML element</div>
</div>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus.
Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed,
dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper
congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est
eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu
massa, scelerisque vitae, consequat in, pretium a, enim. Pellentesque congue. Ut
in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent
egestas leo in pede. Praesent blandit odio eu enim.
</p>
<p>
Ut velit mauris, egestas sed, gravida nec, ornare ut, mi. Aenean ut orci vel massa
suscipit pulvinar. Nulla sollicitudin. Fusce varius, ligula non tempus aliquam, nunc
turpis ullamcorper nibh, in tempus sapien eros vitae ligula. Pellentesque rhoncus
nunc et augue. Integer id felis. Curabitur aliquet pellentesque diam. Integer quis
metus vitae elit lobortis egestas. Lorem ipsum dolor sit amet, consectetuer adipiscing
elit. Morbi vel erat non mauris convallis vehicula. Nulla et sapien. Integer tortor
tellus, aliquam faucibus, convallis id, congue eu, quam.
</p>
</div>
`;
await mount(MyComponent, scrollableParent, { env });
@ -377,8 +409,8 @@ QUnit.test("Simple scroll to HTML elements", async (assert) => {
const element = el.getBoundingClientRect();
const scrollable = scrollableParent.getBoundingClientRect();
return {
top: parseInt(element.top - scrollable.top) < 10,
bottom: parseInt(scrollable.bottom - element.bottom) < 10,
top: parseInt(element.top - scrollable.top) === 0,
bottom: parseInt(scrollable.bottom - element.bottom) === 0,
};
};
@ -386,6 +418,13 @@ QUnit.test("Simple scroll to HTML elements", async (assert) => {
// until the element is visible in the scrollable parent
const div_1 = scrollableParent.querySelector("#o-div-1");
const div_2 = scrollableParent.querySelector("#o-div-2");
const div_3 = scrollableParent.querySelector("#o-div-3");
const div_4 = scrollableParent.querySelector("#o-div-4");
const fakeScrollable = scrollableParent.querySelector("#fake-scrollable");
const subScrollable = scrollableParent.querySelector("#sub-scrollable");
subScrollable.style["overflow-y"] = "scroll";
subScrollable.style.height = getComputedStyle(subScrollable)["line-height"];
subScrollable.style.width = "300px";
assert.ok(isVisible(div_1) && !isVisible(div_2), "only the first div is visible");
assert.ok(!border(div_1).top, "the element is not at the top border");
scrollTo(div_2);
@ -393,4 +432,20 @@ QUnit.test("Simple scroll to HTML elements", async (assert) => {
assert.ok(border(div_2).bottom, "the element must be at the bottom border");
scrollTo(div_1);
assert.ok(border(div_1).top, "the element must be at the top border");
assert.ok(!isVisible(div_3) && !isVisible(div_4));
// Specify a scrollable which can not be scrolled, the effective scrollable
// should be its closest actually scrollable parent.
scrollTo(div_3, { scrollable: fakeScrollable });
assert.ok(isVisible(div_3) && !isVisible(div_4));
assert.ok(border(div_3).bottom, "the element must be at the bottom border");
// Reset the position
scrollTo(div_1);
assert.ok(isVisible(div_1) && !isVisible(div_3) && !isVisible(div_4));
// Scrolling should be recursive in case of a hierarchy of
// scrollables, if `isAnchor` is set to `true`, and it must be scrolled
// to the top even if it was positioned below the scroll view.
scrollTo(div_4, { isAnchor: true });
assert.ok(isVisible(div_4));
assert.ok(border(div_4).top, "the element must be at the top border");
assert.ok(border(subScrollable).top, "the element must be a the thop border");
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { TagsList } from "@web/core/tags_list/tags_list";
import { makeTestEnv } from "../helpers/mock_env";
import { getFixture, patchWithCleanup, mount, click } from "../helpers/utils";
import { Component, xml } from "@odoo/owl";
QUnit.module("Web Components", (hooks) => {
QUnit.module("TagsList");
let env;
let target;
hooks.beforeEach(async () => {
env = await makeTestEnv();
target = getFixture();
patchWithCleanup(browser, {
setTimeout: (fn) => Promise.resolve().then(fn),
});
});
QUnit.test("Can be rendered with different tags", async (assert) => {
class Parent extends Component {
setup() {
this.tags = [
{
id: "tag1",
text: "Earth",
},
{
colorIndex: 1,
id: "tag2",
text: "Wind",
onDelete: () => {
assert.step(`tag2 delete button has been clicked`);
},
},
{
colorIndex: 2,
id: "tag3",
text: "Fire",
onClick: () => {
assert.step(`tag3 has been clicked`);
},
onDelete: () => {
assert.step(`tag3 delete button has been clicked`);
},
},
];
}
}
Parent.components = { TagsList };
Parent.template = xml`
<TagsList tags="tags" />`;
await mount(Parent, target, { env });
assert.containsN(target, ".o_tag", 3);
await click(target.querySelector(".o_tag:nth-of-type(2) .o_delete"));
assert.verifySteps(["tag2 delete button has been clicked"]);
await click(target.querySelector(".o_tag:nth-of-type(3)"));
assert.verifySteps(["tag3 has been clicked"]);
});
QUnit.test("Tags can be displayed with an image", async (assert) => {
class Parent extends Component {
setup() {
this.tags = [
{
img: "fake/url",
id: "tag1",
text: "Earth",
},
{
img: "fake/url/2",
id: "tag2",
text: "Wind",
},
];
}
}
Parent.components = { TagsList };
Parent.template = xml`
<TagsList tags="tags" />`;
await mount(Parent, target, { env });
assert.containsN(target, ".o_tag", 2);
assert.strictEqual(
target.querySelector(".o_tag:nth-of-type(1) img").dataset.src,
"fake/url"
);
assert.strictEqual(
target.querySelector(".o_tag:nth-of-type(2) img").dataset.src,
"fake/url/2"
);
});
QUnit.test("Tags can be displayed with an icon", async (assert) => {
class Parent extends Component {
setup() {
this.tags = [
{
icon: "fa-trash",
id: "tag1",
text: "Bad",
},
{
icon: "fa-check",
id: "tag2",
text: "Good",
},
];
}
}
Parent.components = { TagsList };
Parent.template = xml`
<TagsList tags="tags" />`;
await mount(Parent, target, { env });
assert.containsN(target, ".o_tag", 2);
assert.hasClass(target.querySelector(".o_tag:nth-of-type(1) i"), "fa fa-trash");
assert.hasClass(target.querySelector(".o_tag:nth-of-type(2) i"), "fa fa-check");
});
QUnit.test("Limiting the visible tags displays a counter", async (assert) => {
class Parent extends Component {
setup() {
this.tags = [
{
id: "tag1",
text: "Water",
onDelete: () => {},
},
{
id: "tag2",
text: "Grass",
},
{
id: "tag3",
text: "Fire",
},
{
id: "tag4",
text: "Earth",
},
{
id: "tag5",
text: "Wind",
},
{
id: "tag6",
text: "Dust",
},
];
}
}
Parent.components = { TagsList };
Parent.template = xml`
<TagsList tags="tags" itemsVisible="3" />`;
await mount(Parent, target, { env });
assert.containsN(target, ".o_tag", 2);
const counter = target.querySelector(".rounded-circle");
assert.strictEqual(counter.textContent, "+4", "the counter displays 4 more items");
assert.deepEqual(
JSON.parse(counter.dataset.tooltipInfo),
{
tags: [
{ text: "Fire", id: "tag3" },
{ text: "Earth", id: "tag4" },
{ text: "Wind", id: "tag5" },
{ text: "Dust", id: "tag6" },
],
},
"the counter has a tooltip displaying other items"
);
});
QUnit.test("Tags with img have a backdrop only if they can be deleted", async (assert) => {
class Parent extends Component {
setup() {
this.tags = [
{
id: "tag1",
text: "Earth",
img: "fake/url",
},
{
colorIndex: 1,
id: "tag2",
text: "Wind",
img: "fake/url",
onDelete: () => {},
},
];
}
}
Parent.components = { TagsList };
Parent.template = xml`<TagsList tags="tags" />`;
await mount(Parent, target, { env });
assert.containsN(target, ".o_tag", 2);
assert.containsNone(target.querySelectorAll(".o_tag")[0], ".o_avatar_backdrop");
assert.containsOnce(target.querySelectorAll(".o_tag")[1], ".o_avatar_backdrop");
});
});

View file

@ -1,17 +1,17 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { PopoverContainer } from "@web/core/popover/popover_container";
import { popoverService } from "@web/core/popover/popover_service";
import { tooltipService } from "@web/core/tooltip/tooltip_service";
import { registry } from "@web/core/registry";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { getFixture, nextTick, patchWithCleanup, triggerEvent } from "../../helpers/utils";
import { registerCleanup } from "../../helpers/cleanup";
import { makeFakeLocalizationService } from "../../helpers/mock_services";
import { templates } from "@web/core/assets";
import { App, Component, useState, xml } from "@odoo/owl";
import { templates } from "@web/core/assets";
import { browser } from "@web/core/browser/browser";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { popoverService } from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { tooltipService } from "@web/core/tooltip/tooltip_service";
import { registerCleanup } from "../../helpers/cleanup";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { makeFakeLocalizationService } from "../../helpers/mock_services";
import { getFixture, nextTick, patchWithCleanup, triggerEvent } from "../../helpers/utils";
const mainComponents = registry.category("main_components");
@ -49,6 +49,7 @@ export async function makeParent(Child, options = {}) {
registry.category("services").add("popover", popoverService);
registry.category("services").add("tooltip", tooltipService);
registry.category("services").add("localization", makeFakeLocalizationService());
registry.category("services").add("hotkey", hotkeyService, { force: true });
let env = await makeTestEnv();
if (options.extraEnv) {
env = Object.create(env, Object.getOwnPropertyDescriptors(options.extraEnv));
@ -56,7 +57,7 @@ export async function makeParent(Child, options = {}) {
patchWithCleanup(env.services.popover, {
add(...args) {
const result = this._super(...args);
const result = super.add(...args);
if (options.onPopoverAdded) {
options.onPopoverAdded(...args);
}
@ -65,20 +66,13 @@ export async function makeParent(Child, options = {}) {
});
class Parent extends Component {
setup() {
this.Components = mainComponents.getEntries();
}
}
Parent.template = xml`
<div>
<Child/>
static template = xml`
<div>
<t t-foreach="Components" t-as="Component" t-key="Component[0]">
<t t-component="Component[1].Component" t-props="Component[1].props"/>
</t>
</div>
</div>`;
Parent.components = { PopoverContainer, Child };
<Child/>
<MainComponentsContainer />
</div>`;
static components = { Child, MainComponentsContainer };
}
const app = new App(Parent, {
env,
@ -109,22 +103,19 @@ QUnit.module("Tooltip service", (hooks) => {
};
await makeParent(MyComponent, { mockSetTimeout });
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
target.querySelector(".mybtn").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
simulateTimeout();
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.strictEqual(
target.querySelector(".o_popover_container .o_popover").innerText,
"hello"
);
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "hello");
target.querySelector(".mybtn").dispatchEvent(new Event("mouseleave"));
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("basic rendering 2", async (assert) => {
@ -136,28 +127,25 @@ QUnit.module("Tooltip service", (hooks) => {
};
await makeParent(MyComponent, { mockSetTimeout });
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
const [outerSpan, innerSpan] = target.querySelectorAll("span.our_span");
outerSpan.dispatchEvent(new Event("mouseenter"));
innerSpan.dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
simulateTimeout();
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.strictEqual(
target.querySelector(".o_popover_container .o_popover").innerText,
"hello"
);
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "hello");
innerSpan.dispatchEvent(new Event("mouseleave"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
outerSpan.dispatchEvent(new Event("mouseleave"));
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("remove element with opened tooltip", async (assert) => {
@ -179,17 +167,17 @@ QUnit.module("Tooltip service", (hooks) => {
await makeParent(MyComponent, { mockSetInterval });
assert.containsOnce(target, "button");
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
target.querySelector("button").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
compState.visible = false;
await nextTick();
assert.containsNone(target, "button");
simulateInterval();
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("rendering with several tooltips", async (assert) => {
@ -201,15 +189,15 @@ QUnit.module("Tooltip service", (hooks) => {
</div>`;
await makeParent(MyComponent);
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
target.querySelector("button.button_1").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "tooltip 1");
target.querySelector("button.button_1").dispatchEvent(new Event("mouseleave"));
target.querySelector("button.button_2").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "tooltip 2");
});
@ -237,35 +225,35 @@ QUnit.module("Tooltip service", (hooks) => {
// default
target.querySelector("button.default").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "default");
assert.verifySteps(["popover added with default positioning"]);
// top
target.querySelector("button.top").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "top");
assert.verifySteps(["popover added with position: top"]);
// right
target.querySelector("button.right").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "right");
assert.verifySteps(["popover added with position: right"]);
// bottom
target.querySelector("button.bottom").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "bottom");
assert.verifySteps(["popover added with position: bottom"]);
// left
target.querySelector("button.left").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "left");
assert.verifySteps(["popover added with position: left"]);
});
@ -281,10 +269,10 @@ QUnit.module("Tooltip service", (hooks) => {
};
await makeParent(MyComponent, { templates, extraEnv: { tooltip_text: "tooltip" } });
assert.containsNone(target, ".o_popover_container .o-tooltip");
assert.containsNone(target, ".o-tooltip");
target.querySelector("button").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o-tooltip");
assert.containsOnce(target, ".o-tooltip");
assert.strictEqual(target.querySelector(".o-tooltip").innerHTML, "<i>tooltip</i>");
});
@ -312,10 +300,10 @@ QUnit.module("Tooltip service", (hooks) => {
};
await makeParent(MyComponent, { templates });
assert.containsNone(target, ".o_popover_container .o-tooltip");
assert.containsNone(target, ".o-tooltip");
target.querySelector("button").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsOnce(target, ".o_popover_container .o-tooltip");
assert.containsOnce(target, ".o-tooltip");
assert.strictEqual(
target.querySelector(".o-tooltip").innerHTML,
"<ul><li>X: 3</li><li>Y: abc</li></ul>"
@ -335,12 +323,12 @@ QUnit.module("Tooltip service", (hooks) => {
};
await makeParent(MyComponent, { mockSetTimeout });
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
target.querySelector("button").dispatchEvent(new Event("mouseenter"));
await nextTick();
simulateTimeout();
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("tooltip with no delay (default delay)", async (assert) => {
@ -381,24 +369,21 @@ QUnit.module("Tooltip service", (hooks) => {
const mockOnTouchStart = () => {};
await makeParent(MyComponent, { mockSetTimeout, mockSetInterval, mockOnTouchStart });
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
await triggerEvent(target, "button", "touchstart");
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
simulateTimeout();
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.strictEqual(
target.querySelector(".o_popover_container .o_popover").innerText,
"hello"
);
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "hello");
await triggerEvent(target, "button", "touchend");
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
simulateInterval();
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("touch rendering - tap-to-show", async (assert) => {
@ -415,27 +400,24 @@ QUnit.module("Tooltip service", (hooks) => {
const mockOnTouchStart = () => {};
await makeParent(MyComponent, { mockSetTimeout, mockSetInterval, mockOnTouchStart });
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
await triggerEvent(target, "button[data-tooltip]", "touchstart");
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
simulateTimeout();
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.strictEqual(
target.querySelector(".o_popover_container .o_popover").innerText,
"hello"
);
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "hello");
await triggerEvent(target, "button[data-tooltip]", "touchend");
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
simulateInterval();
await nextTick();
assert.containsOnce(target, ".o_popover_container .o_popover");
assert.containsOnce(target, ".o_popover");
await triggerEvent(target, "button[data-tooltip]", "touchstart");
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("tooltip does not crash with disappearing target", async (assert) => {
@ -447,10 +429,10 @@ QUnit.module("Tooltip service", (hooks) => {
};
await makeParent(MyComponent, { mockSetTimeout });
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
target.querySelector(".mybtn").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
// the element disappeared from the DOM during the setTimeout
target.querySelector(".mybtn").remove();
@ -459,6 +441,49 @@ QUnit.module("Tooltip service", (hooks) => {
await nextTick();
// tooltip did not crash and is not shown
assert.containsNone(target, ".o_popover_container .o_popover");
assert.containsNone(target, ".o_popover");
});
QUnit.test("tooltip using the mouse with a touch enabled device", async (assert) => {
// patch matchMedia to alter hasTouch value
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
}
return this._super();
},
});
class MyComponent extends Component {}
MyComponent.template = xml`<button class="mybtn" data-tooltip="hello">Action</button>`;
let simulateTimeout;
let simulateInterval;
const mockSetTimeout = (fn) => {
simulateTimeout = fn;
};
const mockSetInterval = (fn) => {
simulateInterval = fn;
};
await makeParent(MyComponent, { mockSetInterval, mockSetTimeout });
assert.containsNone(target, ".o_popover");
target.querySelector(".mybtn").dispatchEvent(new Event("mouseenter"));
await nextTick();
assert.containsNone(target, ".o_popover");
simulateTimeout();
await nextTick();
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "hello");
simulateInterval();
await nextTick();
assert.containsOnce(target, ".o_popover");
assert.strictEqual(target.querySelector(".o_popover").innerText, "hello");
target.querySelector(".mybtn").dispatchEvent(new Event("mouseleave"));
await nextTick();
assert.containsNone(target, ".o_popover");
});
});

View file

@ -66,7 +66,7 @@ QUnit.test("use block and unblock several times to block ui with ui service", as
assert.strictEqual(blockUI, null, "ui should not be blocked");
});
QUnit.test("a component can be the UI active element: with t-ref delegation", async (assert) => {
QUnit.test("a component can be the UI active element: simple usage", async (assert) => {
class MyComponent extends Component {
setup() {
useActiveElement("delegatedRef");
@ -76,7 +76,9 @@ QUnit.test("a component can be the UI active element: with t-ref delegation", a
MyComponent.template = xml`
<div>
<h1>My Component</h1>
<div t-if="hasRef" id="owner" t-ref="delegatedRef"/>
<div t-if="hasRef" id="owner" t-ref="delegatedRef">
<input type="text"/>
</div>
</div>
`;
@ -85,12 +87,15 @@ QUnit.test("a component can be the UI active element: with t-ref delegation", a
assert.deepEqual(ui.activeElement, document);
const comp = await mount(MyComponent, target, { env });
const input = target.querySelector("#owner input");
assert.deepEqual(ui.activeElement, document.getElementById("owner"));
assert.strictEqual(document.activeElement, input);
comp.hasRef = false;
comp.render();
await nextTick();
assert.deepEqual(ui.activeElement, document);
assert.strictEqual(document.activeElement, document.body);
});
QUnit.test("UI active element: trap focus", async (assert) => {
@ -119,30 +124,19 @@ QUnit.test("UI active element: trap focus", async (assert) => {
);
// Pressing 'Tab'
let event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab" },
{ fast: true }
);
let event = await triggerEvent(document.activeElement, null, "keydown", { key: "Tab" });
assert.strictEqual(event.defaultPrevented, true);
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelector("input[placeholder=withFocus]")
);
// Pressing 'Shift + Tab'
event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab", shiftKey: true },
{ fast: true }
);
event = await triggerEvent(document.activeElement, null, "keydown", {
key: "Tab",
shiftKey: true,
});
assert.strictEqual(event.defaultPrevented, true);
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelector("input[placeholder=withFocus]")
@ -177,47 +171,33 @@ QUnit.test("UI active element: trap focus - default focus with autofocus", async
);
// Pressing 'Tab'
let event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab" },
{ fast: true }
);
let event = await triggerEvent(document.activeElement, null, "keydown", { key: "Tab" });
assert.strictEqual(event.defaultPrevented, true);
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelector("input[placeholder=withoutFocus]")
);
// Pressing 'Shift + Tab'
event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab", shiftKey: true },
{ fast: true }
);
event = await triggerEvent(document.activeElement, null, "keydown", {
key: "Tab",
shiftKey: true,
});
assert.strictEqual(event.defaultPrevented, true);
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelector("input[placeholder=withAutoFocus]")
);
// Pressing 'Shift + Tab' (default)
event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab", shiftKey: true },
{ fast: true }
);
event = await triggerEvent(document.activeElement, null, "keydown", {
key: "Tab",
shiftKey: true,
});
assert.strictEqual(event.defaultPrevented, false);
});
QUnit.test("UI active element: trap focus - no focus element", async (assert) => {
QUnit.test("do not become UI active element if no element to focus", async (assert) => {
class MyComponent extends Component {
setup() {
useActiveElement("delegatedRef");
@ -237,35 +217,7 @@ QUnit.test("UI active element: trap focus - no focus element", async (assert) =>
const env = await makeTestEnv({ ...baseConfig });
await mount(MyComponent, target, { env });
assert.strictEqual(
document.activeElement,
target.querySelector("div[id=idActiveElement]"),
"when there is not other element, the focus is on the UI active element itself"
);
// Pressing 'Tab'
let event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab" },
{ fast: true }
);
assert.strictEqual(event.defaultPrevented, true);
await nextTick();
assert.strictEqual(document.activeElement, target.querySelector("div[id=idActiveElement]"));
// Pressing 'Shift + Tab'
event = triggerEvent(
document.activeElement,
null,
"keydown",
{ key: "Tab", shiftKey: true },
{ fast: true }
);
assert.strictEqual(event.defaultPrevented, true);
await nextTick();
assert.strictEqual(document.activeElement, target.querySelector("div[id=idActiveElement]"));
assert.strictEqual(env.services.ui.activeElement, document);
});
QUnit.test("UI active element: trap focus - first or last tabable changes", async (assert) => {
@ -364,7 +316,7 @@ QUnit.test(
);
// Pressing 'Shift + Tab'
event = triggerEvent(
event = await triggerEvent(
document.activeElement,
null,
"keydown",

View file

@ -3,6 +3,8 @@
import { registry } from "@web/core/registry";
import { userService } from "@web/core/user_service";
import { makeTestEnv } from "../helpers/mock_env";
import { session } from "@web/session";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
const serviceRegistry = registry.category("services");
@ -25,3 +27,22 @@ QUnit.test("successive calls to hasGroup", async (assert) => {
assert.verifySteps(["res.users/has_group/x", "res.users/has_group/y"]);
});
QUnit.test("set user settings do not override old valid keys", async (assert) => {
patchWithCleanup(session, {
...session,
user_settings: { a: 1, b: 2 },
});
serviceRegistry.add("user", userService);
const mockRPC = (route, args) => {
assert.step(JSON.stringify(args.kwargs.new_settings));
return { a: 3, c: 4 };
};
const env = await makeTestEnv({ mockRPC });
assert.deepEqual(env.services.user.settings, { a: 1, b: 2 });
await env.services.user.setUserSettings("a", 3);
assert.verifySteps(['{"a":3}']);
assert.deepEqual(env.services.user.settings, { a: 3, b: 2, c: 4 });
});

View file

@ -2,11 +2,14 @@
import {
cartesian,
ensureArray,
groupBy,
intersection,
shallowEqual,
sortBy,
unique,
zip,
zipWith,
} from "@web/core/utils/arrays";
QUnit.module("utils", () => {
@ -236,6 +239,18 @@ QUnit.module("utils", () => {
assert.deepEqual(cartesian([1], [2], [3], [4]), [[1, 2, 3, 4]]);
});
QUnit.test("ensure array", async (assert) => {
const arrayRef = [];
assert.notEqual(ensureArray(arrayRef), arrayRef, "Should be a different array");
assert.deepEqual(ensureArray([]), []);
assert.deepEqual(ensureArray(), [undefined]);
assert.deepEqual(ensureArray(null), [null]);
assert.deepEqual(ensureArray({ a: 1 }), [{ a: 1 }]);
assert.deepEqual(ensureArray("foo"), ["foo"]);
assert.deepEqual(ensureArray([1, 2, "3"]), [1, 2, "3"]);
assert.deepEqual(ensureArray(new Set([1, 2, 3])), [1, 2, 3]);
});
QUnit.test("unique array", function (assert) {
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(" "));
@ -266,4 +281,20 @@ QUnit.module("utils", () => {
assert.ok(shallowEqual([fn], [fn]));
assert.notOk(shallowEqual([() => {}], [() => {}]));
});
QUnit.test("zip", function (assert) {
assert.deepEqual(zip([1, 2], []), []);
assert.deepEqual(zip([1, 2], ["a"]), [[1, "a"]]);
assert.deepEqual(zip([1, 2], ["a", "b"]), [
[1, "a"],
[2, "b"],
]);
});
QUnit.test("zipWith", function (assert) {
assert.deepEqual(
zipWith([{ a: 1 }, { b: 2 }], ["a", "b"], (o, k) => o[k]),
[1, 2]
);
});
});

View file

@ -0,0 +1,14 @@
/** @odoo-module **/
import { humanSize } from "@web/core/utils/binary";
QUnit.module("utils", () => {
QUnit.module("binary");
QUnit.test("humanSize", (assert) => {
assert.strictEqual(humanSize(0), "0.00 Bytes");
assert.strictEqual(humanSize(3), "3.00 Bytes");
assert.strictEqual(humanSize(2048), "2.00 Kb");
assert.strictEqual(humanSize(2645000), "2.52 Mb");
});
});

View file

@ -0,0 +1,458 @@
/** @odoo-module **/
import {
drag,
dragAndDrop,
getFixture,
mount,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { useDraggable } from "@web/core/utils/draggable";
import { Component, reactive, useRef, useState, xml } from "@odoo/owl";
let target;
QUnit.module("Draggable", ({ beforeEach }) => {
beforeEach(() => (target = getFixture()));
QUnit.module("Draggable hook");
QUnit.test("Parameters error handling", async (assert) => {
assert.expect(5);
const mountListAndAssert = async (setupList, shouldThrow) => {
class List extends Component {
setup() {
setupList();
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
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(() => {
useDraggable({});
}, true);
await mountListAndAssert(() => {
useDraggable({
elements: ".item",
});
}, true);
// Correct params
await mountListAndAssert(() => {
useDraggable({
ref: useRef("root"),
});
}, false);
await mountListAndAssert(() => {
useDraggable({
ref: {},
elements: ".item",
enable: false,
});
}, false);
await mountListAndAssert(() => {
useDraggable({
ref: useRef("root"),
elements: ".item",
});
}, false);
});
QUnit.test("Simple dragging in single group", async (assert) => {
assert.expect(16);
class List extends Component {
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
onDragStart({ element }) {
assert.step("start");
assert.strictEqual(element.innerText, "1");
},
onDrag({ element }) {
assert.step("drag");
assert.strictEqual(element.innerText, "1");
},
onDragEnd({ element }) {
assert.step("end");
assert.strictEqual(element.innerText, "1");
assert.containsN(target, ".item", 3);
},
onDrop({ element }) {
assert.step("drop");
assert.strictEqual(element.innerText, "1");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
assert.containsN(target, ".item", 3);
assert.containsNone(target, ".o_dragged");
assert.verifySteps([]);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
assert.hasClass(target.querySelector(".item"), "o_dragged");
await drop();
assert.containsN(target, ".item", 3);
assert.containsNone(target, ".o_dragged");
assert.verifySteps(["start", "drag", "drop", "end"]);
});
QUnit.test("Dynamically disable draggable feature", async (assert) => {
assert.expect(4);
const state = reactive({ enableDrag: true });
class List extends Component {
setup() {
this.state = useState(state);
useDraggable({
ref: useRef("root"),
elements: ".item",
enable: () => this.state.enableDrag,
onDragStart() {
assert.step("start");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
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.enableDrag = 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("Ignore specified elements", async (assert) => {
assert.expect(6);
class List extends Component {
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
ignore: ".ignored",
onDragStart() {
assert.step("drag");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" class="item">
<span class="ignored" t-esc="i" />
<span class="not-ignored" t-esc="i" />
</li>
</ul>
</div>`;
await mount(List, target);
assert.verifySteps([]);
// Drag root item element
await dragAndDrop(".item:first-child", ".item:nth-child(2)");
assert.verifySteps(["drag"]);
// Drag ignored element
await dragAndDrop(".item:first-child .not-ignored", ".item:nth-child(2)");
assert.verifySteps(["drag"]);
// Drag non-ignored element
await dragAndDrop(".item:first-child .ignored", ".item:nth-child(2)");
assert.verifySteps([]);
});
QUnit.test("Ignore specific elements in a nested draggable", async (assert) => {
assert.expect(7);
class List extends Component {
static components = { List };
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[0, 1]" t-as="i" t-key="i"
t-attf-class="item parent #{ i % 2 ? 'ignored' : 'not-ignored' }">
<span t-esc="'parent' + i" />
<ul class="list">
<li t-foreach="[0, 1]" t-as="j" t-key="j"
t-attf-class="item child #{ j % 2 ? 'ignored' : 'not-ignored' }">
<span t-esc="'child' + j" />
</li>
</ul>
</li>
</ul>
</div>`;
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
preventDrag: (el) => el.classList.contains("ignored"),
onDragStart() {
assert.step("drag");
},
});
}
}
await mount(List, target);
assert.verifySteps([]);
// Drag ignored under non-ignored -> block
await dragAndDrop(
".not-ignored.parent .ignored.child",
".not-ignored.parent .not-ignored.child"
);
assert.verifySteps([]);
// Drag not-ignored-under not-ignored -> succeed
await dragAndDrop(
".not-ignored.parent .not-ignored.child",
".not-ignored.parent .ignored.child"
);
assert.verifySteps(["drag"]);
// Drag ignored under ignored -> block
await dragAndDrop(".ignored.parent .ignored.child", ".ignored.parent .not-ignored.child");
assert.verifySteps([]);
// Drag not-ignored under ignored -> succeed
await dragAndDrop(".ignored.parent .not-ignored.child", ".ignored.parent .ignored.child");
assert.verifySteps(["drag"]);
});
QUnit.test("Dragging element with touch event", async (assert) => {
assert.expect(10);
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
} else {
this._super();
}
},
setTimeout: (fn, delay) => {
assert.strictEqual(delay, 300, "touch drag has a default 300ms initiation delay");
fn();
},
});
class List extends Component {
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
onDragStart({ element }) {
assert.step("start");
assert.hasClass(
element,
"o_touch_bounce",
"element has the animation class applied"
);
},
onDrag() {
assert.step("drag");
},
onDragEnd() {
assert.step("end");
},
async onDrop({ element }) {
assert.step("drop");
await nextTick();
assert.doesNotHaveClass(
element,
"o_touch_bounce",
"element no longer has the animation class applied"
);
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
assert.verifySteps([]);
const { drop, moveTo } = await drag(".item:first-child", "touch");
await moveTo(".item:nth-child(2)");
assert.hasClass(target.querySelector(".item"), "o_dragged");
await drop();
assert.verifySteps(["start", "drag", "drop", "end"]);
});
QUnit.test(
"Dragging element with touch event: initiation delay can be overrided",
async (assert) => {
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
} else {
this._super();
}
},
setTimeout: (fn, delay) => {
assert.strictEqual(delay, 1000, "touch drag has the custom initiation delay");
fn();
},
});
class List extends Component {
setup() {
useDraggable({
ref: useRef("root"),
delay: 1000,
elements: ".item",
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
const { drop, moveTo } = await drag(".item:first-child", "touch");
await moveTo(".item:nth-child(2)");
await drop();
}
);
QUnit.test("Elements are confined within their container", async (assert) => {
/**
* @param {string} selector
*/
const getRect = (selector) => document.querySelector(selector).getBoundingClientRect();
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<ul class="list list-unstyled m-0 d-flex flex-column">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item w-50" />
</ul>
</div>
`;
setup() {
useDraggable({
ref: useRef("root"),
elements: ".item",
});
}
}
// Reset fixture style to test coordinates properly
Object.assign(target.style, {
top: 0,
left: 0,
width: "100vw",
height: "100vh",
"z-index": -1,
});
await mount(List, target);
const containerRect = getRect(".root");
const { moveTo, drop } = await drag(".item:first-child");
let itemRect = getRect(".item:first-child");
assert.strictEqual(itemRect.x, containerRect.x);
assert.strictEqual(itemRect.y, containerRect.y);
assert.strictEqual(itemRect.width, containerRect.width / 2);
await moveTo(".item:last-child", { y: 9999 });
itemRect = getRect(".item:first-child");
assert.strictEqual(itemRect.x, containerRect.x);
assert.strictEqual(itemRect.y, containerRect.y + containerRect.height - itemRect.height);
await moveTo(".item:last-child", { x: 9999, y: 9999 });
itemRect = getRect(".item:first-child");
assert.strictEqual(itemRect.x, containerRect.x + containerRect.width - itemRect.width);
assert.strictEqual(itemRect.y, containerRect.y + containerRect.height - itemRect.height);
await moveTo(".item:last-child", { x: -9999, y: -9999 });
itemRect = getRect(".item:first-child");
assert.strictEqual(itemRect.x, containerRect.x);
assert.strictEqual(itemRect.y, containerRect.y);
await drop();
});
});

View file

@ -1,6 +1,7 @@
/** @odoo-module **/
import { memoize } from "@web/core/utils/functions";
import { memoize, uniqueId } from "@web/core/utils/functions";
import { patchWithCleanup } from "../../helpers/utils";
QUnit.module("utils", () => {
QUnit.module("Functions");
@ -68,4 +69,15 @@ QUnit.module("utils", () => {
const memoized2 = memoize(function () {});
assert.strictEqual(memoized2.name, "memoized");
});
QUnit.test("uniqueId", (assert) => {
patchWithCleanup(uniqueId, { nextId: 0 });
assert.strictEqual(uniqueId("test_"), "test_1");
assert.strictEqual(uniqueId("bla"), "bla2");
assert.strictEqual(uniqueId("test_"), "test_3");
assert.strictEqual(uniqueId("bla"), "bla4");
assert.strictEqual(uniqueId("test_"), "test_5");
assert.strictEqual(uniqueId("test_"), "test_6");
assert.strictEqual(uniqueId("bla"), "bla7");
});
});

View file

@ -1,20 +1,30 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { uiService } from "@web/core/ui/ui_service";
import { useAutofocus, useBus, useChildRef, useForwardRefToParent, useListener, useService } from "@web/core/utils/hooks";
import {
useAutofocus,
useBus,
useChildRef,
useForwardRefToParent,
useService,
useSpellCheck,
} from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import {
click,
destroy,
getFixture,
makeDeferred,
mount,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { LegacyComponent } from "@web/legacy/legacy_component";
import { Component, onMounted, useState, xml } from "@odoo/owl";
import { dialogService } from "@web/core/dialog/dialog_service";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { CommandPalette } from "@web/core/commands/command_palette";
const serviceRegistry = registry.category("services");
QUnit.module("utils", () => {
@ -108,15 +118,79 @@ QUnit.module("utils", () => {
assert.strictEqual(document.activeElement, comp.inputRef.el);
});
QUnit.test("useAutofocus returns also a ref when isSmall is true", async function (assert) {
assert.expect(2);
QUnit.test(
"useAutofocus returns also a ref when screen has touch",
async function (assert) {
assert.expect(1);
class MyComponent extends Component {
setup() {
this.inputRef = useAutofocus();
onMounted(() => {
assert.ok(this.inputRef.el);
});
}
}
MyComponent.template = xml`
<span>
<input type="text" t-ref="autofocus" />
</span>
`;
registry.category("services").add("ui", uiService);
// patch matchMedia to alter hasTouch value
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
}
this._super();
},
});
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
}
);
QUnit.test(
"useAutofocus works when screen has touch and you provide mobile param",
async function (assert) {
class MyComponent extends Component {
setup() {
this.inputRef = useAutofocus({ mobile: true });
}
}
MyComponent.template = xml`
<span>
<input type="text" t-ref="autofocus" />
</span>
`;
registry.category("services").add("ui", uiService);
// patch matchMedia to alter hasTouch value
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
}
this._super();
},
});
const env = await makeTestEnv();
const target = getFixture();
const comp = await mount(MyComponent, target, { env });
assert.strictEqual(document.activeElement, comp.inputRef.el);
}
);
QUnit.test("useAutofocus does not focus when screen has touch", async function (assert) {
class MyComponent extends Component {
setup() {
this.inputRef = useAutofocus();
assert.ok(this.env.isSmall);
onMounted(() => {
assert.ok(this.inputRef.el);
});
}
}
MyComponent.template = xml`
@ -125,24 +199,22 @@ QUnit.module("utils", () => {
</span>
`;
const fakeUIService = {
start(env) {
const ui = {};
Object.defineProperty(env, "isSmall", {
get() {
return true;
},
});
registry.category("services").add("ui", uiService);
return ui;
// patch matchMedia to alter hasTouch value
patchWithCleanup(browser, {
matchMedia: (media) => {
if (media === "(pointer:coarse)") {
return { matches: true };
}
this._super();
},
};
registry.category("services").add("ui", fakeUIService);
});
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
const comp = await mount(MyComponent, target, { env });
assert.notEqual(document.activeElement, comp.inputRef.el);
});
QUnit.test("supports different ref names", async (assert) => {
@ -203,6 +275,48 @@ QUnit.module("utils", () => {
assert.strictEqual(comp.inputRef.el.selectionEnd, 10);
});
QUnit.test(
"useAutofocus: autofocus outside of active element doesn't work (CommandPalette)",
async function (assert) {
class MyComponent extends Component {
setup() {
this.inputRef = useAutofocus();
}
get OverlayContainer() {
return registry.category("main_components").get("OverlayContainer");
}
}
MyComponent.template = xml`
<div>
<input type="text" t-ref="autofocus" />
<div class="o_dialog_container"/>
<t t-component="OverlayContainer.Component" t-props="OverlayContainer.props" />
</div>
`;
registry.category("services").add("ui", uiService);
registry.category("services").add("dialog", dialogService);
registry.category("services").add("hotkey", hotkeyService);
const config = { providers: [] };
const env = await makeTestEnv();
const target = getFixture();
const comp = await mount(MyComponent, target, { env });
await nextTick();
assert.strictEqual(document.activeElement, comp.inputRef.el);
env.services.dialog.add(CommandPalette, { config });
await nextTick();
assert.containsOnce(target, ".o_command_palette");
assert.notStrictEqual(document.activeElement, comp.inputRef.el);
comp.render();
await nextTick();
assert.notStrictEqual(document.activeElement, comp.inputRef.el);
}
);
QUnit.module("useBus");
QUnit.test("useBus hook: simple usecase", async function (assert) {
@ -229,85 +343,6 @@ QUnit.module("utils", () => {
assert.verifySteps([]);
});
QUnit.module("useListener");
QUnit.test("useListener: simple usecase", async function (assert) {
class MyComponent extends LegacyComponent {
setup() {
useListener("click", () => assert.step("click"));
}
}
MyComponent.template = xml`<button class="root">Click Me</button>`;
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
await click(target.querySelector(".root"));
assert.verifySteps(["click"]);
});
QUnit.test("useListener: event delegation", async function (assert) {
class MyComponent extends LegacyComponent {
setup() {
this.flag = true;
useListener("click", "button", () => assert.step("click"));
}
}
MyComponent.template = xml`
<div class="root">
<button t-if="flag">Click Here</button>
<button t-else="">
<span>or Here</span>
</button>
</div>`;
const env = await makeTestEnv();
const target = getFixture();
const comp = await mount(MyComponent, target, { env });
await click(target.querySelector(".root"));
assert.verifySteps([]);
await click(target.querySelector("button"));
assert.verifySteps(["click"]);
comp.flag = false;
comp.render();
await nextTick();
await click(target.querySelector("button span"));
assert.verifySteps(["click"]);
});
QUnit.test("useListener: event delegation with capture option", async function (assert) {
class MyComponent extends LegacyComponent {
setup() {
this.flag = false;
useListener("click", "button", () => assert.step("click"), { capture: true });
}
}
MyComponent.template = xml`
<div class="root">
<button t-if="flag">Click Here</button>
<button t-else="">
<span>or Here</span>
</button>
</div>`;
const env = await makeTestEnv();
const target = getFixture();
const comp = await mount(MyComponent, target, { env });
await click(target.querySelector(".root"));
assert.verifySteps([]);
await click(target.querySelector("button"));
assert.verifySteps(["click"]);
comp.flag = false;
await comp.render();
await click(target.querySelector("button span"));
assert.verifySteps(["click"]);
});
QUnit.module("useService");
QUnit.test("useService: unavailable service", async function (assert) {
@ -424,6 +459,281 @@ QUnit.module("utils", () => {
assert.strictEqual(nbCalls, 8);
});
QUnit.test("useService: async service with protected methods", async function (assert) {
let nbCalls = 0;
let def = makeDeferred();
class MyComponent extends Component {
setup() {
this.objectService = useService("object_service");
this.functionService = useService("function_service");
}
}
MyComponent.template = xml`<div/>`;
serviceRegistry.add("object_service", {
name: "object_service",
async: ["asyncMethod"],
start() {
return {
async asyncMethod() {
nbCalls++;
await def;
return this;
},
};
},
});
serviceRegistry.add("function_service", {
name: "function_service",
async: true,
start() {
return async function asyncFunc() {
nbCalls++;
await def;
return this;
};
},
});
const env = await makeTestEnv();
const target = getFixture();
const comp = await mount(MyComponent, target, { env });
// Functions and methods have the correct this
def.resolve();
assert.deepEqual(await comp.objectService.asyncMethod(), comp.objectService);
assert.deepEqual(await comp.objectService.asyncMethod.call("boundThis"), "boundThis");
assert.deepEqual(await comp.functionService(), comp);
assert.deepEqual(await comp.functionService.call("boundThis"), "boundThis");
assert.strictEqual(nbCalls, 4);
// Functions that were called before the component is destroyed but resolved after never resolve
let nbResolvedProms = 0;
def = makeDeferred();
comp.objectService.asyncMethod().then(() => nbResolvedProms++);
comp.objectService.asyncMethod.call("boundThis").then(() => nbResolvedProms++);
comp.functionService().then(() => nbResolvedProms++);
comp.functionService.call("boundThis").then(() => nbResolvedProms++);
assert.strictEqual(nbCalls, 8);
comp.__owl__.app.destroy();
def.resolve();
await nextTick();
assert.strictEqual(
nbResolvedProms,
0,
"The promises returned by the calls should never resolve"
);
// Calling the functions after the destruction rejects the promise
assert.rejects(comp.objectService.asyncMethod(), "Component is destroyed");
assert.rejects(
comp.objectService.asyncMethod.call("boundThis"),
"Component is destroyed"
);
assert.rejects(comp.functionService(), "Component is destroyed");
assert.rejects(comp.functionService.call("boundThis"), "Component is destroyed");
assert.strictEqual(nbCalls, 8);
});
QUnit.module("useSpellCheck");
QUnit.test("useSpellCheck: ref is on the textarea", async function (assert) {
class MyComponent extends Component {
setup() {
useSpellCheck();
}
}
MyComponent.template = xml`<div><textarea t-ref="spellcheck" class="textArea"/></div>`;
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
const textArea = target.querySelector(".textArea");
assert.strictEqual(textArea.spellcheck, true, "by default, spellcheck is enabled");
textArea.focus();
textArea.blur();
assert.strictEqual(
textArea.spellcheck,
false,
"spellcheck is disabled once the element has lost its focus"
);
textArea.focus();
assert.strictEqual(
textArea.spellcheck,
true,
"spellcheck is re-enabled once the element is focused"
);
});
QUnit.test("useSpellCheck: use a different refName", async function (assert) {
class MyComponent extends Component {
setup() {
useSpellCheck({ refName: "myreference" });
}
}
MyComponent.template = xml`<div><textarea t-ref="myreference" class="textArea"/></div>`;
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
const textArea = target.querySelector(".textArea");
assert.strictEqual(textArea.spellcheck, true, "by default, spellcheck is enabled");
textArea.focus();
textArea.blur();
assert.strictEqual(
textArea.spellcheck,
false,
"spellcheck is disabled once the element has lost its focus"
);
textArea.focus();
assert.strictEqual(
textArea.spellcheck,
true,
"spellcheck is re-enabled once the element is focused"
);
});
QUnit.test(
"useSpellCheck: ref is on the root element and two editable elements",
async function (assert) {
class MyComponent extends Component {
setup() {
useSpellCheck();
}
}
MyComponent.template = xml`
<div t-ref="spellcheck">
<textarea class="textArea"/>
<div contenteditable="true" class="editableDiv"/>
</div>`;
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
const textArea = target.querySelector(".textArea");
const editableDiv = target.querySelector(".editableDiv");
assert.strictEqual(
textArea.spellcheck,
true,
"by default, spellcheck is enabled on the textarea"
);
assert.strictEqual(
editableDiv.spellcheck,
true,
"by default, spellcheck is enabled on the editable div"
);
textArea.focus();
textArea.blur();
editableDiv.focus();
assert.strictEqual(
textArea.spellcheck,
false,
"spellcheck is disabled once the element has lost its focus"
);
editableDiv.blur();
assert.strictEqual(
editableDiv.spellcheck,
false,
"spellcheck is disabled once the element has lost its focus"
);
textArea.focus();
assert.strictEqual(
textArea.spellcheck,
true,
"spellcheck is re-enabled once the element is focused"
);
assert.strictEqual(
editableDiv.spellcheck,
false,
"spellcheck is still disabled as it is not focused"
);
editableDiv.focus();
assert.strictEqual(
editableDiv.spellcheck,
true,
"spellcheck is re-enabled once the element is focused"
);
}
);
QUnit.test(
"useSpellCheck: ref is on the root element and one element has disabled the spellcheck",
async function (assert) {
class MyComponent extends Component {
setup() {
useSpellCheck();
}
}
MyComponent.template = xml`
<div t-ref="spellcheck">
<textarea class="textArea"/>
<div contenteditable="true" spellcheck="false" class="editableDiv"/>
</div>`;
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
const textArea = target.querySelector(".textArea");
const editableDiv = target.querySelector(".editableDiv");
assert.strictEqual(
textArea.spellcheck,
true,
"by default, spellcheck is enabled on the textarea"
);
assert.strictEqual(
editableDiv.spellcheck,
false,
"by default, spellcheck is disabled on the editable div"
);
textArea.focus();
textArea.blur();
editableDiv.focus();
assert.strictEqual(
textArea.spellcheck,
false,
"spellcheck is disabled once the element has lost its focus"
);
assert.strictEqual(
editableDiv.spellcheck,
false,
"spellcheck has not been enabled since it was disabled on purpose"
);
editableDiv.blur();
assert.strictEqual(
editableDiv.spellcheck,
false,
"spellcheck stays disabled once the element has lost its focus"
);
textArea.focus();
assert.strictEqual(
textArea.spellcheck,
true,
"spellcheck is re-enabled once the element is focused"
);
}
);
QUnit.test("ref is on an element with contenteditable attribute", async (assert) => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div t-ref="spellcheck" contenteditable="true" class="editableDiv" />`;
setup() {
useSpellCheck();
}
}
const env = await makeTestEnv();
const target = getFixture();
await mount(MyComponent, target, { env });
const editableDiv = target.querySelector(".editableDiv");
assert.strictEqual(editableDiv.spellcheck, true);
editableDiv.focus();
assert.strictEqual(editableDiv.spellcheck, true);
editableDiv.blur();
assert.strictEqual(editableDiv.spellcheck, false);
});
QUnit.module("useChildRef / useForwardRefToParent");
QUnit.test("simple usecase", async function (assert) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
/** @odoo-module **/
import { range } from "@web/core/utils/numbers";
QUnit.module("utils", () => {
QUnit.module("Numbers", () => {
QUnit.test("test range function from core/utils/numbers.js", (assert) => {
assert.deepEqual(range(0, 10), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
assert.deepEqual(range(0, -10, -1), [0, -1, -2, -3, -4, -5, -6, -7, -8, -9]);
assert.deepEqual(range(0, 35, 5), [0, 5, 10, 15, 20, 25, 30]);
assert.deepEqual(range(-10, 6, 2), [-10, -8, -6, -4, -2, 0, 2, 4]);
assert.deepEqual(range(4, -4, -1), [4, 3, 2, 1, 0, -1, -2, -3]);
});
});
});

View file

@ -0,0 +1,283 @@
/** @odoo-module **/
import { defaultLocalization } from "@web/../tests/helpers/mock_services";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { roundPrecision, roundDecimals, floatIsZero, formatFloat } from "@web/core/utils/numbers";
QUnit.module("utils", () => {
QUnit.module("numbers");
QUnit.test("roundPrecision", function (assert) {
assert.expect(26);
assert.strictEqual(String(roundPrecision(1.0, 1)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.1)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.01)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.001)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.0001)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.00001)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.000001)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.0000001)), "1");
assert.strictEqual(String(roundPrecision(1.0, 0.00000001)), "1");
assert.strictEqual(String(roundPrecision(0.5, 1)), "1");
assert.strictEqual(String(roundPrecision(-0.5, 1)), "-1");
assert.strictEqual(String(roundPrecision(2.6745, 0.001)), "2.6750000000000003");
assert.strictEqual(String(roundPrecision(-2.6745, 0.001)), "-2.6750000000000003");
assert.strictEqual(String(roundPrecision(2.6744, 0.001)), "2.674");
assert.strictEqual(String(roundPrecision(-2.6744, 0.001)), "-2.674");
assert.strictEqual(String(roundPrecision(0.0004, 0.001)), "0");
assert.strictEqual(String(roundPrecision(-0.0004, 0.001)), "0");
assert.strictEqual(String(roundPrecision(357.4555, 0.001)), "357.456");
assert.strictEqual(String(roundPrecision(-357.4555, 0.001)), "-357.456");
assert.strictEqual(String(roundPrecision(457.4554, 0.001)), "457.455");
assert.strictEqual(String(roundPrecision(-457.4554, 0.001)), "-457.455");
assert.strictEqual(String(roundPrecision(-457.4554, 0.05)), "-457.45000000000005");
assert.strictEqual(String(roundPrecision(457.444, 0.5)), "457.5");
assert.strictEqual(String(roundPrecision(457.3, 5)), "455");
assert.strictEqual(String(roundPrecision(457.5, 5)), "460");
assert.strictEqual(String(roundPrecision(457.1, 3)), "456");
});
QUnit.test("roundDecimals", function (assert) {
assert.expect(21);
assert.strictEqual(String(roundDecimals(1.0, 0)), "1");
assert.strictEqual(String(roundDecimals(1.0, 1)), "1");
assert.strictEqual(String(roundDecimals(1.0, 2)), "1");
assert.strictEqual(String(roundDecimals(1.0, 3)), "1");
assert.strictEqual(String(roundDecimals(1.0, 4)), "1");
assert.strictEqual(String(roundDecimals(1.0, 5)), "1");
assert.strictEqual(String(roundDecimals(1.0, 6)), "1");
assert.strictEqual(String(roundDecimals(1.0, 7)), "1");
assert.strictEqual(String(roundDecimals(1.0, 8)), "1");
assert.strictEqual(String(roundDecimals(0.5, 0)), "1");
assert.strictEqual(String(roundDecimals(-0.5, 0)), "-1");
assert.strictEqual(String(roundDecimals(2.6745, 3)), "2.6750000000000003");
assert.strictEqual(String(roundDecimals(-2.6745, 3)), "-2.6750000000000003");
assert.strictEqual(String(roundDecimals(2.6744, 3)), "2.674");
assert.strictEqual(String(roundDecimals(-2.6744, 3)), "-2.674");
assert.strictEqual(String(roundDecimals(0.0004, 3)), "0");
assert.strictEqual(String(roundDecimals(-0.0004, 3)), "0");
assert.strictEqual(String(roundDecimals(357.4555, 3)), "357.456");
assert.strictEqual(String(roundDecimals(-357.4555, 3)), "-357.456");
assert.strictEqual(String(roundDecimals(457.4554, 3)), "457.455");
assert.strictEqual(String(roundDecimals(-457.4554, 3)), "-457.455");
});
QUnit.test("floatIsZero", function (assert) {
assert.strictEqual(floatIsZero(1, 0), false);
assert.strictEqual(floatIsZero(0.9999, 0), false);
assert.strictEqual(floatIsZero(0.50001, 0), false);
assert.strictEqual(floatIsZero(0.5, 0), false);
assert.strictEqual(floatIsZero(0.49999, 0), true);
assert.strictEqual(floatIsZero(0, 0), true);
assert.strictEqual(floatIsZero(0.49999, 0), true);
assert.strictEqual(floatIsZero(-0.50001, 0), false);
assert.strictEqual(floatIsZero(-0.5, 0), false);
assert.strictEqual(floatIsZero(-0.9999, 0), false);
assert.strictEqual(floatIsZero(-1, 0), false);
assert.strictEqual(floatIsZero(0.1, 1), false);
assert.strictEqual(floatIsZero(0.099999, 1), false);
assert.strictEqual(floatIsZero(0.050001, 1), false);
assert.strictEqual(floatIsZero(0.05, 1), false);
assert.strictEqual(floatIsZero(0.049999, 1), true);
assert.strictEqual(floatIsZero(0, 1), true);
assert.strictEqual(floatIsZero(-0.049999, 1), true);
assert.strictEqual(floatIsZero(-0.05, 1), false);
assert.strictEqual(floatIsZero(-0.050001, 1), false);
assert.strictEqual(floatIsZero(-0.099999, 1), false);
assert.strictEqual(floatIsZero(-0.1, 1), false);
assert.strictEqual(floatIsZero(0.01, 2), false);
assert.strictEqual(floatIsZero(0.0099999, 2), false);
assert.strictEqual(floatIsZero(0.005, 2), false);
assert.strictEqual(floatIsZero(0.0050001, 2), false);
assert.strictEqual(floatIsZero(0.0049999, 2), true);
assert.strictEqual(floatIsZero(0, 2), true);
assert.strictEqual(floatIsZero(-0.0049999, 2), true);
assert.strictEqual(floatIsZero(-0.0050001, 2), false);
assert.strictEqual(floatIsZero(-0.005, 2), false);
assert.strictEqual(floatIsZero(-0.0099999, 2), false);
assert.strictEqual(floatIsZero(-0.01, 2), false);
// 4 and 5 decimal places are mentioned as special cases in `roundDecimals` method.
assert.strictEqual(floatIsZero(0.0001, 4), false);
assert.strictEqual(floatIsZero(0.000099999, 4), false);
assert.strictEqual(floatIsZero(0.00005, 4), false);
assert.strictEqual(floatIsZero(0.000050001, 4), false);
assert.strictEqual(floatIsZero(0.000049999, 4), true);
assert.strictEqual(floatIsZero(0, 4), true);
assert.strictEqual(floatIsZero(-0.000049999, 4), true);
assert.strictEqual(floatIsZero(-0.000050001, 4), false);
assert.strictEqual(floatIsZero(-0.00005, 4), false);
assert.strictEqual(floatIsZero(-0.000099999, 4), false);
assert.strictEqual(floatIsZero(-0.0001, 4), false);
assert.strictEqual(floatIsZero(0.00001, 5), false);
assert.strictEqual(floatIsZero(0.0000099999, 5), false);
assert.strictEqual(floatIsZero(0.000005, 5), false);
assert.strictEqual(floatIsZero(0.0000050001, 5), false);
assert.strictEqual(floatIsZero(0.0000049999, 5), true);
assert.strictEqual(floatIsZero(0, 5), true);
assert.strictEqual(floatIsZero(-0.0000049999, 5), true);
assert.strictEqual(floatIsZero(-0.0000050001, 5), false);
assert.strictEqual(floatIsZero(-0.000005, 5), false);
assert.strictEqual(floatIsZero(-0.0000099999, 5), false);
assert.strictEqual(floatIsZero(-0.00001, 5), false);
assert.strictEqual(floatIsZero(0.0000001, 7), false);
assert.strictEqual(floatIsZero(0.000000099999, 7), false);
assert.strictEqual(floatIsZero(0.00000005, 7), false);
assert.strictEqual(floatIsZero(0.000000050001, 7), false);
assert.strictEqual(floatIsZero(0.000000049999, 7), true);
assert.strictEqual(floatIsZero(0, 7), true);
assert.strictEqual(floatIsZero(-0.000000049999, 7), true);
assert.strictEqual(floatIsZero(-0.000000050001, 7), false);
assert.strictEqual(floatIsZero(-0.00000005, 7), false);
assert.strictEqual(floatIsZero(-0.000000099999, 7), false);
assert.strictEqual(floatIsZero(-0.0000001, 7), false);
});
});
QUnit.module("utils", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
});
QUnit.module("numbers");
QUnit.test("formatFloat", function (assert) {
assert.strictEqual(formatFloat(1000000), "1,000,000.00");
const options = { grouping: [3, 2, -1], decimalPoint: "?", thousandsSep: "€" };
assert.strictEqual(formatFloat(106500, options), "1€06€500?00");
assert.strictEqual(formatFloat(1500, { thousandsSep: "" }), "1500.00");
assert.strictEqual(formatFloat(-1.01), "-1.01");
assert.strictEqual(formatFloat(-0.01), "-0.01");
assert.strictEqual(formatFloat(38.0001, { trailingZeros: false }), "38");
assert.strictEqual(formatFloat(38.1, { trailingZeros: false }), "38.1");
assert.strictEqual(formatFloat(38.0001, { digits: [16, 0], trailingZeros: false }), "38");
patchWithCleanup(localization, { grouping: [3, 3, 3, 3] });
assert.strictEqual(formatFloat(1000000), "1,000,000.00");
patchWithCleanup(localization, { grouping: [3, 2, -1] });
assert.strictEqual(formatFloat(106500), "1,06,500.00");
patchWithCleanup(localization, { grouping: [1, 2, -1] });
assert.strictEqual(formatFloat(106500), "106,50,0.00");
patchWithCleanup(localization, {
grouping: [2, 0],
decimalPoint: "!",
thousandsSep: "@",
});
assert.strictEqual(formatFloat(6000), "60@00!00");
});
QUnit.test("formatFloat (humanReadable=true)", async (assert) => {
assert.strictEqual(
formatFloat(1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.02k"
);
assert.strictEqual(
formatFloat(1020000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"1,020k"
);
assert.strictEqual(
formatFloat(10200000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"10.20M"
);
assert.strictEqual(
formatFloat(1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.02k"
);
assert.strictEqual(
formatFloat(1002, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.00k"
);
assert.strictEqual(
formatFloat(101, { humanReadable: true, decimals: 2, minDigits: 1 }),
"101.00"
);
assert.strictEqual(
formatFloat(64.2, { humanReadable: true, decimals: 2, minDigits: 1 }),
"64.20"
);
assert.strictEqual(formatFloat(1e18, { humanReadable: true }), "1E");
assert.strictEqual(
formatFloat(1e21, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1e+21"
);
assert.strictEqual(
formatFloat(1.0045e22, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1e+22"
);
assert.strictEqual(
formatFloat(1.0045e22, { humanReadable: true, decimals: 3, minDigits: 1 }),
"1.005e+22"
);
assert.strictEqual(
formatFloat(1.012e43, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.01e+43"
);
assert.strictEqual(
formatFloat(1.012e43, { humanReadable: true, decimals: 2, minDigits: 2 }),
"1.01e+43"
);
assert.strictEqual(
formatFloat(-1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.02k"
);
assert.strictEqual(
formatFloat(-1020000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"-1,020k"
);
assert.strictEqual(
formatFloat(-10200000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"-10.20M"
);
assert.strictEqual(
formatFloat(-1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.02k"
);
assert.strictEqual(
formatFloat(-1002, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.00k"
);
assert.strictEqual(
formatFloat(-101, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-101.00"
);
assert.strictEqual(
formatFloat(-64.2, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-64.20"
);
assert.strictEqual(formatFloat(-1e18, { humanReadable: true }), "-1E");
assert.strictEqual(
formatFloat(-1e21, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1e+21"
);
assert.strictEqual(
formatFloat(-1.0045e22, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1e+22"
);
assert.strictEqual(
formatFloat(-1.0045e22, { humanReadable: true, decimals: 3, minDigits: 1 }),
"-1.004e+22"
);
assert.strictEqual(
formatFloat(-1.012e43, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.01e+43"
);
assert.strictEqual(
formatFloat(-1.012e43, { humanReadable: true, decimals: 2, minDigits: 2 }),
"-1.01e+43"
);
});
});

View file

@ -57,4 +57,16 @@ QUnit.module("utils", () => {
assert.ok(shallowEqual({ a: fn }, { a: fn }));
assert.notOk(shallowEqual({ a: () => {} }, { a: () => {} }));
});
QUnit.test("shallowEqual: custom comparison function", function (assert) {
const dateA = new Date();
const dateB = new Date(dateA);
assert.notOk(shallowEqual({ a: 1, date: dateA }, { a: 1, date: dateB }));
assert.ok(
shallowEqual({ a: 1, date: dateA }, { a: 1, date: dateB }, (a, b) =>
a instanceof Date ? Number(a) === Number(b) : a === b
)
);
});
});

View file

@ -1,7 +1,6 @@
/** @odoo-module **/
import { patch, unpatch } from "@web/core/utils/patch";
import legacyUtils from "web.utils";
import { patch } from "@web/core/utils/patch";
function makeBaseClass(assert, assertInSetup) {
class BaseClass {
@ -51,13 +50,13 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -72,24 +71,24 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch1", {
patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch1.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch1.fn");
},
});
patch(BaseClass.prototype, "patch2", {
patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch2.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch2.fn");
},
});
@ -105,31 +104,18 @@ QUnit.module("utils", () => {
]);
});
QUnit.test("two patches with same name on same base class", async function (assert) {
assert.expect(1);
const A = class {};
patch(A.prototype, "patch");
// keys should be unique
assert.throws(() => {
patch(A.prototype, "patch");
});
});
QUnit.test("unpatch", async function (assert) {
assert.expect(8);
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -137,7 +123,7 @@ QUnit.module("utils", () => {
new BaseClass().fn();
assert.verifySteps(["base.setup", "patch.setup", "base.fn", "patch.fn"]);
unpatch(BaseClass.prototype, "patch");
unpatch();
new BaseClass().fn();
@ -149,24 +135,24 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch1", {
const unpatch1 = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch1.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch1.fn");
},
});
patch(BaseClass.prototype, "patch2", {
const unpatch2 = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch2.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch2.fn");
},
});
@ -182,13 +168,13 @@ QUnit.module("utils", () => {
"patch2.fn",
]);
unpatch(BaseClass.prototype, "patch1");
unpatch1();
new BaseClass().fn();
assert.verifySteps(["base.setup", "patch2.setup", "base.fn", "patch2.fn"]);
unpatch(BaseClass.prototype, "patch2");
unpatch2();
new BaseClass().fn();
@ -202,24 +188,24 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch1", {
const unpatch1 = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch1.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch1.fn");
},
});
patch(BaseClass.prototype, "patch2", {
const unpatch2 = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch2.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch2.fn");
},
});
@ -235,13 +221,13 @@ QUnit.module("utils", () => {
"patch2.fn",
]);
unpatch(BaseClass.prototype, "patch1");
unpatch1();
new BaseClass().fn();
assert.verifySteps(["base.setup", "patch2.setup", "base.fn", "patch2.fn"]);
unpatch(BaseClass.prototype, "patch2");
unpatch2();
new BaseClass().fn();
@ -249,18 +235,6 @@ QUnit.module("utils", () => {
}
);
QUnit.test("unpatch twice the same patch name", async function (assert) {
assert.expect(1);
const A = class {};
patch(A.prototype, "patch");
unpatch(A.prototype, "patch");
assert.throws(() => {
unpatch(A.prototype, "patch");
});
});
QUnit.test("patch for specialization", async function (assert) {
assert.expect(1);
@ -275,9 +249,9 @@ QUnit.module("utils", () => {
}
};
patch(A.prototype, "patch", {
patch(A.prototype, {
setup() {
this._super("patch", ...arguments);
super.setup("patch", ...arguments);
},
});
@ -290,9 +264,9 @@ QUnit.module("utils", () => {
assert.expect(3);
const BaseClass = makeBaseClass(assert, false);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
setup() {
this._super(...arguments);
super.setup(...arguments);
this.str += "patch";
this.arr.push("patch");
@ -313,7 +287,7 @@ QUnit.module("utils", () => {
assert.notOk(new BaseClass().f);
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
f() {
assert.step("patch.f");
},
@ -322,7 +296,7 @@ QUnit.module("utils", () => {
new BaseClass().f();
assert.verifySteps(["patch.f"]);
unpatch(BaseClass.prototype, "patch");
unpatch();
assert.notOk(new BaseClass().f);
});
@ -335,9 +309,9 @@ QUnit.module("utils", () => {
BaseClass.staticFn();
assert.verifySteps(["base.staticFn"]);
patch(BaseClass, "patch", {
const unpatch = patch(BaseClass, {
staticFn() {
this._super();
super.staticFn();
assert.step("patch.staticFn");
},
});
@ -345,7 +319,7 @@ QUnit.module("utils", () => {
BaseClass.staticFn();
assert.verifySteps(["base.staticFn", "patch.staticFn"]);
unpatch(BaseClass, "patch");
unpatch();
BaseClass.staticFn();
assert.verifySteps(["base.staticFn"]);
@ -356,7 +330,7 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass, "patch", {
const unpatch = patch(BaseClass, {
staticStr: BaseClass.staticStr + "patch",
staticArr: [...BaseClass.staticArr, "patch"],
staticObj: { ...BaseClass.staticObj, patch: "patch" },
@ -366,7 +340,7 @@ QUnit.module("utils", () => {
assert.deepEqual(BaseClass.staticArr, ["base", "patch"]);
assert.deepEqual(BaseClass.staticObj, { base: "base", patch: "patch" });
unpatch(BaseClass, "patch");
unpatch();
assert.strictEqual(BaseClass.staticStr, "base");
assert.deepEqual(BaseClass.staticArr, ["base"]);
@ -379,14 +353,14 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
const instance = new BaseClass();
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
// will not be called
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -394,7 +368,7 @@ QUnit.module("utils", () => {
instance.fn();
assert.verifySteps(["base.setup", "base.fn", "patch.fn"]);
unpatch(BaseClass.prototype, "patch");
unpatch();
instance.fn();
assert.verifySteps(["base.fn"]);
@ -405,16 +379,16 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert, false);
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
get dynamic() {
return this._super() + "patch";
return super.dynamic + "patch";
},
});
const instance = new BaseClass();
assert.strictEqual(instance.dynamic, "basepatch");
unpatch(BaseClass.prototype, "patch");
unpatch();
assert.strictEqual(instance.dynamic, "base");
});
@ -423,9 +397,9 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert, false);
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
set dynamic(value) {
this._super("patch:" + value);
super.dynamic = "patch:" + value;
},
});
@ -435,7 +409,7 @@ QUnit.module("utils", () => {
instance.dynamic = "patch";
assert.strictEqual(instance.dynamic, "patch:patch");
unpatch(BaseClass.prototype, "patch");
unpatch();
instance.dynamic = "base";
assert.strictEqual(instance.dynamic, "base");
@ -450,7 +424,7 @@ QUnit.module("utils", () => {
BaseClass.prototype,
"dynamic"
);
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
dynamic: "patched",
});
@ -460,11 +434,11 @@ QUnit.module("utils", () => {
value: "patched",
writable: true,
configurable: true,
enumerable: true,
enumerable: false, // class properties are not enumerable
});
assert.equal(instance.dynamic, "patched");
unpatch(BaseClass.prototype, "patch");
unpatch();
instance.dynamic = "base";
assert.deepEqual(
@ -479,11 +453,10 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert, false);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
async asyncFn() {
const _super = this._super;
await Promise.resolve();
await _super(...arguments);
await super.asyncFn(...arguments);
assert.step("patch.asyncFn");
},
});
@ -500,20 +473,18 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert, false);
patch(BaseClass.prototype, "patch1", {
patch(BaseClass.prototype, {
async asyncFn() {
const _super = this._super;
await Promise.resolve();
await _super(...arguments);
await super.asyncFn(...arguments);
// also check this binding
assert.step(`patch1.${this.str}`);
},
});
patch(BaseClass.prototype, "patch2", {
patch(BaseClass.prototype, {
async asyncFn() {
const _super = this._super;
await Promise.resolve();
await _super(...arguments);
await super.asyncFn(...arguments);
// also check this binding
assert.step(`patch2.${this.str}`);
},
@ -526,6 +497,25 @@ QUnit.module("utils", () => {
assert.verifySteps(["base.asyncFn", "patch1.asyncFn", "patch2.asyncFn"]);
});
QUnit.test("call another super method", async function (assert) {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, {
setup() {
assert.step("patch.setup");
super.fn();
},
fn() {
assert.step("patch.fn"); // should not called
},
});
new BaseClass();
assert.verifySteps([
"patch.setup",
"base.fn",
]);
});
QUnit.module("inheritance");
QUnit.test("inherit a patched class (extends before patch)", async function (assert) {
@ -547,13 +537,13 @@ QUnit.module("utils", () => {
new Extension().fn();
assert.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -574,13 +564,13 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -627,13 +617,13 @@ QUnit.module("utils", () => {
new Extension().fn();
assert.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
patch(Extension.prototype, "patch", {
patch(Extension.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -654,13 +644,13 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -676,13 +666,13 @@ QUnit.module("utils", () => {
}
}
patch(Extension.prototype, "patch", {
patch(Extension.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.extension.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.extension.fn");
},
});
@ -700,6 +690,50 @@ QUnit.module("utils", () => {
]);
});
QUnit.test("patch an inherited patched class 2", async function (assert) {
assert.expect(7);
const BaseClass = makeBaseClass(assert);
class Extension extends BaseClass {
// nothing in the prototype
}
// First patch Extension
patch(Extension.prototype, {
setup() {
super.setup();
assert.step("patch.extension.setup");
},
fn() {
super.fn();
assert.step("patch.extension.fn");
},
});
// Then patch BaseClass
patch(BaseClass.prototype, {
setup() {
super.setup();
assert.step("patch.setup");
},
fn() {
super.fn();
assert.step("patch.fn");
},
});
new Extension().fn();
assert.verifySteps([
"base.setup",
"patch.setup",
"patch.extension.setup",
"base.fn",
"patch.fn",
"patch.extension.fn",
]);
});
QUnit.test("unpatch base class", async function (assert) {
assert.expect(12);
@ -716,13 +750,13 @@ QUnit.module("utils", () => {
}
}
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -737,7 +771,7 @@ QUnit.module("utils", () => {
"extension.fn",
]);
unpatch(BaseClass.prototype, "patch");
unpatch();
new Extension().fn();
assert.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
@ -759,13 +793,13 @@ QUnit.module("utils", () => {
}
}
patch(Extension.prototype, "patch", {
const unpatch = patch(Extension.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -780,7 +814,7 @@ QUnit.module("utils", () => {
"patch.fn",
]);
unpatch(Extension.prototype, "patch");
unpatch();
new Extension().fn();
assert.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
@ -793,13 +827,13 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch.BaseClass", {
const unpatchBase = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -815,13 +849,13 @@ QUnit.module("utils", () => {
}
}
patch(Extension.prototype, "patch.Extension", {
const unpatchExtension = patch(Extension.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.extension.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.extension.fn");
},
});
@ -838,7 +872,7 @@ QUnit.module("utils", () => {
"patch.extension.fn",
]);
unpatch(BaseClass.prototype, "patch.BaseClass");
unpatchBase();
new Extension().fn();
assert.verifySteps([
@ -850,7 +884,7 @@ QUnit.module("utils", () => {
"patch.extension.fn",
]);
unpatch(Extension.prototype, "patch.Extension");
unpatchExtension();
new Extension().fn();
assert.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
@ -864,13 +898,13 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass.prototype, "patch.BaseClass", {
const unpatchBase = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -886,13 +920,13 @@ QUnit.module("utils", () => {
}
}
patch(Extension.prototype, "patch.Extension", {
const unpatchExtension = patch(Extension.prototype, {
setup() {
this._super();
super.setup();
assert.step("patch.extension.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.extension.fn");
},
});
@ -909,7 +943,7 @@ QUnit.module("utils", () => {
"patch.extension.fn",
]);
unpatch(Extension.prototype, "patch.Extension");
unpatchExtension();
new Extension().fn();
assert.verifySteps([
@ -921,7 +955,7 @@ QUnit.module("utils", () => {
"extension.fn",
]);
unpatch(BaseClass.prototype, "patch.BaseClass");
unpatchBase();
new Extension().fn();
assert.verifySteps(["base.setup", "extension.setup", "base.fn", "extension.fn"]);
@ -940,16 +974,16 @@ QUnit.module("utils", () => {
}
}
patch(BaseClass, "patch.BaseClass", {
const unpatchBase = patch(BaseClass, {
staticFn() {
this._super();
super.staticFn();
assert.step("patch.staticFn");
},
});
patch(Extension, "patch.Extension", {
const unpatchExtension = patch(Extension, {
staticFn() {
this._super();
super.staticFn();
assert.step("patch.extension.staticFn");
},
});
@ -962,12 +996,12 @@ QUnit.module("utils", () => {
"patch.extension.staticFn",
]);
unpatch(BaseClass, "patch.BaseClass");
unpatchBase();
Extension.staticFn();
assert.verifySteps(["base.staticFn", "extension.staticFn", "patch.extension.staticFn"]);
unpatch(Extension, "patch.Extension");
unpatchExtension();
Extension.staticFn();
assert.verifySteps(["base.staticFn", "extension.staticFn"]);
@ -978,7 +1012,7 @@ QUnit.module("utils", () => {
const BaseClass = makeBaseClass(assert);
patch(BaseClass, "patch.BaseClass", {
const unpatch = patch(BaseClass, {
staticStr: BaseClass.staticStr + "patch",
staticArr: [...BaseClass.staticArr, "patch"],
staticObj: { ...BaseClass.staticObj, patch: "patch" },
@ -997,7 +1031,7 @@ QUnit.module("utils", () => {
extension: "extension",
});
unpatch(BaseClass, "patch.BaseClass");
unpatch();
// /!\ WARNING /!\
// If inherit comes after the patch then extension will still have
@ -1024,7 +1058,7 @@ QUnit.module("utils", () => {
// /!\ WARNING /!\
// If patch comes after the inherit then extension won't have
// the patched data.
patch(BaseClass, "patch.BaseClass", {
const unpatch = patch(BaseClass, {
staticStr: BaseClass.staticStr + "patch",
staticArr: [...BaseClass.staticArr, "patch"],
staticObj: { ...BaseClass.staticObj, patch: "patch" },
@ -1034,7 +1068,7 @@ QUnit.module("utils", () => {
assert.deepEqual(Extension.staticArr, ["base", "extension"]);
assert.deepEqual(Extension.staticObj, { base: "base", extension: "extension" });
unpatch(BaseClass, "patch.BaseClass");
unpatch();
assert.strictEqual(Extension.staticStr, "baseextension");
assert.deepEqual(Extension.staticArr, ["base", "extension"]);
@ -1058,14 +1092,14 @@ QUnit.module("utils", () => {
const instance = new Extension();
patch(BaseClass.prototype, "patch", {
const unpatch = patch(BaseClass.prototype, {
setup() {
this._super();
super.setup();
// will not be called
assert.step("patch.setup");
},
fn() {
this._super();
super.fn();
assert.step("patch.fn");
},
});
@ -1079,7 +1113,7 @@ QUnit.module("utils", () => {
"extension.fn",
]);
unpatch(BaseClass.prototype, "patch");
unpatch();
instance.fn();
assert.verifySteps(["base.fn", "extension.fn"]);
@ -1097,7 +1131,7 @@ QUnit.module("utils", () => {
assert.strictEqual(descriptor.configurable, true);
assert.strictEqual(descriptor.enumerable, false);
patch(BaseClass.prototype, "patch", {
patch(BaseClass.prototype, {
// getter declared in object are enumerable
get getter() {
return true;
@ -1121,10 +1155,10 @@ QUnit.module("utils", () => {
},
};
patch(obj, "patch", {
const unpatch = patch(obj, {
var: obj.var + "patch",
fn() {
this._super(...arguments);
super.fn(...arguments);
assert.step("patch");
},
});
@ -1134,7 +1168,7 @@ QUnit.module("utils", () => {
obj.fn();
assert.verifySteps(["obj", "patch"]);
unpatch(obj, "patch");
unpatch();
assert.strictEqual(obj.var, "obj");
@ -1153,7 +1187,7 @@ QUnit.module("utils", () => {
};
const originalFn = obj.fn;
patch(obj, "patch", {
patch(obj, {
fn() {
assert.step("patched");
originalFn();
@ -1165,41 +1199,5 @@ QUnit.module("utils", () => {
assert.verifySteps(["patched", "original"]);
});
QUnit.test("patch an object with a legacy patch", async function (assert) {
const a = {
doSomething() {
assert.step("a");
},
};
legacyUtils.patch(a, "a.patch.legacy", {
doSomething() {
this._super();
assert.step("a.patch.legacy");
},
});
patch(a, "a.patch", {
doSomething() {
this._super();
assert.step("a.patch");
},
});
a.doSomething();
assert.verifySteps(["a", "a.patch.legacy", "a.patch"]);
});
QUnit.module("patch 'pure' option");
QUnit.test("function objects are preserved with 'pure' patch", async function (assert) {
const obj1 = { a: () => {} };
const obj2 = { a: () => {} };
function someValue() {}
patch(obj1, "patch1", { a: someValue });
assert.notStrictEqual(obj1.a, someValue);
patch(obj2, "patch2", { a: someValue }, { pure: true });
assert.strictEqual(obj2.a, someValue);
});
});
});

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { renderToElement, renderToString } from "@web/core/utils/render";
QUnit.module("utils", () => {
QUnit.module("render");
QUnit.test("renderToElement always returns an element", (assert) => {
renderToString.app.addTemplate(
"test.render.template.1",
`<t t-if="False">
<div>NotOk</div>
</t>
<t t-else="">
<div>Ok</div>
</t>`
);
const compiledTemplate = renderToElement("test.render.template.1");
assert.strictEqual(
compiledTemplate.parentElement,
null,
"compiledTemplate.parentElement must be empty"
);
assert.strictEqual(
compiledTemplate.nodeType,
Node.ELEMENT_NODE,
"compiledTemplate must be an element"
);
assert.strictEqual(compiledTemplate.outerHTML, "<div>Ok</div>");
});
});

View file

@ -4,25 +4,22 @@ import {
drag,
dragAndDrop,
getFixture,
makeDeferred,
mockAnimationFrame,
mount,
nextTick,
patchWithCleanup,
triggerHotkey
} from "@web/../tests/helpers/utils";
import { browser } from "@web/core/browser/browser";
import { useSortable } from "@web/core/utils/sortable";
import { useSortable } from "@web/core/utils/sortable_owl";
import { Component, reactive, useRef, useState, xml } from "@odoo/owl";
let target;
QUnit.module("UI", ({ beforeEach }) => {
QUnit.module("Draggable", ({ beforeEach }) => {
beforeEach(() => (target = getFixture()));
QUnit.module("Sortable hook");
QUnit.test("Parameters error handling", async (assert) => {
assert.expect(8);
assert.expect(6);
const mountListAndAssert = async (setupList, shouldThrow) => {
class List extends Component {
@ -51,11 +48,6 @@ QUnit.module("UI", ({ beforeEach }) => {
await mountListAndAssert(() => {
useSortable({});
}, true);
await mountListAndAssert(() => {
useSortable({
ref: useRef("root"),
});
}, true);
await mountListAndAssert(() => {
useSortable({
elements: ".item",
@ -67,20 +59,13 @@ QUnit.module("UI", ({ beforeEach }) => {
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: useRef("root"),
});
}, false);
await mountListAndAssert(() => {
useSortable({
ref: {},
@ -98,7 +83,7 @@ QUnit.module("UI", ({ beforeEach }) => {
});
QUnit.test("Simple sorting in single group", async (assert) => {
assert.expect(19);
assert.expect(22);
class List extends Component {
setup() {
@ -115,7 +100,7 @@ QUnit.module("UI", ({ beforeEach }) => {
assert.strictEqual(element.innerText, "2");
},
onDragEnd({ element, group }) {
assert.step("stop");
assert.step("end");
assert.notOk(group);
assert.strictEqual(element.innerText, "1");
assert.containsN(target, ".item", 4);
@ -142,13 +127,20 @@ QUnit.module("UI", ({ beforeEach }) => {
await mount(List, target);
assert.containsN(target, ".item", 3);
assert.containsNone(target, ".o_dragged");
assert.verifySteps([]);
// First item after 2nd item
await dragAndDrop(".item:first-child", ".item:nth-child(2)");
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
assert.hasClass(target.querySelector(".item"), "o_dragged");
await drop();
assert.containsN(target, ".item", 3);
assert.verifySteps(["start", "elemententer", "stop", "drop"]);
assert.containsNone(target, ".o_dragged");
assert.verifySteps(["start", "elemententer", "drop", "end"]);
});
QUnit.test("Simple sorting in multiple groups", async (assert) => {
@ -171,7 +163,7 @@ QUnit.module("UI", ({ beforeEach }) => {
assert.hasClass(group, "list1");
},
onDragEnd({ element, group }) {
assert.step("stop");
assert.step("end");
assert.hasClass(group, "list2");
assert.strictEqual(element.innerText, "2 1");
},
@ -205,35 +197,11 @@ QUnit.module("UI", ({ beforeEach }) => {
assert.containsN(target, ".list", 3);
assert.containsN(target, ".item", 9);
assert.verifySteps(["start", "groupenter", "stop", "drop"]);
assert.verifySteps(["start", "groupenter", "drop", "end"]);
});
QUnit.test("Sorting in groups with distinct per-axis scrolling", async (assert) => {
const nextAnimationFrame = async (timeDelta) => {
timeStamp += timeDelta;
animationFrameDef.resolve();
animationFrameDef = makeDeferred();
await Promise.resolve();
};
let animationFrameDef = makeDeferred();
let timeStamp = 0;
let handlers = new Set();
patchWithCleanup(browser, {
async requestAnimationFrame(handler) {
await animationFrameDef;
// Prevent setRecurringAnimationFrame from being recursive
// for better test control (only the first iteration/movement
// is needed to check that the scrolling works).
if (!handlers.has(handler)) {
handler(timeStamp);
handlers.add(handler);
}
},
performance: { now: () => timeStamp },
});
const { advanceFrame } = mockAnimationFrame();
class List extends Component {
setup() {
useSortable({
@ -272,15 +240,15 @@ QUnit.module("UI", ({ beforeEach }) => {
const assertScrolling = (top, left) => {
assert.strictEqual(scrollParentY.scrollTop, top);
assert.strictEqual(scrollParentX.scrollLeft, left);
}
const cancelDrag = async () => {
triggerHotkey("Escape");
};
const cancelDrag = async (cancel) => {
await cancel();
await nextTick();
scrollParentY.scrollTop = 0;
scrollParentX.scrollLeft = 0;
await nextTick();
assert.containsNone(target, ".o_dragged");
}
};
assert.containsNone(target, ".o_dragged");
// Negative horizontal scrolling.
@ -288,37 +256,110 @@ QUnit.module("UI", ({ beforeEach }) => {
scrollParentX.scrollLeft = 16;
await nextTick();
assertScrolling(50, 16);
await drag(".item12", ".item11", "left");
await nextAnimationFrame(16);
let dragHelpers = await drag(".item12");
await dragHelpers.moveTo(".item11", "left");
await advanceFrame();
assertScrolling(50, 0);
await cancelDrag();
await cancelDrag(dragHelpers.cancel);
// Positive horizontal scrolling.
target.querySelector(".spacer_horizontal").scrollIntoView();
await nextTick();
assertScrolling(50, 0);
await drag(".item11", ".item12", "right");
await nextAnimationFrame(16);
dragHelpers = await drag(".item11");
await dragHelpers.moveTo(".item12", "right");
await advanceFrame();
assertScrolling(50, 16);
await cancelDrag();
await cancelDrag(dragHelpers.cancel);
// Negative vertical scrolling.
target.querySelector(".root").scrollIntoView();
await nextTick();
assertScrolling(100, 0);
await drag(".item11", ".item11", "top");
await nextAnimationFrame(16);
dragHelpers = await drag(".item11");
await dragHelpers.moveTo(".item11", "top");
await advanceFrame();
assertScrolling(84, 0);
await cancelDrag();
await cancelDrag(dragHelpers.cancel);
// Positive vertical scrolling.
target.querySelector(".spacer_before").scrollIntoView();
await nextTick();
assertScrolling(0, 0);
await drag(".item21", ".item21", "bottom");
await nextAnimationFrame(16);
dragHelpers = await drag(".item21");
await dragHelpers.moveTo(".item21", "bottom");
await advanceFrame();
assertScrolling(16, 0);
await cancelDrag();
await cancelDrag(dragHelpers.cancel);
});
QUnit.test("draggable area contains overflowing visible elements", async (assert) => {
const { advanceFrame } = mockAnimationFrame();
class List extends Component {
setup() {
useSortable({
ref: useRef("renderer"),
elements: ".item",
groups: ".list",
connectGroups: true,
});
}
}
List.template = xml`
<div class="controller" style="max-width: 900px; min-width: 900px;">
<div class="content" style="max-width: 600px;">
<div t-ref="renderer" class="renderer d-flex" style="overflow: visible;">
<div t-foreach="[1, 2, 3]" t-as="c" t-key="c" t-attf-class="list m-0 list{{ c }}">
<div style="min-width: 300px; min-height: 50px;"
t-foreach="[1, 2, 3]" t-as="l" t-key="l" t-esc="'item' + l + '' + c" t-attf-class="item item{{ l + '' + c }}"/>
</div>
</div>
</div>
</div>
`;
await mount(List, target);
const controller = target.querySelector(".controller");
const content = target.querySelector(".content");
const renderer = target.querySelector(".renderer");
assert.strictEqual(content.scrollLeft, 0);
assert.strictEqual(controller.getBoundingClientRect().width, 900);
assert.strictEqual(content.getBoundingClientRect().width, 600);
assert.strictEqual(renderer.getBoundingClientRect().width, 600);
assert.strictEqual(renderer.scrollWidth, 900);
assert.containsNone(target, ".item.o_dragged");
const dragHelpers = await drag(".item11");
// Drag first record of first group to the right
await dragHelpers.moveTo(".list3 .item");
// Next frame (normal time delta)
await advanceFrame();
// Verify that there is no scrolling
assert.strictEqual(content.scrollLeft, 0);
assert.containsOnce(target, ".item.o_dragged");
const dragged = target.querySelector(".item.o_dragged");
const sibling = target.querySelector(".list3 .item");
// Verify that the dragged element is allowed to go inside the
// overflowing part of the draggable container.
assert.strictEqual(
dragged.getBoundingClientRect().right,
900 + target.getBoundingClientRect().x
);
assert.strictEqual(
sibling.getBoundingClientRect().right,
900 + target.getBoundingClientRect().x
);
// Cancel drag: press "Escape"
await dragHelpers.cancel();
await nextTick();
assert.containsNone(target, ".item.o_dragged");
});
QUnit.test("Dynamically disable sortable feature", async (assert) => {
@ -366,38 +407,6 @@ QUnit.module("UI", ({ beforeEach }) => {
assert.verifySteps([]);
});
QUnit.test("Disabled in small environment", async (assert) => {
assert.expect(2);
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
onDragStart() {
throw new Error("Shouldn't start the sortable feature.");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
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");
});
QUnit.test(
"Drag has a default tolerance of 10 pixels before initiating the dragging",
async (assert) => {
@ -409,7 +418,7 @@ QUnit.module("UI", ({ beforeEach }) => {
ref: useRef("root"),
elements: ".item",
onDragStart() {
assert.step("Initation of the drag sequence");
assert.step("Initiation of the drag sequence");
},
});
}
@ -425,13 +434,20 @@ QUnit.module("UI", ({ beforeEach }) => {
await mount(List, target);
// Move the element from only 5 pixels
await dragAndDrop(".item:first-child", ".item:first-child", { x: 5, y: 5 });
const listItem = target.querySelector(".item:first-child");
await dragAndDrop(listItem, listItem, {
x: listItem.getBoundingClientRect().width / 2,
y: listItem.getBoundingClientRect().height / 2 + 5,
});
assert.verifySteps([], "No drag sequence should have been initiated");
// Move the element from more than 10 pixels
await dragAndDrop(".item:first-child", ".item:first-child", { x: 10, y: 10 });
await dragAndDrop(".item:first-child", ".item:first-child", {
x: listItem.getBoundingClientRect().width / 2 + 10,
y: listItem.getBoundingClientRect().height / 2 + 10,
});
assert.verifySteps(
["Initation of the drag sequence"],
["Initiation of the drag sequence"],
"A drag sequence should have been initiated"
);
}
@ -482,4 +498,115 @@ QUnit.module("UI", ({ beforeEach }) => {
assert.verifySteps([]);
});
QUnit.test("the classes parameters (placeholderElement, helpElement)", async (assert) => {
assert.expect(7);
let dragElement;
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
placeholderClasses: ["placeholder-t1", "placeholder-t2"],
followingElementClasses: ["add-1", "add-2"],
onDragStart({ element }) {
dragElement = element;
assert.hasClass(dragElement, "add-1");
assert.hasClass(dragElement, "add-2");
// the placeholder is added in onDragStart after the current element
const children = [...dragElement.parentElement.children];
const placeholder = children[children.indexOf(dragElement) + 1];
assert.hasClass(placeholder, "placeholder-t1");
assert.hasClass(placeholder, "placeholder-t2");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
await drop();
assert.doesNotHaveClass(dragElement, "add-1");
assert.doesNotHaveClass(dragElement, "add-2");
assert.containsNone(target, ".item.placeholder-t1.placeholder-t2");
});
QUnit.test("applyChangeOnDrop option", async (assert) => {
assert.expect(2);
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
placeholderClasses: ["placeholder"],
applyChangeOnDrop: true,
onDragStart({ element }) {
const items = [...target.querySelectorAll(".item:not(.placeholder)")];
assert.strictEqual(items.map((el) => el.innerText).toString(), "1,2,3");
},
onDragEnd({ element, group }) {
const items = [...target.querySelectorAll(".item:not(.placeholder)")];
assert.strictEqual(items.map((el) => el.innerText).toString(), "2,1,3");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
await drop();
});
QUnit.test("clone option", async (assert) => {
assert.expect(2);
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
placeholderClasses: ["placeholder"],
clone: false,
onDragStart({ element }) {
assert.containsOnce(target, ".placeholder:not(.item)");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
await drop();
assert.containsNone(target, ".placeholder:not(.item)");
});
});

View file

@ -1,7 +1,14 @@
/** @odoo-module **/
import { escapeRegExp, intersperse, sprintf } from "@web/core/utils/strings";
import { _lt, translatedTerms } from "@web/core/l10n/translation";
import {
escape,
escapeRegExp,
intersperse,
isEmail,
sprintf,
unaccent,
} from "@web/core/utils/strings";
import { _t, translatedTerms } from "@web/core/l10n/translation";
import { patchWithCleanup } from "../../helpers/utils";
QUnit.module("utils", () => {
@ -52,6 +59,13 @@ QUnit.module("utils", () => {
assert.deepEqual(intersperse("12345678", [3, 0], "."), "12.345.678");
});
QUnit.test("unaccent", function (assert) {
assert.strictEqual(unaccent("éèàôù"), "eeaou");
assert.strictEqual(unaccent("ⱮɀꝾƶⱵȥ"), "mzgzhz"); // single characters
assert.strictEqual(unaccent("DZDŽꝎꜩꝡƕ"), "dzdzootzvyhv"); // doubled characters
assert.strictEqual(unaccent("ⱮɀꝾƶⱵȥ", true), "MzGzHz"); // case sensitive characters
});
QUnit.test("sprintf properly formats strings", (assert) => {
assert.deepEqual(sprintf("Hello %s!", "ged"), "Hello ged!");
assert.deepEqual(sprintf("Hello %s and %s!", "ged", "lpe"), "Hello ged and lpe!");
@ -88,13 +102,39 @@ QUnit.module("utils", () => {
two: "två",
});
assert.deepEqual(sprintf("Hello %s", _lt("one")), "Hello en");
assert.deepEqual(sprintf("Hello %s %s", _lt("one"), _lt("two")), "Hello en två");
assert.deepEqual(sprintf("Hello %s", _t("one")), "Hello en");
assert.deepEqual(sprintf("Hello %s %s", _t("one"), _t("two")), "Hello en två");
const vals = {
one: _lt("one"),
two: _lt("two"),
one: _t("one"),
two: _t("two"),
};
assert.deepEqual(sprintf("Hello %(two)s %(one)s", vals), "Hello två en");
});
QUnit.test("escape", (assert) => {
assert.strictEqual(escape("<a>this is a link</a>"), "&lt;a&gt;this is a link&lt;/a&gt;");
assert.strictEqual(
escape(`<a href="https://www.odoo.com">odoo<a>`),
`&lt;a href=&quot;https://www.odoo.com&quot;&gt;odoo&lt;a&gt;`
);
assert.strictEqual(
escape(`<a href='https://www.odoo.com'>odoo<a>`),
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;odoo&lt;a&gt;`
);
assert.strictEqual(
escape("<a href='https://www.odoo.com'>Odoo`s website<a>"),
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;Odoo&#x60;s website&lt;a&gt;`
);
});
QUnit.test("isEmail", (assert) => {
assert.notOk(isEmail(""));
assert.notOk(isEmail("test"));
assert.notOk(isEmail("test@odoo"));
assert.notOk(isEmail("test@odoo@odoo.com"));
assert.notOk(isEmail("te st@odoo.com"));
assert.ok(isEmail("test@odoo.com"));
});
});

View file

@ -1,163 +1,549 @@
/** @odoo-module **/
import { Component, xml } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { debounce, throttleForAnimation } from "@web/core/utils/timing";
import {
batched,
debounce,
throttleForAnimation,
useDebounced,
useThrottleForAnimation,
} from "@web/core/utils/timing";
import {
makeDeferred,
patchWithCleanup,
mockTimeout,
mockAnimationFrame,
mount,
getFixture,
click,
destroy,
} from "../../helpers/utils";
function nextMicroTick() {
return Promise.resolve();
}
function nextAnimationFrame() {
return new Promise((resolve) => window.requestAnimationFrame(resolve));
}
function nextSetTimeout() {
return new Promise(setTimeout);
}
QUnit.module("utils", () => {
QUnit.module("timing");
QUnit.module("timing", () => {
QUnit.test("debounce on an async function", async function (assert) {
let callback;
patchWithCleanup(browser, {
setTimeout: (later) => {
callback = later;
},
});
const imSearchDef = makeDeferred();
const myFunc = () => {
assert.step("myFunc");
return imSearchDef;
};
const myDebouncedFunc = debounce(myFunc, 3000);
myDebouncedFunc().then(() => {
throw new Error("Should never be resolved");
});
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps([]);
callback();
assert.verifySteps(["myFunc"]);
imSearchDef.resolve(42);
await Promise.resolve(); // wait for promise returned by myFunc
await Promise.resolve(); // wait for promise returned by debounce
QUnit.test("debounce on an async function", async function (assert) {
let callback;
patchWithCleanup(browser, {
setTimeout: (later) => {
callback = later;
},
});
const imSearchDef = makeDeferred();
const myFunc = () => {
assert.step("myFunc");
return imSearchDef;
};
const myDebouncedFunc = debounce(myFunc, 3000);
myDebouncedFunc().then(() => {
throw new Error("Should never be resolved");
});
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps([]);
callback();
assert.verifySteps(["myFunc"]);
imSearchDef.resolve(42);
await Promise.resolve(); // wait for promise returned by myFunc
await Promise.resolve(); // wait for promise returned by debounce
assert.verifySteps(["resolved 42"]);
});
QUnit.test("debounce on a sync function", async function (assert) {
let callback;
patchWithCleanup(browser, {
setTimeout: (later) => {
callback = later;
},
});
const myFunc = () => {
assert.step("myFunc");
return 42;
};
const myDebouncedFunc = debounce(myFunc, 3000);
myDebouncedFunc().then(() => {
throw new Error("Should never be resolved");
});
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps([]);
callback();
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by myFunc
await Promise.resolve(); // wait for promise returned by debounce
assert.verifySteps(["resolved 42"]);
});
QUnit.test("debounce with immediate", async function (assert) {
const { execRegisteredTimeouts } = mockTimeout();
const myFunc = () => {
assert.step("myFunc");
return 42;
};
const myDebouncedFunc = debounce(myFunc, 3000, { immediate: true });
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by debounce
await Promise.resolve(); // wait for promise returned chained onto it (step resolved x)
assert.verifySteps(["resolved 42"]);
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
await execRegisteredTimeouts();
assert.verifySteps([]); // not called 3000ms did not elapse between the previous call and the first
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by debounce
await Promise.resolve(); // wait for promise returned chained onto it (step resolved x)
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();
const myFunc = () => {
assert.step("myFunc");
};
const myDebouncedFunc = debounce(myFunc, 3000);
myDebouncedFunc();
myDebouncedFunc.cancel();
execRegisteredTimeouts();
assert.verifySteps([], "Debounced call was cancelled");
myDebouncedFunc();
execRegisteredTimeouts();
assert.verifySteps(["myFunc"], "Debounced call was not cancelled");
});
QUnit.test("throttleForAnimation", async (assert) => {
assert.expect(4);
const execAnimationFrameCallbacks = mockAnimationFrame();
const throttledFn = throttleForAnimation((val) => {
assert.step(`throttled function called with ${val}`);
assert.verifySteps(["resolved 42"]);
});
throttledFn(0);
throttledFn(1);
assert.verifySteps([], "throttled function hasn't been called yet");
execAnimationFrameCallbacks();
assert.verifySteps(
["throttled function called with 1"],
"only the last queued call was executed"
);
throttledFn(2);
throttledFn(3);
throttledFn.cancel();
execAnimationFrameCallbacks();
assert.verifySteps([], "queued throttled function calls were cancelled correctly");
QUnit.test("debounce on a sync function", async function (assert) {
let callback;
patchWithCleanup(browser, {
setTimeout: (later) => {
callback = later;
},
});
const myFunc = () => {
assert.step("myFunc");
return 42;
};
const myDebouncedFunc = debounce(myFunc, 3000);
myDebouncedFunc().then(() => {
throw new Error("Should never be resolved");
});
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps([]);
callback();
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by myFunc
await Promise.resolve(); // wait for promise returned by debounce
assert.verifySteps(["resolved 42"]);
});
QUnit.test("debounce with immediate", async function (assert) {
const { execRegisteredTimeouts } = mockTimeout();
const myFunc = () => {
assert.step("myFunc");
return 42;
};
const myDebouncedFunc = debounce(myFunc, 3000, true);
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by debounce
await Promise.resolve(); // wait for promise returned chained onto it (step resolved x)
assert.verifySteps(["resolved 42"]);
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
await execRegisteredTimeouts();
assert.verifySteps([]); // not called 3000ms did not elapse between the previous call and the first
myDebouncedFunc().then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by debounce
await Promise.resolve(); // wait for promise returned chained onto it (step resolved x)
assert.verifySteps(["resolved 42"]);
});
QUnit.test("debounce with leading and trailing", async function (assert) {
const { execRegisteredTimeouts } = mockTimeout();
const myFunc = (lastValue) => {
assert.step("myFunc");
return lastValue;
};
const myDebouncedFunc = debounce(myFunc, 3000, { leading: true, trailing: true });
myDebouncedFunc(42).then((x) => {
assert.step("resolved " + x);
});
myDebouncedFunc(43).then((x) => {
assert.step("resolved " + x);
});
myDebouncedFunc(44).then((x) => {
assert.step("resolved " + x);
});
assert.verifySteps(["myFunc"]);
await Promise.resolve(); // wait for promise returned by debounce
await Promise.resolve(); // wait for promise returned chained onto it (step resolved x)
assert.verifySteps(["resolved 42"]);
await execRegisteredTimeouts();
await Promise.resolve(); // wait for the inner promise
assert.verifySteps(["myFunc", "resolved 44"]);
});
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();
const myFunc = () => {
assert.step("myFunc");
};
const myDebouncedFunc = debounce(myFunc, 3000);
myDebouncedFunc();
myDebouncedFunc.cancel();
execRegisteredTimeouts();
assert.verifySteps([], "Debounced call was cancelled");
myDebouncedFunc();
execRegisteredTimeouts();
assert.verifySteps(["myFunc"], "Debounced call was not cancelled");
});
QUnit.test("throttleForAnimation", async (assert) => {
const { advanceFrame, execRegisteredAnimationFrames } = mockAnimationFrame();
const throttledFn = throttleForAnimation((val) => {
assert.step(`${val}`);
});
// A single call is executed immediately
throttledFn(1);
assert.verifySteps(["1"], "has been called on the leading edge");
execRegisteredAnimationFrames();
assert.verifySteps([], "has not been called");
// Successive calls
throttledFn(1);
assert.verifySteps(["1"], "has been called on the leading edge");
throttledFn(2);
throttledFn(3);
assert.verifySteps([], "has not been called");
execRegisteredAnimationFrames();
assert.verifySteps(["3"], "only the last queued call was executed");
// Can be cancelled
throttledFn(1);
assert.verifySteps(["1"], "has been called on the leading edge");
throttledFn(2);
throttledFn(3);
throttledFn.cancel();
execRegisteredAnimationFrames();
assert.verifySteps([], "queued throttled function calls were cancelled correctly");
// Successive calls: more precise timing case
throttledFn(1);
assert.verifySteps(["1"], "has been called on the leading edge");
await advanceFrame();
throttledFn(2);
assert.verifySteps(["2"], "has been called on the leading edge");
throttledFn(3);
throttledFn(4);
await advanceFrame();
assert.verifySteps(["4"], "last call is executed on the trailing edge");
execRegisteredAnimationFrames();
assert.verifySteps([], "has not been called");
});
QUnit.module("hooks", () => {
QUnit.test("useDebounced: cancels on comp destroy", async function (assert) {
const { advanceTime } = mockTimeout();
class C extends Component {
static template = xml`<button class="c" t-on-click="debounced">C</button>`;
setup() {
this.debounced = useDebounced(() => assert.step("debounced"), 1000);
}
}
const fixture = getFixture();
const comp = await mount(C, fixture);
assert.verifySteps([]);
assert.containsOnce(fixture, "button.c");
await click(fixture, "button.c");
await advanceTime(999);
assert.verifySteps([]);
await advanceTime(1);
assert.verifySteps(["debounced"]);
await click(fixture, "button.c");
await advanceTime(999);
assert.verifySteps([]);
destroy(comp);
await advanceTime(1);
assert.verifySteps([]);
});
QUnit.test(
"useDebounced with execBeforeUnmount option (callback not resolved before comp destroy)",
async function (assert) {
const { advanceTime } = mockTimeout();
class C extends Component {
static template = xml`<button class="c" t-on-click="() => this.debounced('hello')">C</button>`;
setup() {
this.debounced = useDebounced(
(p) => assert.step(`debounced: ${p}`),
1000,
{
execBeforeUnmount: true,
}
);
}
}
const fixture = getFixture();
const comp = await mount(C, fixture);
assert.verifySteps([]);
assert.containsOnce(fixture, "button.c");
await click(fixture, "button.c");
await advanceTime(999);
assert.verifySteps([]);
await advanceTime(1);
assert.verifySteps(["debounced: hello"]);
await click(fixture, "button.c");
await advanceTime(999);
assert.verifySteps([]);
destroy(comp);
assert.verifySteps(["debounced: hello"]);
}
);
QUnit.test(
"useDebounced with execBeforeUnmount option (callback resolved before comp destroy)",
async function (assert) {
const { advanceTime } = mockTimeout();
class C extends Component {
static template = xml`<button class="c" t-on-click="debounced">C</button>`;
setup() {
this.debounced = useDebounced(() => assert.step("debounced"), 1000, {
execBeforeUnmount: true,
});
}
}
const fixture = getFixture();
const comp = await mount(C, fixture);
assert.verifySteps([]);
assert.containsOnce(fixture, "button.c");
await click(fixture, "button.c");
await advanceTime(999);
assert.verifySteps([]);
await advanceTime(1);
assert.verifySteps(["debounced"]);
destroy(comp);
await advanceTime(1);
assert.verifySteps([]);
}
);
QUnit.test("useThrottleForAnimation: cancels on comp destroy", async function (assert) {
const { advanceFrame, execRegisteredAnimationFrames } = mockAnimationFrame();
class C extends Component {
static template = xml`<button class="c" t-on-click="throttled">C</button>`;
setup() {
this.throttled = useThrottleForAnimation(
() => assert.step("throttled"),
1000
);
}
}
const fixture = getFixture();
const comp = await mount(C, fixture);
assert.verifySteps([]);
assert.containsOnce(fixture, "button.c");
// Without destroy
await click(fixture, "button.c");
assert.verifySteps(["throttled"]);
await click(fixture, "button.c");
assert.verifySteps([]);
await advanceFrame();
assert.verifySteps(["throttled"]);
// Clean restart
execRegisteredAnimationFrames();
assert.verifySteps([]);
// With destroy
await click(fixture, "button.c");
assert.verifySteps(["throttled"]);
await click(fixture, "button.c");
assert.verifySteps([]);
destroy(comp);
await advanceFrame();
assert.verifySteps([]);
});
});
QUnit.module("batched", () => {
QUnit.test("callback is called only once after operations", async (assert) => {
let n = 0;
const fn = batched(() => n++);
assert.strictEqual(n, 0);
fn();
fn();
assert.strictEqual(n, 0);
await nextMicroTick();
assert.strictEqual(n, 1);
await nextMicroTick();
assert.strictEqual(n, 1);
});
QUnit.test(
"calling batched function from within the callback is not treated as part of the original batch",
async (assert) => {
let n = 0;
const fn = batched(() => {
n++;
if (n === 1) {
fn();
}
});
assert.strictEqual(n, 0);
fn();
assert.strictEqual(n, 0);
await nextMicroTick(); // First batch
assert.strictEqual(n, 1);
await nextMicroTick(); // Second batch initiated from within the callback
assert.strictEqual(n, 2);
await nextMicroTick();
assert.strictEqual(n, 2);
}
);
QUnit.test("callback is called twice", async (assert) => {
let n = 0;
const fn = batched(() => n++);
assert.strictEqual(n, 0);
fn();
assert.strictEqual(n, 0);
await nextMicroTick();
assert.strictEqual(n, 1);
fn();
assert.strictEqual(n, 1);
await nextMicroTick();
assert.strictEqual(n, 2);
});
QUnit.test(
"callback is called only once after operations (synchronize at nextAnimationFrame)",
async (assert) => {
let n = 0;
const fn = batched(
() => n++,
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
);
assert.strictEqual(n, 0);
fn();
fn();
assert.strictEqual(n, 0);
await nextAnimationFrame();
assert.strictEqual(n, 1);
await nextAnimationFrame();
assert.strictEqual(n, 1);
}
);
QUnit.test(
"calling batched function from within the callback is not treated as part of the original batch (synchronize at nextAnimationFrame)",
async (assert) => {
let n = 0;
const fn = batched(
() => {
n++;
if (n === 1) {
fn();
}
},
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
);
assert.strictEqual(n, 0);
fn();
assert.strictEqual(n, 0);
await nextAnimationFrame(); // First batch
assert.strictEqual(n, 1);
await nextAnimationFrame(); // Second batch initiated from within the callback
assert.strictEqual(n, 2);
await nextAnimationFrame();
assert.strictEqual(n, 2);
}
);
QUnit.test(
"callback is called twice (synchronize at nextAnimationFrame)",
async (assert) => {
let n = 0;
const fn = batched(
() => n++,
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
);
assert.strictEqual(n, 0);
fn();
assert.strictEqual(n, 0);
await nextAnimationFrame();
assert.strictEqual(n, 1);
fn();
assert.strictEqual(n, 1);
await nextAnimationFrame();
assert.strictEqual(n, 2);
}
);
QUnit.test(
"callback is called only once after operations (synchronize at setTimeout)",
async (assert) => {
let n = 0;
const fn = batched(
() => n++,
() => new Promise(setTimeout)
);
assert.strictEqual(n, 0);
fn();
fn();
assert.strictEqual(n, 0);
await nextSetTimeout();
assert.strictEqual(n, 1);
await nextSetTimeout();
assert.strictEqual(n, 1);
}
);
QUnit.test(
"calling batched function from within the callback is not treated as part of the original batch (synchronize at setTimeout)",
async (assert) => {
let n = 0;
const fn = batched(
() => {
n++;
if (n === 1) {
fn();
}
},
() => new Promise(setTimeout)
);
assert.strictEqual(n, 0);
fn();
assert.strictEqual(n, 0);
await nextSetTimeout(); // First batch
assert.strictEqual(n, 1);
await nextSetTimeout(); // Second batch initiated from within the callback
assert.strictEqual(n, 2);
await nextSetTimeout();
assert.strictEqual(n, 2);
}
);
QUnit.test("callback is called twice (synchronize at setTimeout)", async (assert) => {
let n = 0;
const fn = batched(
() => n++,
() => new Promise(setTimeout)
);
assert.strictEqual(n, 0);
fn();
assert.strictEqual(n, 0);
await nextSetTimeout();
assert.strictEqual(n, 1);
fn();
assert.strictEqual(n, 1);
await nextSetTimeout();
assert.strictEqual(n, 2);
});
});
});
QUnit.test("throttleForAnimationScrollEvent", async (assert) => {
assert.expect(5);
const execAnimationFrameCallbacks = mockAnimationFrame();
assert.expect(9);
const { advanceFrame, execRegisteredAnimationFrames } = mockAnimationFrame();
let resolveThrottled;
const throttled = new Promise(resolve => resolveThrottled = resolve);
let throttled = new Promise(resolve => resolveThrottled = resolve);
const throttledFn = throttleForAnimation((val, targetEl) => {
// In Chrome, the currentTarget of scroll events is lost after the
// event was handled, it is therefore null here.
@ -174,7 +560,7 @@ QUnit.module("utils", () => {
const childEl = document.createElement("div");
childEl.style = "height: 200px; width: 200px;";
let resolveScrolled;
const scrolled = new Promise(resolve => resolveScrolled = resolve);
let scrolled = new Promise(resolve => resolveScrolled = resolve);
el.appendChild(childEl);
el.addEventListener("scroll", (ev) => {
assert.step("before scroll");
@ -185,19 +571,34 @@ QUnit.module("utils", () => {
document.body.appendChild(el);
el.scrollBy(1, 1);
el.scrollBy(2, 2);
el.remove();
await scrolled;
await throttled;
assert.verifySteps([
"before scroll",
"throttled function called with DIV in event, but DIV in parameter",
"after scroll",
], "scroll happened and direct first call to throttled function happened too");
throttled = new Promise(resolve => resolveThrottled = resolve);
scrolled = new Promise(resolve => resolveScrolled = resolve);
el.scrollBy(3, 3);
await scrolled;
assert.verifySteps([
"before scroll",
// Further call is delayed.
"after scroll",
], "scroll happened but throttled function hasn't been called yet");
setTimeout(execAnimationFrameCallbacks);
setTimeout(() => {
advanceFrame();
execRegisteredAnimationFrames();
});
await throttled;
assert.verifySteps(
["throttled function called with null in event, but DIV in parameter"],
"currentTarget was not available in throttled function's event"
);
el.remove();
});
});

View file

@ -1,7 +1,13 @@
/** @odoo-module */
import { browser } from "@web/core/browser/browser";
import { getDataURLFromFile, getOrigin, url } from "@web/core/utils/urls";
import {
getDataURLFromFile,
getOrigin,
redirect,
RedirectionError,
url,
} from "@web/core/utils/urls";
import { patchWithCleanup } from "../../helpers/utils";
QUnit.module("URLS", (hooks) => {
@ -63,6 +69,33 @@ QUnit.module("URLS", (hooks) => {
QUnit.test("getDataURLFromFile handles empty file", async (assert) => {
const emptyFile = new File([""], "empty.txt", { type: "text/plain" });
const dataUrl = await getDataURLFromFile(emptyFile);
assert.strictEqual(dataUrl, "data:text/plain;base64,", "dataURL for empty file is not proper");
assert.strictEqual(
dataUrl,
"data:text/plain;base64,",
"dataURL for empty file is not proper"
);
});
QUnit.test("redirect", (assert) => {
function testRedirect(url) {
browser.location = {
protocol: "http:",
host: "testhost",
origin: "http://www.test.com",
pathname: "/some/tests",
};
redirect(url);
return browser.location;
}
assert.strictEqual(testRedirect("abc"), "http://www.test.com/some/abc");
assert.strictEqual(testRedirect("./abc"), "http://www.test.com/some/abc");
assert.strictEqual(testRedirect("../abc/def"), "http://www.test.com/abc/def");
assert.strictEqual(testRedirect("/abc/def"), "http://www.test.com/abc/def");
assert.strictEqual(testRedirect("/abc/def?x=y"), "http://www.test.com/abc/def?x=y");
assert.strictEqual(testRedirect("/abc?x=y#a=1&b=2"), "http://www.test.com/abc?x=y#a=1&b=2");
assert.throws(() => testRedirect("https://www.odoo.com"), RedirectionError);
assert.throws(() => testRedirect("javascript:alert('boom');"), RedirectionError);
});
});

View file

@ -1,5 +1,5 @@
/** @odoo-module **/
import { XMLParser } from "@web/core/utils/xml";
import { parseXML } from "@web/core/utils/xml";
QUnit.module("utils", () => {
QUnit.module("xml");
@ -7,10 +7,9 @@ QUnit.module("utils", () => {
QUnit.test("parse error throws an exception", async (assert) => {
assert.expect(3);
const parser = new XMLParser();
let XMLToParse = "<invalid'>";
try {
parser.parseXML(XMLToParse);
parseXML(XMLToParse);
assert.step("no error");
} catch (e) {
if (e.message.includes("error occured while parsing")) {
@ -20,7 +19,7 @@ QUnit.module("utils", () => {
XMLToParse = "<div><div>Valid</div><div><Invalid</div></div>";
try {
parser.parseXML(XMLToParse);
parseXML(XMLToParse);
assert.step("no error");
} catch (e) {
if (e.message.includes("error occured while parsing")) {

View file

@ -0,0 +1,155 @@
/** @odoo-module **/
import { Component, useRef, xml } from "@odoo/owl";
import { useVirtual } from "@web/core/virtual_hook";
import { getFixture, mount, patchWithCleanup, triggerEvent } from "../helpers/utils";
/**
* @typedef ItemType
* @property {number} id
*
* @typedef {import("@web/core/virtual_hook").VirtualHookParams<ItemType>} TestComponentProps
*/
function objectToStyle(obj) {
return Object.entries(obj)
.map(([k, v]) => `${k}: ${v};`)
.join("");
}
/** @type {ItemType[]} */
const ITEMS = Array.from({ length: 200 }, (_, i) => ({ id: i + 1 }));
const ITEM_HEIGHT = 50;
const ITEM_STYLE = objectToStyle({
height: `${ITEM_HEIGHT}px`,
width: "100%",
border: "1px solid black",
position: "absolute",
"background-color": "white",
});
const CONTAINER_SIZE = 5 * ITEM_HEIGHT;
const CONTAINER_STYLE = objectToStyle({
height: `${CONTAINER_SIZE}px`,
width: "100%",
overflow: "auto",
position: "relative",
"background-color": "lightblue",
});
const MAX_SCROLL_TOP = ITEMS.length * ITEM_HEIGHT - CONTAINER_SIZE;
/**
* @param {HTMLElement} [target]
* @param {TestComponentProps} [props]
*/
async function mountTestComponent(target, props) {
/** @extends {Component<ItemType, any>} **/
class Item extends Component {
static props = ["id"];
static template = xml`
<div class="item" t-att-data-id="props.id" t-att-style="style" t-esc="props.id"/>
`;
get style() {
return `top: ${(this.props.id - 1) * ITEM_HEIGHT}px; ${ITEM_STYLE}`;
}
}
/** @extends {Component<TestComponentProps, any>} **/
class TestComponent extends Component {
static props = ["getItems?", "scrollableRef?", "initialScroll?", "getItemHeight?"];
static components = { Item };
static template = xml`
<div class="scrollable" t-ref="scrollable" style="${CONTAINER_STYLE}">
<div class="inner" t-att-style="innerStyle">
<t t-foreach="items" t-as="item" t-key="item.id">
<Item id="item.id"/>
</t>
</div>
</div>
`;
setup() {
const scrollableRef = useRef("scrollable");
this.items = useVirtual({
getItems: () => ITEMS,
getItemHeight: () => ITEM_HEIGHT,
scrollableRef,
...this.props,
});
}
get innerStyle() {
return `height: ${ITEMS.length * ITEM_HEIGHT}px;`;
}
}
await mount(TestComponent, target, { props });
}
function scroll(target, scrollTop) {
target.querySelector(".scrollable").scrollTop = scrollTop;
return triggerEvent(target, ".scrollable", "scroll");
}
let target;
QUnit.module("useVirtual hook", {
async beforeEach() {
target = getFixture();
// In this test suite, we trick the hook by setting the window size to the size
// of the scrollable, so that it is a measurable size and this suite can run
// in a window of any size.
patchWithCleanup(window, { innerHeight: CONTAINER_SIZE });
},
});
QUnit.test("basic usage", async (assert) => {
await mountTestComponent(target);
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "11");
// scroll to the middle
await scroll(target, MAX_SCROLL_TOP / 2);
assert.containsN(target, ".item", 16);
assert.strictEqual(target.querySelector(".item").dataset.id, "93");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "108");
// scroll to the end
await scroll(target, MAX_SCROLL_TOP);
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "190");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "200");
});
QUnit.test("updates on resize", async (assert) => {
await mountTestComponent(target);
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "11");
// resize the window
patchWithCleanup(window, { innerHeight: CONTAINER_SIZE / 2 });
await triggerEvent(window, null, "resize");
assert.containsN(target, ".item", 6);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "6");
// resize the window
patchWithCleanup(window, { innerHeight: CONTAINER_SIZE * 2 });
await triggerEvent(window, null, "resize");
assert.containsN(target, ".item", 21);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "21");
});
QUnit.test("initialScroll: middle", async (assert) => {
const initialScroll = { top: MAX_SCROLL_TOP / 2 };
await mountTestComponent(target, { initialScroll });
assert.containsN(target, ".item", 16);
assert.strictEqual(target.querySelector(".item").dataset.id, "93");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "108");
});
QUnit.test("initialScroll: bottom", async (assert) => {
const initialScroll = { top: MAX_SCROLL_TOP };
await mountTestComponent(target, { initialScroll });
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "190");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "200");
});