mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 03:52:01 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -89,6 +89,49 @@ test("can be rendered", async () => {
|
|||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
|
||||
});
|
||||
|
||||
// TODO: Hoot dispatches "change"/"blur" in the wrong order vs browser.
|
||||
test.todo("select option with onChange", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources" onChange.bind="onChange" />`;
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [
|
||||
item("/contactus", this.onSelect.bind(this)),
|
||||
item("/contactus-thank-you", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onChange({ inputValue, isOptionSelected }) {
|
||||
expect.step(`isOptionSelected:${isOptionSelected}`);
|
||||
if (isOptionSelected) {
|
||||
return;
|
||||
}
|
||||
this.state.value = inputValue;
|
||||
}
|
||||
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
await contains(".o-autocomplete input").edit("/", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
await runAllTimers();
|
||||
expect.verifySteps(["isOptionSelected:true", "/contactus"]);
|
||||
|
||||
await contains(".o-autocomplete input").edit("hello", { confirm: "false" });
|
||||
expect.verifySteps(["isOptionSelected:false"]);
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete input").toHaveValue("hello");
|
||||
});
|
||||
|
||||
test("select option", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
|
|
@ -573,7 +616,7 @@ test("correct sequence of blur, focus and select", async () => {
|
|||
await contains(".o-autocomplete input").edit("", { confirm: false });
|
||||
await runAllTimers();
|
||||
await contains(document.body).click();
|
||||
expect.verifySteps(["blur", "change"]);
|
||||
expect.verifySteps(["change", "blur"]);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
|
|
@ -647,6 +690,60 @@ test("tab and shift+tab close the dropdown", async () => {
|
|||
expect(dropdown).not.toHaveCount();
|
||||
});
|
||||
|
||||
test("Clicking away selects the first option when selectOnBlur is true", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources" selectOnBlur="true"/>`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [
|
||||
item("World", this.onSelect.bind(this)),
|
||||
item("Hello", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
const input = ".o-autocomplete input";
|
||||
await contains(input).click();
|
||||
expect(".o-autocomplete--dropdown-menu").toBeVisible();
|
||||
queryFirst(input).blur();
|
||||
await animationFrame();
|
||||
expect(input).toHaveValue("World");
|
||||
expect.verifySteps(["World"]);
|
||||
});
|
||||
|
||||
test("selectOnBlur doesn't interfere with selecting by mouse clicking", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources" selectOnBlur="true"/>`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [
|
||||
item("World", this.onSelect.bind(this)),
|
||||
item("Hello", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
const input = ".o-autocomplete input";
|
||||
await contains(input).click();
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect(input).toHaveValue("Hello");
|
||||
expect.verifySteps(["Hello"]);
|
||||
});
|
||||
|
||||
test("autocomplete scrolls when moving with arrows", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
|
|
@ -836,3 +933,32 @@ test("items are selected only when the mouse moves, not just on enter", async ()
|
|||
"ui-state-active"
|
||||
);
|
||||
});
|
||||
|
||||
test("do not attempt to scroll if element is null", async () => {
|
||||
const def = new Deferred();
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="''" sources="sources" />`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
sources = [
|
||||
buildSources(async () => {
|
||||
await def;
|
||||
return [item("delayed one"), item("delayed two"), item("delayed three")];
|
||||
}),
|
||||
buildSources(Array.from(Array(20)).map((_, index) => item(`item ${index}`))),
|
||||
].flat();
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
queryOne(`.o-autocomplete input`).focus();
|
||||
queryOne(`.o-autocomplete input`).click();
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete .dropdown-item").toHaveCount(21);
|
||||
expect(".o-autocomplete .dropdown-item:eq(0)").toHaveClass("o_loading");
|
||||
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete .dropdown-item").toHaveCount(23); // + 3 items - loading
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { test, expect } from "@odoo/hoot";
|
||||
import { press, click, animationFrame, queryOne } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { press, click, animationFrame, queryOne, manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
|
||||
import { Component, xml, useState } from "@odoo/owl";
|
||||
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { ColorPicker, DEFAULT_COLORS } from "@web/core/color_picker/color_picker";
|
||||
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
|
||||
|
|
@ -230,6 +230,42 @@ test("custom color picker sets default color as selected", async () => {
|
|||
expect("input.o_hex_input").toHaveValue("#FF0000");
|
||||
});
|
||||
|
||||
test("should preserve color slider when picking max lightness color", async () => {
|
||||
class TestColorPicker extends Component {
|
||||
static template = xml`
|
||||
<div style="width: 222px">
|
||||
<CustomColorPicker selectedColor="state.color" onColorPreview.bind="onColorChange" onColorSelect.bind="onColorChange"/>
|
||||
</div>`;
|
||||
static components = { CustomColorPicker };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({
|
||||
color: "#FFFF00",
|
||||
});
|
||||
}
|
||||
onColorChange({ cssColor }) {
|
||||
this.state.color = cssColor;
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(TestColorPicker);
|
||||
const colorPickerArea = queryOne(".o_color_pick_area");
|
||||
const colorPickerRect = colorPickerArea.getBoundingClientRect();
|
||||
|
||||
const clientX = colorPickerRect.left + colorPickerRect.width / 2;
|
||||
const clientY = colorPickerRect.top; // Lightness 100%
|
||||
manuallyDispatchProgrammaticEvent(colorPickerArea, "pointerdown", {
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
manuallyDispatchProgrammaticEvent(colorPickerArea, "pointerup", {
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
|
||||
await animationFrame();
|
||||
expect(colorPickerArea).toHaveStyle({ backgroundColor: "rgb(255, 255, 0)" });
|
||||
});
|
||||
|
||||
test("custom color picker change color on click in hue slider", async () => {
|
||||
await mountWithCleanup(CustomColorPicker, { props: { selectedColor: "#FF0000" } });
|
||||
expect("input.o_hex_input").toHaveValue("#FF0000");
|
||||
|
|
@ -269,3 +305,25 @@ test("can register an extra tab", async () => {
|
|||
expect(".o_font_color_selector>p:last-child").toHaveText("Color picker extra tab");
|
||||
registry.category("color_picker_tabs").remove("web.extra");
|
||||
});
|
||||
|
||||
test("should mark default color as selected when it is selected", async () => {
|
||||
defineStyle(`
|
||||
:root {
|
||||
--900: #212527;
|
||||
}
|
||||
`);
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "#212527",
|
||||
defaultTab: "custom",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
},
|
||||
});
|
||||
expect(".o_color_button[data-color='900']").toHaveClass("selected");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1291,16 +1291,21 @@ test("bold the searchValue on the commands with special char", async () => {
|
|||
const action = () => {};
|
||||
const providers = [
|
||||
{
|
||||
namespace: "/",
|
||||
provide: () => [
|
||||
{
|
||||
name: "Test&",
|
||||
action,
|
||||
},
|
||||
{
|
||||
name: "Research & Development",
|
||||
action,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const config = {
|
||||
searchValue: "&",
|
||||
searchValue: "/",
|
||||
providers,
|
||||
};
|
||||
getService("dialog").add(CommandPalette, {
|
||||
|
|
@ -1308,9 +1313,29 @@ test("bold the searchValue on the commands with special char", async () => {
|
|||
});
|
||||
await animationFrame();
|
||||
expect(".o_command_palette").toHaveCount(1);
|
||||
expect(".o_command").toHaveCount(1);
|
||||
expect(queryAllTexts(".o_command")).toEqual(["Test&"]);
|
||||
expect(queryAllTexts(".o_command .fw-bolder")).toEqual(["&"]);
|
||||
expect(".o_command").toHaveCount(2);
|
||||
expect(queryAllTexts(".o_command")).toEqual(["Test&", "Research & Development"]);
|
||||
expect(queryAllTexts(".o_command .fw-bolder")).toEqual([]);
|
||||
|
||||
await click(".o_command_palette_search input");
|
||||
await edit("/a");
|
||||
await runAllTimers();
|
||||
expect(".o_command").toHaveCount(2);
|
||||
expect(
|
||||
queryAll(".o_command").map((command) =>
|
||||
queryAllTexts(".o_command_name .fw-bolder", { root: command })
|
||||
)
|
||||
).toEqual([[], ["a"]]);
|
||||
|
||||
await click(".o_command_palette_search input");
|
||||
await edit("/&");
|
||||
await runAllTimers();
|
||||
expect(".o_command").toHaveCount(2);
|
||||
expect(
|
||||
queryAll(".o_command").map((command) =>
|
||||
queryAllTexts(".o_command_name .fw-bolder", { root: command })
|
||||
)
|
||||
).toEqual([["&"], ["&"]]);
|
||||
});
|
||||
|
||||
test("bold the searchValue on the commands with accents", async () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Component, reactive, useState, xml } from "@odoo/owl";
|
|||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
|
|
@ -143,3 +144,55 @@ test("value is not updated if it did not change", async () => {
|
|||
expect(getShortDate(pickerProps.value)).toBe("2023-07-07");
|
||||
expect.verifySteps(["2023-07-07"]);
|
||||
});
|
||||
|
||||
test("close popover when owner component is unmounted", async() => {
|
||||
class Child extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<div>
|
||||
<input type="text" class="datetime_hook_input" t-ref="start-date"/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
useDateTimePicker({
|
||||
createPopover: usePopover,
|
||||
pickerProps: {
|
||||
value: [false, false],
|
||||
type: "date",
|
||||
range: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { resolve: hidePopover, promise } = Promise.withResolvers();
|
||||
|
||||
class DateTimeToggler extends Component {
|
||||
static components = { Child };
|
||||
static props = [];
|
||||
static template = xml`<Child t-if="!state.hidden"/>`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
hidden: false,
|
||||
});
|
||||
promise.then(() => {
|
||||
this.state.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(DateTimeToggler);
|
||||
|
||||
await click("input.datetime_hook_input");
|
||||
await animationFrame();
|
||||
expect(".o_datetime_picker").toHaveCount(1);
|
||||
|
||||
// we can't simply add a button because `useClickAway` will be triggered, thus closing the popover properly
|
||||
hidePopover();
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { CopyButton } from "@web/core/copy_button/copy_button";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { click } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(browser.navigator.clipboard, {
|
||||
async writeText(text) {
|
||||
expect.step(`writeText: ${text}`);
|
||||
},
|
||||
async write(object) {
|
||||
expect.step(
|
||||
`write: {${Object.entries(object)
|
||||
.map(([k, v]) => k + ": " + v)
|
||||
.join(", ")}}`
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("copies a string to the clipboard", async () => {
|
||||
await mountWithCleanup(CopyButton, { props: { content: "content to copy" } });
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["writeText: content to copy"]);
|
||||
});
|
||||
|
||||
test("copies an object to the clipboard", async () => {
|
||||
await mountWithCleanup(CopyButton, { props: { content: { oneKey: "oneValue" } } });
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["write: {oneKey: oneValue}"]);
|
||||
});
|
||||
|
||||
test("does not submit forms", async () => {
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { CopyButton };
|
||||
static template = xml`
|
||||
<form t-on-submit="this.onSubmit">
|
||||
<CopyButton content="'some text'"/>
|
||||
<!-- note that type="submit" is implicit on the following button -->
|
||||
<button class="submit-button"/>
|
||||
</form>
|
||||
`;
|
||||
onSubmit(ev) {
|
||||
ev.preventDefault();
|
||||
expect.step("form submit");
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["writeText: some text"]);
|
||||
await click(".submit-button");
|
||||
expect.verifySteps(["form submit"]);
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ import { Dialog } from "@web/core/dialog/dialog";
|
|||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
test("simple rendering", async () => {
|
||||
expect.assertions(8);
|
||||
expect.assertions(7);
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
|
|
@ -33,10 +33,7 @@ test("simple rendering", async () => {
|
|||
expect(".o_dialog main").toHaveCount(1, { message: "a dialog has always a main node" });
|
||||
expect("main").toHaveText("Hello!");
|
||||
expect(".o_dialog footer").toHaveCount(1, { message: "the footer is rendered by default" });
|
||||
expect(".o_dialog footer button").toHaveCount(1, {
|
||||
message: "the footer is rendered with a single button 'Ok' by default",
|
||||
});
|
||||
expect("footer button").toHaveText("Ok");
|
||||
expect(".o_dialog footer:visible").toHaveCount(0, { message: "the footer is hidden if empty" });
|
||||
});
|
||||
|
||||
test("hotkeys work on dialogs", async () => {
|
||||
|
|
@ -45,9 +42,15 @@ test("hotkeys work on dialogs", async () => {
|
|||
static template = xml`
|
||||
<Dialog title="'Wow(l) Effect'">
|
||||
Hello!
|
||||
<t t-set-slot="footer">
|
||||
<button t-on-click="onClickOk">Ok</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
onClickOk() {
|
||||
expect.step("clickOk");
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
|
|
@ -65,7 +68,7 @@ test("hotkeys work on dialogs", async () => {
|
|||
// Same effect as clicking on the Ok button
|
||||
await keyDown("control+enter");
|
||||
await keyUp("ctrl+enter");
|
||||
expect.verifySteps(["close"]);
|
||||
expect.verifySteps(["clickOk"]);
|
||||
});
|
||||
|
||||
test("simple rendering with two dialogs", async () => {
|
||||
|
|
@ -143,30 +146,6 @@ test("click on the button x triggers the close and dismiss defined by a Child co
|
|||
expect.verifySteps(["dismiss", "close"]);
|
||||
});
|
||||
|
||||
test("click on the default footer button triggers the service close", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: () => expect.step("close"),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("render custom footer buttons is possible", async () => {
|
||||
expect.assertions(2);
|
||||
class SimpleButtonsDialog extends Component {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect, beforeEach, describe } from "@odoo/hoot";
|
||||
import { test, expect, beforeEach } from "@odoo/hoot";
|
||||
import { click, press, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
|
@ -8,8 +8,6 @@ import { usePopover } from "@web/core/popover/popover_hook";
|
|||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
});
|
||||
|
|
@ -25,7 +23,7 @@ test("Simple rendering with a single dialog", async () => {
|
|||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Welcome");
|
||||
await click(".o_dialog footer button");
|
||||
await click(".o_dialog button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
|
@ -68,7 +66,7 @@ test("rendering with two dialogs", async () => {
|
|||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
|
||||
await click(".o_dialog footer button");
|
||||
await click(".o_dialog button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Sauron");
|
||||
|
|
@ -174,7 +172,7 @@ test("Interactions between multiple dialogs", async () => {
|
|||
expect(res.active).toEqual([false, true]);
|
||||
expect(res.names).toEqual(["Hello", "Sauron"]);
|
||||
|
||||
await click(".o_dialog:not(.o_inactive_modal) footer button");
|
||||
await click(".o_dialog:not(.o_inactive_modal) button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
|
|
@ -182,7 +180,7 @@ test("Interactions between multiple dialogs", async () => {
|
|||
expect(res.active).toEqual([true]);
|
||||
expect(res.names).toEqual(["Hello"]);
|
||||
|
||||
await click("footer button");
|
||||
await click(".o_dialog:not(.o_inactive_modal) button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -787,7 +787,11 @@ test("don't close dropdown outside the active element", async () => {
|
|||
expect(".modal-dialog").toHaveCount(1);
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
|
||||
await click(".modal-dialog .btn-primary");
|
||||
if (getMockEnv().isSmall) {
|
||||
await click(".modal-dialog .oi-arrow-left");
|
||||
} else {
|
||||
await click(".modal-dialog .btn-close");
|
||||
}
|
||||
await animationFrame();
|
||||
expect(".modal-dialog").toHaveCount(0);
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
|
|
|
|||
|
|
@ -193,3 +193,23 @@ test("Upload button is disabled if attachment upload is not finished", async ()
|
|||
message: "the upload button should be enabled for upload",
|
||||
});
|
||||
});
|
||||
|
||||
test("support preprocessing of files via props", async () => {
|
||||
await createFileInput({
|
||||
props: {
|
||||
onWillUploadFiles(files) {
|
||||
expect.step(files[0].name);
|
||||
return files;
|
||||
},
|
||||
},
|
||||
mockPost: (route, params) => {
|
||||
return JSON.stringify([{ name: params.ufile[0].name }]);
|
||||
},
|
||||
});
|
||||
|
||||
await contains(".o_file_input input", { visible: false }).click();
|
||||
await setInputFiles([new File(["test"], "fake_file.txt", { type: "text/plain" })]);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["fake_file.txt"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { expect, test, waitFor } from "@odoo/hoot";
|
||||
import {
|
||||
contains,
|
||||
getService,
|
||||
|
|
@ -61,13 +61,12 @@ test("upload renders new component(s)", async () => {
|
|||
|
||||
test("upload end removes component", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
onRpc("/test/", () => true);
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await animationFrame();
|
||||
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("load"));
|
||||
await animationFrame();
|
||||
expect(".file_upload").toHaveCount(0);
|
||||
expect(".o_notification").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("upload error removes component", async () => {
|
||||
|
|
@ -125,3 +124,49 @@ test("upload updates on progress", async () => {
|
|||
await animationFrame();
|
||||
expect(".file_upload_progress_text_right").toHaveText("(350/500MB)");
|
||||
});
|
||||
|
||||
test("handles error", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
onRpc("/test/", () => {
|
||||
throw new Error("Boom");
|
||||
});
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await waitFor(".o_notification:has(.bg-danger):contains(An error occured while uploading)");
|
||||
});
|
||||
|
||||
test("handles http not success", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
onRpc("/test/", () => new Response("<p>Boom HTML</p>", { status: 500 }));
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await waitFor(".o_notification:has(.bg-danger):contains(Boom HTML)");
|
||||
});
|
||||
|
||||
test("handles jsonrpc error", async () => {
|
||||
// https://www.jsonrpc.org/specification#error_object
|
||||
await mountWithCleanup(Parent);
|
||||
onRpc("/test/", () => ({
|
||||
error: {
|
||||
message: "Boom JSON",
|
||||
},
|
||||
}));
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await waitFor(".o_notification:has(.bg-danger):contains(Boom JSON)");
|
||||
});
|
||||
|
||||
test("handles Odoo's jsonrpc error", async () => {
|
||||
await mountWithCleanup(Parent);
|
||||
onRpc("/test/", () => ({
|
||||
error: {
|
||||
data: {
|
||||
name: "ValidationError",
|
||||
message: "Boom Odoo",
|
||||
},
|
||||
},
|
||||
}));
|
||||
const fileUploadService = await getService("file_upload");
|
||||
fileUploadService.upload("/test/", []);
|
||||
await waitFor(".o_notification:has(.bg-danger):contains(ValidationError: Boom Odoo)");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { expect, getFixture, test } from "@odoo/hoot";
|
||||
import { animationFrame, mockFetch } from "@odoo/hoot-mock";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
makeMockEnv,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
|
|
@ -34,8 +35,8 @@ test("Installation page displays the app info correctly", async () => {
|
|||
},
|
||||
});
|
||||
mountManifestLink("/web/manifest.scoped_app_manifest");
|
||||
mockFetch((route) => {
|
||||
expect.step(route);
|
||||
onRpc("/*", (request) => {
|
||||
expect.step(new URL(request.url).pathname);
|
||||
return {
|
||||
icons: [
|
||||
{
|
||||
|
|
@ -73,7 +74,7 @@ test("Installation page displays the app info correctly", async () => {
|
|||
expect("button.btn-primary").toHaveCount(1);
|
||||
expect("button.btn-primary").toHaveText("Install");
|
||||
await contains(".fa-pencil").click();
|
||||
await contains("input").edit("<Otto&");
|
||||
await contains("input").edit("<Otto&", { confirm: "blur" });
|
||||
expect.verifySteps(["URL replace"]);
|
||||
});
|
||||
|
||||
|
|
@ -81,8 +82,8 @@ test("Installation page displays the error message when browser is not supported
|
|||
delete browser.BeforeInstallPromptEvent;
|
||||
await makeMockEnv();
|
||||
mountManifestLink("/web/manifest.scoped_app_manifest");
|
||||
mockFetch((route) => {
|
||||
expect.step(route);
|
||||
onRpc("/*", (request) => {
|
||||
expect.step(new URL(request.url).pathname);
|
||||
return {
|
||||
icons: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint no-restricted-syntax: 0 */
|
||||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { animationFrame, Deferred } from "@odoo/hoot-mock";
|
||||
import {
|
||||
defineParams,
|
||||
makeMockEnv,
|
||||
|
|
@ -10,9 +11,8 @@ import {
|
|||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { _t as basic_t, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
|
||||
import { session } from "@web/session";
|
||||
import { IndexedDB } from "@web/core/utils/indexed_db";
|
||||
import { animationFrame, Deferred } from "@odoo/hoot-mock";
|
||||
import { session } from "@web/session";
|
||||
|
||||
import { Component, markup, xml } from "@odoo/owl";
|
||||
const { DateTime } = luxon;
|
||||
|
|
@ -51,6 +51,7 @@ async function mockLang(lang) {
|
|||
await makeMockEnv();
|
||||
}
|
||||
|
||||
test.tags("headless");
|
||||
test("lang is given by the user context", async () => {
|
||||
onRpc("/web/webclient/translations", (request) => {
|
||||
const urlParams = new URLSearchParams(new URL(request.url).search);
|
||||
|
|
@ -60,6 +61,7 @@ test("lang is given by the user context", async () => {
|
|||
expect.verifySteps(["fr_FR"]);
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("lang is given by an attribute on the DOM root node", async () => {
|
||||
serverState.lang = null;
|
||||
onRpc("/web/webclient/translations", (request) => {
|
||||
|
|
@ -74,20 +76,17 @@ test("lang is given by an attribute on the DOM root node", async () => {
|
|||
expect.verifySteps(["fr_FR"]);
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("url is given by the session", async () => {
|
||||
expect.assertions(1);
|
||||
patchWithCleanup(session, {
|
||||
translationURL: "/get_translations",
|
||||
});
|
||||
onRpc(
|
||||
"/get_translations",
|
||||
function (request) {
|
||||
expect(request.url).toInclude("/get_translations");
|
||||
return this.loadTranslations(request);
|
||||
},
|
||||
{ pure: true }
|
||||
);
|
||||
onRpc("/get_translations", function (request) {
|
||||
expect.step("/get_translations");
|
||||
return this.loadTranslations(request);
|
||||
});
|
||||
await makeMockEnv();
|
||||
expect.verifySteps(["/get_translations"]);
|
||||
});
|
||||
|
||||
test("can translate a text node", async () => {
|
||||
|
|
@ -340,56 +339,67 @@ test("can lazy translate", async () => {
|
|||
expect("#main").toHaveText("Bonjour");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("luxon is configured in the correct lang", async () => {
|
||||
await mockLang("fr_BE");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("MMMM")).toBe("décembre");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("arabic has the correct numbering system (generic)", async () => {
|
||||
await mockLang("ar_001");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("arabic has the correct numbering system (Algeria)", async () => {
|
||||
await mockLang("ar_DZ");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("arabic has the correct numbering system (Lybia)", async () => {
|
||||
await mockLang("ar_LY");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("arabic has the correct numbering system (Morocco)", async () => {
|
||||
await mockLang("ar_MA");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("arabic has the correct numbering system (Saudi Arabia)", async () => {
|
||||
await mockLang("ar_SA");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("arabic has the correct numbering system (Tunisia)", async () => {
|
||||
await mockLang("ar_TN");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("bengalese has the correct numbering system", async () => {
|
||||
await mockLang("bn");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("১০/১২/২০২১ ১২:০০:০০");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("punjabi (gurmukhi) has the correct numbering system", async () => {
|
||||
await mockLang("pa_IN");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("੧੦/੧੨/੨੦੨੧ ੧੨:੦੦:੦੦");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("tamil has the correct numbering system", async () => {
|
||||
await mockLang("ta");
|
||||
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("௧௦/௧௨/௨௦௨௧ ௧௨:௦௦:௦௦");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("_t fills the format specifiers in translated terms with its extra arguments", async () => {
|
||||
patchTranslations({
|
||||
web: {
|
||||
|
|
@ -400,6 +410,7 @@ test("_t fills the format specifiers in translated terms with its extra argument
|
|||
expect(translatedStr).toBe("Échéance dans 513 jours");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("_t fills the format specifiers in translated terms with formatted lists", async () => {
|
||||
await mockLang("fr_FR");
|
||||
patchTranslations({
|
||||
|
|
@ -418,6 +429,7 @@ test("_t fills the format specifiers in translated terms with formatted lists",
|
|||
expect(translatedStr2).toBe("Échéance dans 30, 60 et 90 jours pour Mitchell");
|
||||
});
|
||||
|
||||
test.tags("headless");
|
||||
test("_t fills the format specifiers in lazy translated terms with its extra arguments", async () => {
|
||||
translatedTerms[translationLoaded] = false;
|
||||
const translatedStr = _t("Due in %s days", 513);
|
||||
|
|
@ -429,6 +441,7 @@ test("_t fills the format specifiers in lazy translated terms with its extra arg
|
|||
expect(translatedStr.toString()).toBe("Échéance dans 513 jours");
|
||||
});
|
||||
|
||||
describe.tags("headless");
|
||||
describe("_t with markups", () => {
|
||||
test("non-markup values are escaped", () => {
|
||||
translatedTerms[translationLoaded] = true;
|
||||
|
|
|
|||
|
|
@ -946,3 +946,39 @@ test("showDebugInput = false", async () => {
|
|||
await openModelFieldSelectorPopover();
|
||||
expect(".o_model_field_selector_debug").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("models with a m2o of the same name should show the correct page data", async () => {
|
||||
class Cat extends models.Model {
|
||||
cat_name = fields.Char();
|
||||
link = fields.Many2one({ relation: "dog" });
|
||||
}
|
||||
|
||||
class Dog extends models.Model {
|
||||
dog_name = fields.Char();
|
||||
link = fields.Many2one({ relation: "fish" });
|
||||
}
|
||||
|
||||
class Fish extends models.Model {
|
||||
fish_name = fields.Char();
|
||||
}
|
||||
defineModels([Cat, Dog, Fish]);
|
||||
|
||||
await mountWithCleanup(ModelFieldSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
path: "link",
|
||||
resModel: "cat",
|
||||
},
|
||||
});
|
||||
|
||||
await openModelFieldSelectorPopover();
|
||||
await contains(".o_model_field_selector_popover_relation_icon").click();
|
||||
expect(getDisplayedFieldNames()).toEqual([
|
||||
"Created on",
|
||||
"Display name",
|
||||
"Dog name",
|
||||
"Id",
|
||||
"Last Modified on",
|
||||
"Link",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -215,3 +215,15 @@ test("model_selector: with an initial value", async () => {
|
|||
await mountModelSelector(["model.1", "model.2", "model.3"], "Model 1");
|
||||
expect(".o-autocomplete--input").toHaveValue("Model 1");
|
||||
});
|
||||
|
||||
test("model_selector: autofocus", async () => {
|
||||
await mountWithCleanup(ModelSelector, {
|
||||
props: {
|
||||
models: ["model.1"],
|
||||
autofocus: true,
|
||||
onModelSelected: () => {},
|
||||
},
|
||||
});
|
||||
const input = queryAll("input.o-autocomplete--input")[0];
|
||||
expect(input).toBe(document.activeElement);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -112,3 +112,21 @@ test("test name_and_signature widget update signmode with onSignatureChange prop
|
|||
await contains(".o_web_sign_draw_button").click();
|
||||
expect(currentSignMode).toBe("draw");
|
||||
});
|
||||
|
||||
test("test name_and_signature widget with non-breaking spaces", async function () {
|
||||
const props = {
|
||||
signature: { name: "Non Breaking Spaces" },
|
||||
};
|
||||
const res = await mountWithCleanup(NameAndSignature, { props });
|
||||
expect(res.getCleanedName()).toBe("Non Breaking Spaces");
|
||||
});
|
||||
|
||||
|
||||
test("test name_and_signature widget with non-breaking spaces and initials mode", async function () {
|
||||
const props = {
|
||||
signature: { name: "Non Breaking Spaces" },
|
||||
signatureType: "initial",
|
||||
};
|
||||
const res = await mountWithCleanup(NameAndSignature, { props });
|
||||
expect(res.getCleanedName()).toBe("N.B.S.");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Component, onMounted, useState, xml } from "@odoo/owl";
|
||||
import { Navigator, useNavigation } from "@web/core/navigation/navigation";
|
||||
import { ACTIVE_ELEMENT_CLASS, Navigator, useNavigation } from "@web/core/navigation/navigation";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { describe, destroy, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
|
|
@ -312,3 +312,91 @@ test("non-navigable dom update does NOT cause re-focus", async () => {
|
|||
expect(".test-non-navigable").toHaveCount(1);
|
||||
expect(".one").not.toBeFocused();
|
||||
});
|
||||
|
||||
test("mousehover only set active if navigation is availible", async () => {
|
||||
class Parent extends Component {
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<div class="container" t-ref="containerRef">
|
||||
<button class="o-navigable one">target one</button>
|
||||
<button class="o-navigable two">target two</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
this.navigation = useNavigation("containerRef");
|
||||
}
|
||||
}
|
||||
|
||||
const component = await mountWithCleanup(Parent);
|
||||
expect(".one").not.toBeFocused();
|
||||
expect(".two").not.toBeFocused();
|
||||
expect(component.navigation.activeItem).toBe(null);
|
||||
|
||||
await hover(".one");
|
||||
expect(component.navigation.activeItem).toBe(null);
|
||||
|
||||
await hover(".two");
|
||||
expect(component.navigation.activeItem).toBe(null);
|
||||
|
||||
await click(".one");
|
||||
expect(".one").toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(component.navigation.activeItem.target).toBe(queryOne(".one"));
|
||||
|
||||
await hover(".two");
|
||||
expect(".one").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(".two").toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(component.navigation.activeItem.target).toBe(queryOne(".two"));
|
||||
});
|
||||
|
||||
test("active item is unset when focusing out", async () => {
|
||||
class Parent extends Component {
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<button class="outside">outside</button>
|
||||
<div class="container" t-ref="containerRef">
|
||||
<button class="o-navigable one">target one</button>
|
||||
<button class="o-navigable two">target two</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
this.navigation = useNavigation("containerRef");
|
||||
}
|
||||
}
|
||||
|
||||
const component = await mountWithCleanup(Parent);
|
||||
await click(".one");
|
||||
expect(".one").toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(component.navigation.activeItem.target).toEqual(queryOne(".one"));
|
||||
|
||||
await click(".outside");
|
||||
expect(".one").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
|
||||
expect(component.navigation.activeItem).toBe(null);
|
||||
});
|
||||
|
||||
test("set focused element as active item", async () => {
|
||||
class Parent extends Component {
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<div class="container" t-ref="containerRef">
|
||||
<input class="o-navigable one" id="input" t-ref="autofocus"/>
|
||||
<button class="o-navigable two">target two</button>
|
||||
<button class="o-navigable three">target three</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
this.inputRef = useAutofocus();
|
||||
this.navigation = useNavigation("containerRef");
|
||||
}
|
||||
}
|
||||
|
||||
const component = await mountWithCleanup(Parent);
|
||||
expect(component.inputRef.el).toBeFocused();
|
||||
expect(component.navigation.activeItem).not.toBeEmpty();
|
||||
expect(component.navigation.activeItem.el).toBe(component.inputRef.el);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,10 +138,12 @@ test("trigger a ConnectionLostError when response isn't json parsable", async ()
|
|||
|
||||
test("rpc can send additional headers", async () => {
|
||||
mockFetch((url, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Hello: "World",
|
||||
});
|
||||
expect(settings.headers).toEqual(
|
||||
new Headers([
|
||||
["Content-Type", "application/json"],
|
||||
["Hello", "World"],
|
||||
])
|
||||
);
|
||||
return { result: true };
|
||||
});
|
||||
await rpc("/test/", null, { headers: { Hello: "World" } });
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { Deferred, microTick } from "@odoo/hoot-mock";
|
||||
import { Deferred, describe, expect, microTick, test, tick } from "@odoo/hoot";
|
||||
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { RPCCache } from "@web/core/network/rpc_cache";
|
||||
import { IDBQuotaExceededError, IndexedDB } from "@web/core/utils/indexed_db";
|
||||
|
||||
const symbol = Symbol("Promise");
|
||||
const S_PENDING = Symbol("Promise");
|
||||
|
||||
/**
|
||||
* @param {Promise<any>} promise
|
||||
*/
|
||||
function promiseState(promise) {
|
||||
return Promise.race([promise, Promise.resolve(symbol)]).then(
|
||||
(value) => (value === symbol ? { status: "pending" } : { status: "fulfilled", value }),
|
||||
return Promise.race([promise, Promise.resolve(S_PENDING)]).then(
|
||||
(value) => (value === S_PENDING ? { status: "pending" } : { status: "fulfilled", value }),
|
||||
(reason) => ({ status: "rejected", reason })
|
||||
);
|
||||
}
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("RamCache: can cache a simple call", async () => {
|
||||
// The fist call to rpcCache.read will save the result on the RamCache.
|
||||
// The fist call to rpcCache.read saves the result on the RamCache.
|
||||
// Each next call will retrive the ram cache independently, without executing the fallback
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
|
|
@ -86,7 +93,7 @@ test("PersistentCache: can cache a simple call", async () => {
|
|||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// Simulate a reload (Clear the Ram Cache)
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
const def = new Deferred();
|
||||
|
|
@ -231,7 +238,7 @@ test("IndexedDB Crypt: can cache a simple call", async () => {
|
|||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// Simulate a reload (Clear the Ram Cache)
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
const def = new Deferred();
|
||||
|
|
@ -326,7 +333,7 @@ test("update callback - Disk Value", async () => {
|
|||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// Simulate a reload (Clear the Ram Cache)
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
const def = new Deferred();
|
||||
|
|
@ -448,7 +455,7 @@ test("Ram value shouldn't change (update the IndexedDB response)", async () => {
|
|||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// Simulate a reload (Clear the Ram Cache)
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
|
||||
|
|
@ -533,7 +540,7 @@ test("Changing the result shouldn't force the call to callback with hasChanged (
|
|||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// Simulate a reload (Clear the Ram Cache)
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
|
||||
|
|
@ -563,11 +570,235 @@ test("Changing the result shouldn't force the call to callback with hasChanged (
|
|||
def.resolve({ test: 123 });
|
||||
});
|
||||
|
||||
test("DiskCache: multiple consecutive calls, call once fallback", async () => {
|
||||
// The fist call to rpcCache.read will save the promise to the Ram Cache.
|
||||
// Each next call (before the end of the first call) will retrive the promise of the first call
|
||||
// without executing the fallback
|
||||
// the callback of each call is executed.
|
||||
test("RamCache (no update): consecutive calls (success)", async () => {
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
|
||||
const def = new Deferred();
|
||||
rpcCache
|
||||
.read("table", "key", () => def)
|
||||
.then((r) => {
|
||||
expect.step(`first prom resolved with ${r}`);
|
||||
});
|
||||
rpcCache
|
||||
.read("table", "key", () => expect.step("should not be called"))
|
||||
.then((r) => {
|
||||
expect.step(`second prom resolved with ${r}`);
|
||||
});
|
||||
|
||||
def.resolve("some value");
|
||||
await tick();
|
||||
expect.verifySteps([
|
||||
"first prom resolved with some value",
|
||||
"second prom resolved with some value",
|
||||
]);
|
||||
});
|
||||
|
||||
test("RamCache (no update): consecutive calls and rejected promise", async () => {
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
|
||||
const def = new Deferred();
|
||||
rpcCache
|
||||
.read("table", "key", () => def)
|
||||
.catch((e) => {
|
||||
expect.step(`first prom rejected ${e.message}`);
|
||||
});
|
||||
rpcCache
|
||||
.read("table", "key", () => expect.step("should not be called"))
|
||||
.catch((e) => {
|
||||
expect.step(`second prom rejected ${e.message}`);
|
||||
});
|
||||
|
||||
def.reject(new Error("boom"));
|
||||
await tick();
|
||||
expect.verifySteps(["first prom rejected boom", "second prom rejected boom"]);
|
||||
});
|
||||
|
||||
test("RamCache: pending request and call to invalidate", async () => {
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
|
||||
const def = new Deferred();
|
||||
rpcCache
|
||||
.read("table", "key", () => {
|
||||
expect.step("fallback first call");
|
||||
return def;
|
||||
})
|
||||
.then((r) => {
|
||||
expect.step(`first prom resolved with ${r}`);
|
||||
});
|
||||
rpcCache.invalidate();
|
||||
rpcCache
|
||||
.read("table", "key", () => {
|
||||
expect.step("fallback second call");
|
||||
return Promise.resolve("another value");
|
||||
})
|
||||
.then((r) => {
|
||||
expect.step(`second prom resolved with ${r}`);
|
||||
});
|
||||
|
||||
def.resolve("some value");
|
||||
await tick();
|
||||
expect.verifySteps([
|
||||
"fallback first call",
|
||||
"fallback second call",
|
||||
"second prom resolved with another value",
|
||||
"first prom resolved with some value",
|
||||
]);
|
||||
|
||||
// call again to ensure that the correct value is stored in the cache
|
||||
rpcCache
|
||||
.read("table", "key", () => expect.step("should not be called"))
|
||||
.then((r) => {
|
||||
expect.step(`third prom resolved with ${r}`);
|
||||
});
|
||||
await tick();
|
||||
expect.verifySteps(["third prom resolved with another value"]);
|
||||
});
|
||||
|
||||
test("RamCache: pending request and call to invalidate, update callbacks", async () => {
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
|
||||
// populate the cache
|
||||
rpcCache.read("table", "key", () => {
|
||||
expect.step("first call: fallback");
|
||||
return Promise.resolve("initial value");
|
||||
});
|
||||
await tick();
|
||||
expect.verifySteps(["first call: fallback"]);
|
||||
|
||||
// read cache again, with update callback
|
||||
const def = new Deferred();
|
||||
rpcCache
|
||||
.read(
|
||||
"table",
|
||||
"key",
|
||||
() => {
|
||||
expect.step("second call: fallback");
|
||||
return def;
|
||||
},
|
||||
{
|
||||
callback: (newValue) => expect.step(`second call: callback ${newValue}`),
|
||||
update: "always",
|
||||
}
|
||||
)
|
||||
.then((r) => expect.step(`second call: resolved with ${r}`));
|
||||
// read it twice, s.t. there's a pending request
|
||||
rpcCache
|
||||
.read(
|
||||
"table",
|
||||
"key",
|
||||
() => {
|
||||
expect.step("should not be called as there's a pending request");
|
||||
},
|
||||
{
|
||||
callback: (newValue) => expect.step(`third call: callback ${newValue}`),
|
||||
update: "always",
|
||||
}
|
||||
)
|
||||
.then((r) => {
|
||||
expect.step(`third call: resolved with ${r}`);
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect.verifySteps([
|
||||
"second call: fallback",
|
||||
"second call: resolved with initial value",
|
||||
"third call: resolved with initial value",
|
||||
]);
|
||||
|
||||
rpcCache.invalidate();
|
||||
// sanity check to ensure that cache has been invalidated
|
||||
rpcCache.read("table", "key", () => {
|
||||
expect.step("fourth call: fallback");
|
||||
return Promise.resolve("value after invalidation");
|
||||
});
|
||||
expect.verifySteps(["fourth call: fallback"]);
|
||||
|
||||
// resolve def => update callbacks of requests 2 and 3 must be called
|
||||
def.resolve("updated value");
|
||||
await tick();
|
||||
expect.verifySteps([
|
||||
"second call: callback updated value",
|
||||
"third call: callback updated value",
|
||||
]);
|
||||
});
|
||||
|
||||
test("RamCache: pending request and call to invalidate, update callbacks in error", async () => {
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
|
||||
const defs = [new Deferred(), new Deferred()];
|
||||
rpcCache
|
||||
.read("table", "key", () => {
|
||||
expect.step("first call: fallback (error)");
|
||||
return defs[0]; // will be rejected
|
||||
})
|
||||
.catch((e) => expect.step(`first call: rejected with ${e}`));
|
||||
|
||||
// invalidate cache and read again
|
||||
rpcCache.invalidate();
|
||||
rpcCache
|
||||
.read("table", "key", () => {
|
||||
expect.step("second call: fallback");
|
||||
return defs[1];
|
||||
})
|
||||
.then((r) => expect.step(`second call: resolved with ${r}`));
|
||||
await tick();
|
||||
|
||||
expect.verifySteps(["first call: fallback (error)", "second call: fallback"]);
|
||||
|
||||
// reject first def
|
||||
defs[0].reject("my_error");
|
||||
await tick();
|
||||
expect.verifySteps(["first call: rejected with my_error"]);
|
||||
|
||||
// read again, should retrieve same prom as second call which is still pending
|
||||
rpcCache
|
||||
.read("table", "key", () => expect.step("should not be called"))
|
||||
.then((r) => expect.step(`third call: resolved with ${r}`));
|
||||
await tick();
|
||||
expect.verifySteps([]);
|
||||
|
||||
// read again, should retrieve same prom as second call which is still pending (update "always")
|
||||
rpcCache
|
||||
.read("table", "key", () => expect.step("should not be called"), { update: "always" })
|
||||
.then((r) => expect.step(`fourth call: resolved with ${r}`));
|
||||
await tick();
|
||||
expect.verifySteps([]);
|
||||
|
||||
// resolve second def
|
||||
defs[1].resolve("updated value");
|
||||
await tick();
|
||||
expect.verifySteps([
|
||||
"second call: resolved with updated value",
|
||||
"third call: resolved with updated value",
|
||||
"fourth call: resolved with updated value",
|
||||
]);
|
||||
});
|
||||
|
||||
test("DiskCache: multiple consecutive calls, empty cache", async () => {
|
||||
// The fist call to rpcCache.read saves the promise to the RAM cache.
|
||||
// Each next call (before the end of the first call) retrieves the same result as the first call
|
||||
// without executing the fallback.
|
||||
// The callback of each call is executed.
|
||||
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
|
|
@ -586,7 +817,7 @@ test("DiskCache: multiple consecutive calls, call once fallback", async () => {
|
|||
},
|
||||
{
|
||||
callback: () => {
|
||||
expect.step("callback " + id++);
|
||||
expect.step(`callback ${++id}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
@ -595,10 +826,221 @@ test("DiskCache: multiple consecutive calls, call once fallback", async () => {
|
|||
rpcCacheRead();
|
||||
rpcCacheRead();
|
||||
rpcCacheRead();
|
||||
rpcCacheRead();
|
||||
|
||||
expect.verifySteps(["fallback"]);
|
||||
def.resolve({ test: 123 });
|
||||
await microTick();
|
||||
await tick();
|
||||
|
||||
expect.verifySteps(["fallback", "callback 0", "callback 1", "callback 2", "callback 3"]);
|
||||
expect.verifySteps(["callback 1", "callback 2", "callback 3"]);
|
||||
});
|
||||
|
||||
test("DiskCache: multiple consecutive calls, value already in disk cache", async () => {
|
||||
// The first call to rpcCache.read saves the promise to the RAM cache.
|
||||
// Each next call (before the end of the first call) retrieves the same result as the first call.
|
||||
// Each call receives as value the disk value, then each callback is executed.
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
const def = new Deferred();
|
||||
|
||||
// fill the cache
|
||||
await rpcCache.read("table", "key", () => Promise.resolve({ test: 123 }), {
|
||||
type: "disk",
|
||||
});
|
||||
await tick();
|
||||
expect(rpcCache.indexedDB.mockIndexedDB.table.key.ciphertext).toBe(
|
||||
`encrypted data:{"test":123}`
|
||||
);
|
||||
expect(await promiseState(rpcCache.ramCache.ram.table.key)).toEqual({
|
||||
status: "fulfilled",
|
||||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
|
||||
const rpcCacheRead = (id) =>
|
||||
rpcCache.read(
|
||||
"table",
|
||||
"key",
|
||||
() => {
|
||||
expect.step(`fallback ${id}`);
|
||||
return def;
|
||||
},
|
||||
{
|
||||
type: "disk",
|
||||
callback: (result, hasChanged) => {
|
||||
expect.step(
|
||||
`callback ${id}: ${JSON.stringify(result)} ${hasChanged ? "(changed)" : ""}`
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
rpcCacheRead(1).then((result) => expect.step("res call 1: " + JSON.stringify(result)));
|
||||
await tick();
|
||||
rpcCacheRead(2).then((result) => expect.step("res call 2: " + JSON.stringify(result)));
|
||||
await tick();
|
||||
rpcCacheRead(3).then((result) => expect.step("res call 3: " + JSON.stringify(result)));
|
||||
await tick();
|
||||
|
||||
expect.verifySteps([
|
||||
"fallback 1",
|
||||
'res call 1: {"test":123}',
|
||||
'res call 2: {"test":123}',
|
||||
'res call 3: {"test":123}',
|
||||
]);
|
||||
|
||||
def.resolve({ test: 456 });
|
||||
await tick();
|
||||
expect.verifySteps([
|
||||
'callback 1: {"test":456} (changed)',
|
||||
'callback 2: {"test":456} (changed)',
|
||||
'callback 3: {"test":456} (changed)',
|
||||
]);
|
||||
});
|
||||
|
||||
test("DiskCache: multiple consecutive calls, fallback fails", async () => {
|
||||
// The first call to rpcCache.read saves the promise to the RAM cache.
|
||||
// Each next call (before the end of the first call) retrieves the same result as the first call.
|
||||
// The fallback fails.
|
||||
// Each call receives as value the disk value, callbacks aren't executed.
|
||||
expect.errors(1);
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
const def = new Deferred();
|
||||
|
||||
// fill the cache
|
||||
await rpcCache.read("table", "key", () => Promise.resolve({ test: 123 }), {
|
||||
type: "disk",
|
||||
});
|
||||
await tick();
|
||||
expect(rpcCache.indexedDB.mockIndexedDB.table.key.ciphertext).toBe(
|
||||
`encrypted data:{"test":123}`
|
||||
);
|
||||
expect(await promiseState(rpcCache.ramCache.ram.table.key)).toEqual({
|
||||
status: "fulfilled",
|
||||
value: { test: 123 },
|
||||
});
|
||||
|
||||
// simulate a reload (clear ramCache)
|
||||
rpcCache.ramCache.invalidate();
|
||||
expect(rpcCache.ramCache.ram).toEqual({});
|
||||
|
||||
const rpcCacheRead = (id) =>
|
||||
rpcCache.read(
|
||||
"table",
|
||||
"key",
|
||||
() => {
|
||||
expect.step(`fallback ${id}`);
|
||||
return def;
|
||||
},
|
||||
{
|
||||
type: "disk",
|
||||
callback: () => {
|
||||
expect.step("callback (should not be executed)");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
rpcCacheRead(1).then((result) => expect.step("res call 1: " + JSON.stringify(result)));
|
||||
await tick();
|
||||
rpcCacheRead(2).then((result) => expect.step("res call 2: " + JSON.stringify(result)));
|
||||
await tick();
|
||||
rpcCacheRead(3).then((result) => expect.step("res call 3: " + JSON.stringify(result)));
|
||||
await tick();
|
||||
|
||||
expect.verifySteps([
|
||||
"fallback 1",
|
||||
'res call 1: {"test":123}',
|
||||
'res call 2: {"test":123}',
|
||||
'res call 3: {"test":123}',
|
||||
]);
|
||||
|
||||
def.reject(new Error("my RPCError"));
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect.verifySteps([]);
|
||||
expect.verifyErrors(["my RPCError"]);
|
||||
});
|
||||
|
||||
test("DiskCache: multiple consecutive calls, empty cache, fallback fails", async () => {
|
||||
// The first call to rpcCache.read saves the promise to the RAM cache. That promise will be
|
||||
// rejected.
|
||||
// Each next call (before the end of the first call) retrieves the same result as the first call.
|
||||
// The fallback fails.
|
||||
// Each call receives the error.
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
const def = new Deferred();
|
||||
|
||||
const rpcCacheRead = (id) =>
|
||||
rpcCache.read(
|
||||
"table",
|
||||
"key",
|
||||
() => {
|
||||
expect.step(`fallback ${id}`);
|
||||
return def;
|
||||
},
|
||||
{
|
||||
type: "disk",
|
||||
callback: () => {
|
||||
expect.step("callback (should not be executed)");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
rpcCacheRead(1).catch((error) => expect.step(`error call 1: ${error.message}`));
|
||||
await tick();
|
||||
rpcCacheRead(2).catch((error) => expect.step(`error call 2: ${error.message}`));
|
||||
await tick();
|
||||
rpcCacheRead(3).catch((error) => expect.step(`error call 3: ${error.message}`));
|
||||
await tick();
|
||||
|
||||
expect.verifySteps(["fallback 1"]);
|
||||
|
||||
def.reject(new Error("my RPCError"));
|
||||
await tick();
|
||||
|
||||
expect.verifySteps([
|
||||
"error call 1: my RPCError",
|
||||
"error call 2: my RPCError",
|
||||
"error call 3: my RPCError",
|
||||
]);
|
||||
});
|
||||
|
||||
test("DiskCache: write throws an IDBQuotaExceededError", async () => {
|
||||
patchWithCleanup(IndexedDB.prototype, {
|
||||
deleteDatabase() {
|
||||
expect.step("delete db");
|
||||
},
|
||||
write() {
|
||||
expect.step("write");
|
||||
return Promise.reject(new IDBQuotaExceededError());
|
||||
},
|
||||
});
|
||||
|
||||
const rpcCache = new RPCCache(
|
||||
"mockRpc",
|
||||
1,
|
||||
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
|
||||
);
|
||||
|
||||
const fallback = () => {
|
||||
expect.step(`fallback`);
|
||||
return Promise.resolve("value");
|
||||
};
|
||||
await rpcCache.read("table", "key", fallback, { type: "disk" });
|
||||
|
||||
await expect.waitForSteps(["fallback", "write", "delete db"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -354,3 +354,64 @@ test("icons can be given for each page tab", async () => {
|
|||
expect(".nav-item:nth-child(3) i").toHaveClass("fa-pencil");
|
||||
expect(".nav-item:nth-child(3)").toHaveText("page3");
|
||||
});
|
||||
|
||||
test("switch notebook page after async work", async () => {
|
||||
let { promise, resolve } = Promise.withResolvers();
|
||||
class Page extends Component {
|
||||
static template = xml`<h3>Coucou</h3>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<Notebook pages="this.pages" onWillActivatePage="() => this.onWillActivatePage()"/>`;
|
||||
static components = { Notebook };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.pages = [
|
||||
{
|
||||
Component: Page,
|
||||
index: 1,
|
||||
title: "Page 1",
|
||||
},
|
||||
{
|
||||
Component: Page,
|
||||
index: 2,
|
||||
title: "Page 2",
|
||||
},
|
||||
];
|
||||
}
|
||||
onWillActivatePage() {
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
const h3Capture1 = queryFirst("h3");
|
||||
expect(h3Capture1).toBeInstanceOf(HTMLElement);
|
||||
|
||||
await click(".o_notebook_headers .nav-item:nth-child(2) a");
|
||||
await animationFrame();
|
||||
// async work is not finished
|
||||
const h3Capture2 = queryFirst("h3");
|
||||
expect(h3Capture2).toBe(h3Capture1);
|
||||
|
||||
resolve(true);
|
||||
await animationFrame();
|
||||
// async work completed successfully
|
||||
const h3Capture3 = queryFirst("h3");
|
||||
expect(h3Capture3).toBeInstanceOf(HTMLElement);
|
||||
expect(h3Capture3).not.toBe(h3Capture1);
|
||||
|
||||
({ promise, resolve } = Promise.withResolvers());
|
||||
await click(".o_notebook_headers .nav-item:nth-child(1) a");
|
||||
await animationFrame();
|
||||
// async work is not finished
|
||||
const h3Capture4 = queryFirst("h3");
|
||||
expect(h3Capture4).toBe(h3Capture3);
|
||||
|
||||
resolve(false);
|
||||
await animationFrame();
|
||||
// async work resolved with false, preventing the page change
|
||||
const h3Capture5 = queryFirst("h3");
|
||||
expect(h3Capture5).toBe(h3Capture3);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
import { expect, getFixture, test } from "@odoo/hoot";
|
||||
import { beforeEach, expect, getFixture, test } from "@odoo/hoot";
|
||||
import { queryOne, queryRect, resize, scroll, waitFor } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, useRef, xml } from "@odoo/owl";
|
||||
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { Component, useRef, useState, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
defineStyle,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { Popover } from "@web/core/popover/popover";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
class Content extends Component {
|
||||
static props = ["*"];
|
||||
static template = xml`<div id="popover">Popover Content</div>`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(Popover.defaultProps, {
|
||||
animation: false,
|
||||
arrow: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("popover can have custom class", async () => {
|
||||
await mountWithCleanup(Popover, {
|
||||
props: {
|
||||
|
|
@ -238,7 +249,7 @@ test("within iframe", async () => {
|
|||
close: () => {},
|
||||
target: popoverTarget,
|
||||
component: Content,
|
||||
animation: false,
|
||||
arrow: true,
|
||||
onPositioned: (_, { direction }) => {
|
||||
expect.step(direction);
|
||||
},
|
||||
|
|
@ -262,7 +273,7 @@ test("within iframe", async () => {
|
|||
|
||||
await scroll(popoverTarget.ownerDocument.documentElement, { y: 100 }, { scrollable: false });
|
||||
await animationFrame();
|
||||
expect.verifySteps(["bottom"]);
|
||||
expect.verifySteps(["bottom", "bottom"]);
|
||||
popoverBox = comp.popoverRef.el.getBoundingClientRect();
|
||||
expectedTop -= 100;
|
||||
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
|
||||
|
|
@ -359,7 +370,12 @@ test("popover with arrow and onPositioned", async () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect.verifySteps(["onPositioned (from override)", "onPositioned (from props)"]);
|
||||
expect.verifySteps([
|
||||
"onPositioned (from override)",
|
||||
"onPositioned (from props)", // On mounted
|
||||
"onPositioned (from override)",
|
||||
"onPositioned (from props)", // arrow repositionning -> triggers resize observer
|
||||
]);
|
||||
expect(".o_popover").toHaveClass("o_popover popover mw-100 bs-popover-auto");
|
||||
expect(".o_popover").toHaveAttribute("data-popper-placement", "bottom");
|
||||
expect(".o_popover > .popover-arrow").toHaveClass("position-absolute z-n1");
|
||||
|
|
@ -389,10 +405,50 @@ test("popover closes when navigating", async () => {
|
|||
expect.verifySteps(["HTML", "close"]);
|
||||
});
|
||||
|
||||
test("popover position is updated when the content dimensions change", async () => {
|
||||
class DynamicContent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
showMore: false,
|
||||
});
|
||||
}
|
||||
static props = ["*"];
|
||||
static template = xml`<div id="popover">
|
||||
Click on this <button t-on-click="() => this.state.showMore = true">button</button> to read more
|
||||
<span t-if="state.showMore">
|
||||
This tooltip gives your more information on this topic!
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(/* xml */ `
|
||||
<div class="popover-target" style="width: 50px; height: 50px;" />
|
||||
`);
|
||||
|
||||
await mountWithCleanup(Popover, {
|
||||
props: {
|
||||
close: () => {},
|
||||
target: queryOne(".popover-target"),
|
||||
position: "bottom-start",
|
||||
component: DynamicContent,
|
||||
onPositioned() {
|
||||
expect.step("onPositioned");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
await runAllTimers();
|
||||
expect.verifySteps(["onPositioned", "onPositioned"]);
|
||||
await contains("#popover button").click();
|
||||
expect("#popover span").toHaveCount(1);
|
||||
await expect.waitForSteps(["onPositioned"]);
|
||||
});
|
||||
|
||||
test("arrow follows target and can get sucked", async () => {
|
||||
let container;
|
||||
patch(Popover, { animationTime: 0 });
|
||||
patch(Popover.prototype, {
|
||||
patchWithCleanup(Popover.defaultProps, { arrow: true });
|
||||
patchWithCleanup(Popover.prototype, {
|
||||
get positioningOptions() {
|
||||
return {
|
||||
...super.positioningOptions,
|
||||
|
|
@ -472,3 +528,29 @@ test("arrow follows target and can get sucked", async () => {
|
|||
expect(".popover-arrow").toHaveClass("sucked");
|
||||
expect(".popover-arrow").not.toBeVisible();
|
||||
});
|
||||
|
||||
test("popover can animate", async () => {
|
||||
patchWithCleanup(window.Element.prototype, {
|
||||
animate() {
|
||||
expect(this).toHaveClass("o_popover");
|
||||
expect.step("animated");
|
||||
return super.animate(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
await mountWithCleanup(Popover, {
|
||||
props: {
|
||||
close: () => {},
|
||||
target: getFixture(),
|
||||
animation: true,
|
||||
component: Content,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
|
||||
expect.verifySteps(["animated"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -949,9 +949,10 @@ const CONTAINER_STYLE_MAP = {
|
|||
w125: { width: "125px" }, // width of popper + 1/2 target
|
||||
};
|
||||
|
||||
function getRepositionTest(from, to, containerStyleChanges) {
|
||||
function getRepositionTest(from, to, containerStyleChanges, extendedFlipping = false) {
|
||||
return async () => {
|
||||
const TestComp = getTestComponent({
|
||||
extendedFlipping,
|
||||
position: from,
|
||||
onPositioned: (el, { direction, variant }) => {
|
||||
expect.step(`${direction}-${variant}`);
|
||||
|
|
@ -975,156 +976,219 @@ function getRepositionTest(from, to, containerStyleChanges) {
|
|||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
test("reposition from top-start to top", getRepositionTest("top-start", "bottom-start", "top"));
|
||||
test(
|
||||
"reposition from top-start to bottom-start",
|
||||
getRepositionTest("top-start", "bottom-start", "top")
|
||||
);
|
||||
test(
|
||||
"reposition from top-start to bottom-end",
|
||||
"reposition from top-start to top right",
|
||||
getRepositionTest("top-start", "bottom-end", "top right")
|
||||
);
|
||||
test(
|
||||
"reposition from top-start to top-start",
|
||||
"reposition from top-start to slimfit bottom",
|
||||
getRepositionTest("top-start", "top-start", "slimfit bottom")
|
||||
);
|
||||
test("reposition from top-start to top-end", getRepositionTest("top-start", "top-end", "right"));
|
||||
test("reposition from top-start to right", getRepositionTest("top-start", "top-end", "right"));
|
||||
// -----------------------------------------------------------------------------
|
||||
test("reposition from top-middle to top", getRepositionTest("top-middle", "bottom-middle", "top"));
|
||||
test(
|
||||
"reposition from top-middle to bottom-middle",
|
||||
getRepositionTest("top-middle", "bottom-middle", "top")
|
||||
);
|
||||
test(
|
||||
"reposition from top-middle to top-middle",
|
||||
"reposition from top-middle to slimfit bottom",
|
||||
getRepositionTest("top-middle", "top-middle", "slimfit bottom")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from top-end to bottom-start",
|
||||
"reposition from top-end to top left",
|
||||
getRepositionTest("top-end", "bottom-start", "top left")
|
||||
);
|
||||
test("reposition from top-end to bottom-end", getRepositionTest("top-end", "bottom-end", "top"));
|
||||
test("reposition from top-end to top-start", getRepositionTest("top-end", "top-start", "left"));
|
||||
test("reposition from top-end to top", getRepositionTest("top-end", "bottom-end", "top"));
|
||||
test("reposition from top-end to left", getRepositionTest("top-end", "top-start", "left"));
|
||||
test(
|
||||
"reposition from top-end to top-end",
|
||||
"reposition from top-end to slimfit bottom",
|
||||
getRepositionTest("top-end", "top-end", "slimfit bottom")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test("reposition from left-start to left", getRepositionTest("left-start", "right-start", "left"));
|
||||
test(
|
||||
"reposition from left-start to right-start",
|
||||
getRepositionTest("left-start", "right-start", "left")
|
||||
);
|
||||
test(
|
||||
"reposition from left-start to right-end",
|
||||
"reposition from left-start to left bottom",
|
||||
getRepositionTest("left-start", "right-end", "left bottom")
|
||||
);
|
||||
test(
|
||||
"reposition from left-start to left-start",
|
||||
"reposition from left-start to slimfit top",
|
||||
getRepositionTest("left-start", "left-start", "slimfit top")
|
||||
);
|
||||
test(
|
||||
"reposition from left-start to left-end",
|
||||
getRepositionTest("left-start", "left-end", "bottom")
|
||||
);
|
||||
test("reposition from left-start to bottom", getRepositionTest("left-start", "left-end", "bottom"));
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from left-middle to right-middle",
|
||||
"reposition from left-middle to left",
|
||||
getRepositionTest("left-middle", "right-middle", "left")
|
||||
);
|
||||
test(
|
||||
"reposition from left-middle to left-middle",
|
||||
"reposition from left-middle to slimfit bottom",
|
||||
getRepositionTest("left-middle", "left-middle", "slimfit bottom")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from left-end to right-start",
|
||||
"reposition from left-end to left top",
|
||||
getRepositionTest("left-end", "right-start", "left top")
|
||||
);
|
||||
test("reposition from left-end to right-end", getRepositionTest("left-end", "right-end", "left"));
|
||||
test("reposition from left-end to left-start", getRepositionTest("left-end", "left-start", "top"));
|
||||
test("reposition from left-end to left", getRepositionTest("left-end", "right-end", "left"));
|
||||
test("reposition from left-end to top", getRepositionTest("left-end", "left-start", "top"));
|
||||
test(
|
||||
"reposition from left-end to left-end",
|
||||
"reposition from left-end to slimfit bottom",
|
||||
getRepositionTest("left-end", "left-end", "slimfit bottom")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from bottom-start to bottom-start",
|
||||
"reposition from bottom-start to slimfit top",
|
||||
getRepositionTest("bottom-start", "bottom-start", "slimfit top")
|
||||
);
|
||||
test(
|
||||
"reposition from bottom-start to bottom-end",
|
||||
"reposition from bottom-start to right",
|
||||
getRepositionTest("bottom-start", "bottom-end", "right")
|
||||
);
|
||||
test(
|
||||
"reposition from bottom-start to top-start",
|
||||
"reposition from bottom-start to bottom",
|
||||
getRepositionTest("bottom-start", "top-start", "bottom")
|
||||
);
|
||||
test(
|
||||
"reposition from bottom-start to top-end",
|
||||
"reposition from bottom-start to bottom right",
|
||||
getRepositionTest("bottom-start", "top-end", "bottom right")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from bottom-middle to bottom-middle",
|
||||
"reposition from bottom-middle to slimfit top",
|
||||
getRepositionTest("bottom-middle", "bottom-middle", "slimfit top")
|
||||
);
|
||||
test(
|
||||
"reposition from bottom-middle to top-middle",
|
||||
"reposition from bottom-middle to bottom",
|
||||
getRepositionTest("bottom-middle", "top-middle", "bottom")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test("reposition from bottom-end to left", getRepositionTest("bottom-end", "bottom-start", "left"));
|
||||
test(
|
||||
"reposition from bottom-end to bottom-start",
|
||||
getRepositionTest("bottom-end", "bottom-start", "left")
|
||||
);
|
||||
test(
|
||||
"reposition from bottom-end to bottom-end",
|
||||
"reposition from bottom-end to slimfit top",
|
||||
getRepositionTest("bottom-end", "bottom-end", "slimfit top")
|
||||
);
|
||||
test(
|
||||
"reposition from bottom-end to top-start",
|
||||
"reposition from bottom-end to bottom left",
|
||||
getRepositionTest("bottom-end", "top-start", "bottom left")
|
||||
);
|
||||
test("reposition from bottom-end to top-end", getRepositionTest("bottom-end", "top-end", "bottom"));
|
||||
test("reposition from bottom-end to bottom", getRepositionTest("bottom-end", "top-end", "bottom"));
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from right-start to right-start",
|
||||
"reposition from right-start to slimfit top",
|
||||
getRepositionTest("right-start", "right-start", "slimfit top")
|
||||
);
|
||||
test(
|
||||
"reposition from right-start to right-end",
|
||||
"reposition from right-start to bottom",
|
||||
getRepositionTest("right-start", "right-end", "bottom")
|
||||
);
|
||||
test(
|
||||
"reposition from right-start to left-start",
|
||||
"reposition from right-start to right",
|
||||
getRepositionTest("right-start", "left-start", "right")
|
||||
);
|
||||
test(
|
||||
"reposition from right-start to left-end",
|
||||
"reposition from right-start to right bottom",
|
||||
getRepositionTest("right-start", "left-end", "right bottom")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test(
|
||||
"reposition from right-middle to right-middle",
|
||||
"reposition from right-middle to slimfit bottom",
|
||||
getRepositionTest("right-middle", "right-middle", "slimfit bottom")
|
||||
);
|
||||
test(
|
||||
"reposition from right-middle to left-middle",
|
||||
"reposition from right-middle to right",
|
||||
getRepositionTest("right-middle", "left-middle", "right")
|
||||
);
|
||||
// -----------------------------------------------------------------------------
|
||||
test("reposition from right-end to top", getRepositionTest("right-end", "right-start", "top"));
|
||||
test(
|
||||
"reposition from right-end to right-start",
|
||||
getRepositionTest("right-end", "right-start", "top")
|
||||
);
|
||||
test(
|
||||
"reposition from right-end to right-end",
|
||||
"reposition from right-end to slimfit bottom",
|
||||
getRepositionTest("right-end", "right-end", "slimfit bottom")
|
||||
);
|
||||
test(
|
||||
"reposition from right-end to left-start",
|
||||
"reposition from right-end to right top",
|
||||
getRepositionTest("right-end", "left-start", "right top")
|
||||
);
|
||||
test("reposition from right-end to left-end", getRepositionTest("right-end", "left-end", "right"));
|
||||
test("reposition from right-end to right", getRepositionTest("right-end", "left-end", "right"));
|
||||
// Reposition with all flipping directions allowed
|
||||
test(
|
||||
"extended reposition from top-start to slimfit bottom",
|
||||
getRepositionTest("top-start", "center-start", "slimfit bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from top-start to right",
|
||||
getRepositionTest("top-start", "top-end", "right", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from top-middle to slimfit bottom",
|
||||
getRepositionTest("top-middle", "center-middle", "slimfit bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from top-end to left",
|
||||
getRepositionTest("top-end", "top-start", "left", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from top-end to slimfit bottom",
|
||||
getRepositionTest("top-end", "center-end", "slimfit bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from left-start to slimfit top",
|
||||
getRepositionTest("left-start", "center-start", "slimfit top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from left-start to bottom",
|
||||
getRepositionTest("left-start", "left-end", "bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from left-middle to slimfit bottom",
|
||||
getRepositionTest("left-middle", "center-middle", "slimfit bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from left-end to top",
|
||||
getRepositionTest("left-end", "left-start", "top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from left-end to slimfit bottom",
|
||||
getRepositionTest("left-end", "center-end", "slimfit bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from bottom-start to slimfit top",
|
||||
getRepositionTest("bottom-start", "center-start", "slimfit top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from bottom-start to right",
|
||||
getRepositionTest("bottom-start", "bottom-end", "right", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from bottom-middle to slimfit top",
|
||||
getRepositionTest("bottom-middle", "center-middle", "slimfit top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from bottom-end to left",
|
||||
getRepositionTest("bottom-end", "bottom-start", "left", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from bottom-end to slimfit top",
|
||||
getRepositionTest("bottom-end", "center-end", "slimfit top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from right-start to slimfit top",
|
||||
getRepositionTest("right-start", "center-start", "slimfit top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from right-start to bottom",
|
||||
getRepositionTest("right-start", "right-end", "bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from right-middle to slimfit bottom",
|
||||
getRepositionTest("right-middle", "center-middle", "slimfit bottom", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from right-end to top",
|
||||
getRepositionTest("right-end", "right-start", "top", true)
|
||||
);
|
||||
test(
|
||||
"extended reposition from right-end to slimfit bottom",
|
||||
getRepositionTest("right-end", "center-end", "slimfit bottom", true)
|
||||
);
|
||||
|
||||
function getFittingTest(position, styleAttribute) {
|
||||
return async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, getFixture, test } from "@odoo/hoot";
|
||||
import { mockFetch } from "@odoo/hoot-mock";
|
||||
import { getService, makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { getService, makeMockEnv, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
|
|
@ -17,8 +16,8 @@ const mountManifestLink = (href) => {
|
|||
test("PWA service fetches the manifest found in the page", async () => {
|
||||
await makeMockEnv();
|
||||
mountManifestLink("/web/manifest.webmanifest");
|
||||
mockFetch((route) => {
|
||||
expect.step(route);
|
||||
onRpc("/*", (request) => {
|
||||
expect.step(new URL(request.url).pathname);
|
||||
return { name: "Odoo PWA" };
|
||||
});
|
||||
const pwaService = await getService("pwa");
|
||||
|
|
@ -38,8 +37,8 @@ test("PWA installation process", async () => {
|
|||
browser.BeforeInstallPromptEvent = beforeInstallPromptEvent;
|
||||
await makeMockEnv();
|
||||
mountManifestLink("/web/manifest.scoped_app_manifest");
|
||||
mockFetch((route) => {
|
||||
expect.step(route);
|
||||
onRpc("/*", (request) => {
|
||||
expect.step(new URL(request.url).pathname);
|
||||
return { name: "My App", scope: "/scoped_app/myApp", start_url: "/scoped_app/myApp" };
|
||||
});
|
||||
patchWithCleanup(browser.localStorage, {
|
||||
|
|
|
|||
|
|
@ -88,9 +88,11 @@ test("handles resize handle at start in fixed position", async () => {
|
|||
x: window.innerWidth - 200,
|
||||
},
|
||||
});
|
||||
expect(resizablePanelEl).toHaveRect({
|
||||
width: 100 + queryRect(".o_resizable_panel_handle").width / 2,
|
||||
});
|
||||
const panelExpectedWidth = 100 + queryRect(".o_resizable_panel_handle").width / 2;
|
||||
expect(queryRect(resizablePanelEl).width).toBeWithin(
|
||||
panelExpectedWidth,
|
||||
panelExpectedWidth + 1
|
||||
);
|
||||
});
|
||||
|
||||
test("resizing the window adapts the panel", async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, getFixture, test } from "@odoo/hoot";
|
||||
import { click, on } from "@odoo/hoot-dom";
|
||||
import { tick } from "@odoo/hoot-mock";
|
||||
import { mockMatchMedia, tick } from "@odoo/hoot-mock";
|
||||
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
|
@ -1677,6 +1677,24 @@ describe("History", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Scoped apps", () => {
|
||||
test("url location is changed to /odoo if the client is not used in a standalone scoped app", async () => {
|
||||
Object.assign(browser.location, { pathname: "/scoped_app/some-path" });
|
||||
createRouter();
|
||||
router.pushState({ app_name: "some_app", path: "scoped_app/some_path" });
|
||||
await tick();
|
||||
expect(browser.location.href).toBe("https://www.hoot.test/odoo/some-path?app_name=some_app&path=scoped_app%2Fsome_path");
|
||||
});
|
||||
test("url location is preserved as /scoped_app if the client is used in a standalone scoped app", async () => {
|
||||
mockMatchMedia({ ["display-mode"]: "standalone" });
|
||||
Object.assign(browser.location, { pathname: "/scoped_app/some-path" });
|
||||
createRouter();
|
||||
router.pushState({ app_name: "some_app", path: "scoped_app/some_path" });
|
||||
await tick();
|
||||
expect(browser.location.href).toBe("https://www.hoot.test/scoped_app/some-path?app_name=some_app&path=scoped_app%2Fsome_path");
|
||||
});
|
||||
})
|
||||
|
||||
describe("Retrocompatibility", () => {
|
||||
test("parse an url with hash (key/values)", async () => {
|
||||
Object.assign(browser.location, { pathname: "/web" });
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ test("When multiSelect is enable, value is an array of values, multiple choices
|
|||
|
||||
// Select second choice
|
||||
await open();
|
||||
expect(".o_select_menu_item:nth-of-type(1).active").toHaveCount(1);
|
||||
expect(".o_select_menu_item:nth-of-type(1).selected").toHaveCount(1);
|
||||
|
||||
await editSelectMenu(".o_select_menu input", { index: 1 });
|
||||
expect.verifySteps([["a", "b"]]);
|
||||
|
|
@ -737,7 +737,7 @@ test("When multiSelect is enable, value is an array of values, multiple choices
|
|||
expect(".o_select_menu .o_tag_badge_text").toHaveCount(2);
|
||||
|
||||
await open();
|
||||
expect(".o_select_menu_item.active").toHaveCount(2);
|
||||
expect(".o_select_menu_item.selected").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("When multiSelect is enable, allow deselecting elements by clicking the selected choices inside the dropdown or by clicking the tags", async () => {
|
||||
|
|
@ -778,7 +778,7 @@ test("When multiSelect is enable, allow deselecting elements by clicking the sel
|
|||
expect(".o_select_menu .o_tag_badge_text").toHaveText("B");
|
||||
|
||||
await open();
|
||||
expect(".o_select_menu_item.active").toHaveCount(1);
|
||||
expect(".o_select_menu_item.selected").toHaveCount(1);
|
||||
|
||||
await click(".o_tag .o_delete");
|
||||
await animationFrame();
|
||||
|
|
|
|||
|
|
@ -386,3 +386,40 @@ test("touch rendering - tap-to-show", async () => {
|
|||
await animationFrame();
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("tooltip from and to child element", async () => {
|
||||
class MyComponent extends Component {
|
||||
static props = ["*"];
|
||||
static template = xml`
|
||||
<div class="no-tooltip">space</div>
|
||||
<div class="p-5" data-tooltip="hello">
|
||||
<button>Action</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(MyComponent);
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
|
||||
await pointerDown("div[data-tooltip]");
|
||||
await advanceTime(SHOW_AFTER_DELAY);
|
||||
await advanceTime(OPEN_DELAY);
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
const popover = queryOne(".o_popover");
|
||||
|
||||
await pointerDown("button");
|
||||
await advanceTime(SHOW_AFTER_DELAY);
|
||||
await advanceTime(OPEN_DELAY);
|
||||
expect(".o_popover").toHaveCount(1);
|
||||
expect(queryOne(".o_popover")).toBe(popover);
|
||||
|
||||
await pointerDown("div[data-tooltip]");
|
||||
await advanceTime(SHOW_AFTER_DELAY);
|
||||
await advanceTime(OPEN_DELAY);
|
||||
expect(queryOne(".o_popover")).toBe(popover);
|
||||
|
||||
await pointerDown(".no-tooltip");
|
||||
await advanceTime(SHOW_AFTER_DELAY);
|
||||
await advanceTime(OPEN_DELAY);
|
||||
expect(".o_popover").toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,6 +157,29 @@ test("do not become UI active element if no element to focus", async () => {
|
|||
expect(getService("ui").activeElement).toBe(document);
|
||||
});
|
||||
|
||||
test("become UI active element if no element to focus but the container is focusable", async () => {
|
||||
class MyComponent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<h1>My Component</h1>
|
||||
<input type="text" placeholder="outerUIActiveElement"/>
|
||||
<div id="idActiveElement" t-ref="delegatedRef" tabindex="-1">
|
||||
<div>
|
||||
<span> No focus element </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
useActiveElement("delegatedRef");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(MyComponent);
|
||||
expect(getService("ui").activeElement).toBe(queryOne("#idActiveElement"));
|
||||
});
|
||||
|
||||
test("UI active element: trap focus - first or last tabable changes", async () => {
|
||||
class MyComponent extends Component {
|
||||
static template = xml`
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from "@odoo/hoot";
|
|||
import { allowTranslations } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { humanSize } from "@web/core/utils/binary";
|
||||
import { resizeBlobImg } from "@web/core/utils/files";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
|
|
@ -12,3 +13,35 @@ test("humanSize", () => {
|
|||
expect(humanSize(2048)).toBe("2.00 Kb");
|
||||
expect(humanSize(2645000)).toBe("2.52 Mb");
|
||||
});
|
||||
|
||||
test("resize image", async () => {
|
||||
function buildblobImage(w, h) {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "rgb(200 0 0)";
|
||||
ctx.fillRect(0, 0, w / 2, h / 2);
|
||||
canvas.toBlob(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function blobTob64(blob) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const bigBlobImg = await buildblobImage(256, 256);
|
||||
const smallBlobImg = await buildblobImage(64, 64);
|
||||
|
||||
const resized = await resizeBlobImg(bigBlobImg, { width: 64, height: 64 });
|
||||
const smallBlobImgB64 = await blobTob64(smallBlobImg);
|
||||
expect(smallBlobImgB64).not.toBeEmpty();
|
||||
expect(await blobTob64(resized)).toBe(smallBlobImgB64);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -433,3 +433,39 @@ test("Focusing is not lost after clicking", async () => {
|
|||
await contains(".item").click();
|
||||
expect(".item").toBeFocused();
|
||||
});
|
||||
|
||||
test("allowDisconnected option", async () => {
|
||||
class List extends Component {
|
||||
static template = xml`
|
||||
<div t-ref="root" class="root">
|
||||
<button class="handle" t-if="state.hasHandle">Handle</button>
|
||||
<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 h-100" />
|
||||
</ul>
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ hasHandle: true });
|
||||
useDraggable({
|
||||
ref: useRef("root"),
|
||||
elements: ".handle",
|
||||
allowDisconnected: true,
|
||||
onDragStart: () => {
|
||||
expect.step("start");
|
||||
this.state.hasHandle = false;
|
||||
},
|
||||
onDragEnd: () => expect.step("end"),
|
||||
onDrop: () => expect.step("drop"), // should be called as allowDisconnected
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(List);
|
||||
const { moveTo, drop } = await contains(".handle").drag();
|
||||
expect.verifySteps(["start"]);
|
||||
await animationFrame();
|
||||
expect(".handle").toHaveCount(0);
|
||||
await moveTo(".item:nth-child(2)");
|
||||
await drop();
|
||||
expect.verifySteps(["drop", "end"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { htmlEscape, markup } from "@odoo/owl";
|
||||
|
||||
const Markup = markup().constructor;
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
createDocumentFragmentFromContent,
|
||||
createElementWithContent,
|
||||
|
|
@ -18,6 +16,8 @@ import {
|
|||
setElementContent,
|
||||
} from "@web/core/utils/html";
|
||||
|
||||
const Markup = markup().constructor;
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("createDocumentFragmentFromContent escapes text", () => {
|
||||
|
|
@ -47,6 +47,9 @@ test("highlightText", () => {
|
|||
expect(highlightText("b", "abcb", "hl").toString()).toBe(
|
||||
'a<span class="hl">b</span>c<span class="hl">b</span>'
|
||||
);
|
||||
expect(highlightText("b", "abbc", "hl").toString()).toBe(
|
||||
'a<span class="hl">b</span><span class="hl">b</span>c'
|
||||
);
|
||||
expect(highlightText("b", "<p>ab</p>", "hl").toString()).toBe(
|
||||
'<p>a<span class="hl">b</span></p>'
|
||||
);
|
||||
|
|
@ -111,9 +114,7 @@ test("htmlSprintf escapes list params", () => {
|
|||
markup`<span>test 1</span>`,
|
||||
`<span>test 2</span>`
|
||||
);
|
||||
expect(res.toString()).toBe(
|
||||
"<p><span>test 1</span>,<span>test 2</span></p>undefined"
|
||||
);
|
||||
expect(res.toString()).toBe("<p><span>test 1</span></p><span>test 2</span>");
|
||||
expect(res).toBeInstanceOf(Markup);
|
||||
});
|
||||
|
||||
|
|
@ -299,10 +300,10 @@ test("htmlReplaceAll with html/text does not find, keeps all", () => {
|
|||
|
||||
test("htmlReplace/htmlReplaceAll only accept functions replacement when search is a RegExp", () => {
|
||||
expect(() => htmlReplace("test", /test/, "$1")).toThrow(
|
||||
"htmlReplace: replacement must be a function when search is a RegExp."
|
||||
"htmlReplace: replacer must be a function when search is a RegExp."
|
||||
);
|
||||
expect(() => htmlReplaceAll("test", /test/, "$1")).toThrow(
|
||||
"htmlReplaceAll: replacement must be a function when search is a RegExp."
|
||||
"htmlReplaceAll: replacer must be a function when search is a RegExp."
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -324,9 +325,9 @@ test("odoomark", () => {
|
|||
expect(odoomark("**test** something else **test**").toString()).toBe(
|
||||
"<b>test</b> something else <b>test</b>"
|
||||
);
|
||||
expect(odoomark("--test--").toString()).toBe("<span class='text-muted'>test</span>");
|
||||
expect(odoomark("--test--").toString()).toBe(`<span class="text-muted">test</span>`);
|
||||
expect(odoomark("--test-- something else --test--").toString()).toBe(
|
||||
"<span class='text-muted'>test</span> something else <span class='text-muted'>test</span>"
|
||||
`<span class="text-muted">test</span> something else <span class="text-muted">test</span>`
|
||||
);
|
||||
expect(odoomark("`test`").toString()).toBe(
|
||||
`<span class="o_tag position-relative d-inline-flex align-items-center mw-100 o_badge badge rounded-pill lh-1 o_tag_color_0">test</span>`
|
||||
|
|
@ -337,7 +338,7 @@ test("odoomark", () => {
|
|||
expect(odoomark("test\ttest2").toString()).toBe(
|
||||
`test<span style="margin-left: 2em"></span>test2`
|
||||
);
|
||||
expect(odoomark("test\ntest2").toString()).toBe("test<br/>test2");
|
||||
expect(odoomark("test\ntest2").toString()).toBe("test<br>test2");
|
||||
expect(odoomark("<p>**test**</p>").toString()).toBe("<p><b>test</b></p>");
|
||||
expect(odoomark(markup`<p>**test**</p>`).toString()).toBe("<p><b>test</b></p>");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -252,6 +252,42 @@ test("floatIsZero", () => {
|
|||
});
|
||||
|
||||
describe("formatFloat", () => {
|
||||
test("precision", () => {
|
||||
patchWithCleanup(localization, {
|
||||
decimalPoint: ".",
|
||||
grouping: [3, 0],
|
||||
thousandsSep: ",",
|
||||
});
|
||||
|
||||
let options = {};
|
||||
expect(formatFloat(3, options)).toBe("3.00");
|
||||
expect(formatFloat(3.1, options)).toBe("3.10");
|
||||
expect(formatFloat(3.12, options)).toBe("3.12");
|
||||
expect(formatFloat(3.129, options)).toBe("3.13");
|
||||
|
||||
options = { digits: [15, 3] };
|
||||
expect(formatFloat(3, options)).toBe("3.000");
|
||||
expect(formatFloat(3.1, options)).toBe("3.100");
|
||||
expect(formatFloat(3.123, options)).toBe("3.123");
|
||||
expect(formatFloat(3.1239, options)).toBe("3.124");
|
||||
|
||||
options = { minDigits: 3 };
|
||||
expect(formatFloat(0, options)).toBe("0.000");
|
||||
expect(formatFloat(3, options)).toBe("3.000");
|
||||
expect(formatFloat(3.1, options)).toBe("3.100");
|
||||
expect(formatFloat(3.123, options)).toBe("3.123");
|
||||
expect(formatFloat(3.1239, options)).toBe("3.1239");
|
||||
expect(formatFloat(3.1231239, options)).toBe("3.123124");
|
||||
expect(formatFloat(1234567890.1234567890, options)).toBe("1,234,567,890.12346");
|
||||
|
||||
options = { minDigits: 3, digits: [15, 4] };
|
||||
expect(formatFloat(3, options)).toBe("3.000");
|
||||
expect(formatFloat(3.1, options)).toBe("3.100");
|
||||
expect(formatFloat(3.123, options)).toBe("3.123");
|
||||
expect(formatFloat(3.1239, options)).toBe("3.1239");
|
||||
expect(formatFloat(3.1234567, options)).toBe("3.1235");
|
||||
});
|
||||
|
||||
test("localized", () => {
|
||||
patchWithCleanup(localization, {
|
||||
decimalPoint: ".",
|
||||
|
|
@ -343,7 +379,7 @@ describe("formatFloat", () => {
|
|||
expect(formatFloat(value, options)).toBe(resHuman);
|
||||
});
|
||||
|
||||
Object.assign(options, { humanReadable: false });
|
||||
Object.assign(options, { humanReadable: false, digits: undefined, minDigits: undefined});
|
||||
expect(formatFloat(-0.0000001, options)).toBe("0.00");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ test("fuzzyLookup", () => {
|
|||
{ name: "Jane Yellow" },
|
||||
{ name: "Brandon Green" },
|
||||
{ name: "Jérémy Red" },
|
||||
{ name: "สมศรี จู่โจม" },
|
||||
];
|
||||
expect(fuzzyLookup("ba", data, (d) => d.name)).toEqual([
|
||||
{ name: "Brandon Green" },
|
||||
|
|
@ -25,6 +26,7 @@ test("fuzzyLookup", () => {
|
|||
{ name: "Jane Yellow" },
|
||||
]);
|
||||
expect(fuzzyLookup("", data, (d) => d.name)).toEqual([]);
|
||||
expect(fuzzyLookup("สมศ", data, (d) => d.name)).toEqual([{ name: "สมศรี จู่โจม" }]);
|
||||
});
|
||||
|
||||
test("fuzzyTest", () => {
|
||||
|
|
|
|||
|
|
@ -628,3 +628,53 @@ test("clone option", async () => {
|
|||
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)");
|
||||
expect(".placeholder:not(.item)").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dragged element is removed from the DOM while being dragged", async () => {
|
||||
class List extends Component {
|
||||
static props = ["*"];
|
||||
static template = xml`
|
||||
<div t-ref="root" class="root">
|
||||
<ul class="list">
|
||||
<li t-foreach="state.items" t-as="i" t-key="i" t-esc="i" class="item" />
|
||||
</ul>
|
||||
</div>`;
|
||||
setup() {
|
||||
this.state = useState({
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
useSortable({
|
||||
ref: useRef("root"),
|
||||
elements: ".item",
|
||||
onDragStart() {
|
||||
expect.step("start");
|
||||
},
|
||||
onDragEnd() {
|
||||
expect.step("end");
|
||||
},
|
||||
onDrop() {
|
||||
expect.step("drop"); // should not be called
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const list = await mountWithCleanup(List);
|
||||
|
||||
expect(".item:visible").toHaveCount(3);
|
||||
expect(".o_dragged").toHaveCount(0);
|
||||
expect.verifySteps([]);
|
||||
|
||||
const { drop, moveTo } = await contains(".item:first-child").drag();
|
||||
expect(".o_dragged").toHaveCount(1);
|
||||
expect.verifySteps(["start"]);
|
||||
|
||||
await moveTo(".item:nth-child(2)");
|
||||
expect(".o_dragged").toHaveCount(1);
|
||||
|
||||
list.state.items = [3, 4];
|
||||
await animationFrame();
|
||||
expect(".item:visible").toHaveCount(2);
|
||||
expect(".o_dragged").toHaveCount(0);
|
||||
await drop();
|
||||
expect.verifySteps(["end"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ describe("sprintf", () => {
|
|||
expect(sprintf("Hello!")).toBe("Hello!");
|
||||
expect(sprintf("Hello %s!")).toBe("Hello %s!");
|
||||
expect(sprintf("Hello %(value)s!")).toBe("Hello %(value)s!");
|
||||
expect(sprintf("Hello %(value)s!", {})).toBe("Hello !");
|
||||
});
|
||||
|
||||
test("properly formats numbers", () => {
|
||||
|
|
@ -108,6 +109,16 @@ describe("sprintf", () => {
|
|||
const vals = { one: _t("one"), two: _t("two") };
|
||||
expect(sprintf("Hello %(two)s %(one)s", vals)).toBe("Hello två en");
|
||||
});
|
||||
|
||||
test("supports escaped '%' signs", () => {
|
||||
expect(sprintf("Escape %s", "%s")).toBe("Escape %s");
|
||||
expect(sprintf("Escape %%s", "this!")).toBe("Escape %s");
|
||||
expect(sprintf("Escape %%%s", "this!")).toBe("Escape %this!");
|
||||
expect(sprintf("Escape %%%%s!", "this")).toBe("Escape %%s!");
|
||||
expect(sprintf("Escape %s%s", "this!")).toBe("Escape this!");
|
||||
expect(sprintf("Escape %%s%s", "this!")).toBe("Escape %sthis!");
|
||||
expect(sprintf("Escape %foo!", "this")).toBe("Escape %foo!");
|
||||
});
|
||||
});
|
||||
|
||||
test("capitalize", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue