vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:09 +02:00
parent 5454004ff9
commit d7f6d2725e
979 changed files with 428093 additions and 0 deletions

View file

@ -0,0 +1,190 @@
/* global ace */
import { expect, getFixture, test } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
editAce,
fields,
models,
mountView,
onRpc,
pagerNext,
preloadBundle,
preventResizeObserverError,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_name = "res.partner";
_rec_name = "display_name";
foo = fields.Text({ default: "My little Foo Value" });
_records = [
{ id: 1, foo: "yop" },
{ id: 2, foo: "blip" },
];
}
defineModels([Partner]);
preloadBundle("web.ace_lib");
preventResizeObserverError();
test("AceEditorField on text fields works", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="foo" widget="code"/></form>`,
});
expect(window).toInclude("ace", { message: "the ace library should be loaded" });
expect(`div.ace_content`).toHaveCount(1);
expect(".o_field_code").toHaveText(/yop/);
});
test("AceEditorField mark as dirty as soon at onchange", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="foo" widget="code"/></form>`,
});
const aceEditor = queryOne`.ace_editor`;
expect(aceEditor).toHaveText(/yop/);
// edit the foo field
ace.edit(aceEditor).setValue("blip");
await animationFrame();
expect(`.o_form_status_indicator_buttons`).toHaveCount(1);
expect(`.o_form_status_indicator_buttons`).not.toHaveClass("invisible");
// revert edition
ace.edit(aceEditor).setValue("yop");
await animationFrame();
expect(`.o_form_status_indicator_buttons`).toHaveCount(1);
expect(`.o_form_status_indicator_buttons`).toHaveClass("invisible");
});
test("AceEditorField on html fields works", async () => {
Partner._fields.html_field = fields.Html();
Partner._records.push({ id: 3, html_field: `<p>My little HTML Test</p>` });
onRpc(({ method }) => expect.step(method));
await mountView({
resModel: "res.partner",
resId: 3,
type: "form",
arch: `<form><field name="html_field" widget="code" /></form>`,
});
expect(".o_field_code").toHaveText(/My little HTML Test/);
expect.verifySteps(["get_views", "web_read"]);
// Modify foo and save
await editAce("DEF");
await clickSave();
expect.verifySteps(["web_save"]);
});
test.tags("desktop", "focus required");
test("AceEditorField doesn't crash when editing", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="foo" widget="code"/></form>`,
});
await contains(".ace_editor .ace_content").click();
expect(".ace-view-editor").toHaveClass("ace_focus");
});
test("AceEditorField is updated on value change", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
resIds: [1, 2],
type: "form",
arch: `<form><field name="foo" widget="code"/></form>`,
});
expect(".o_field_code").toHaveText(/yop/);
await pagerNext();
await animationFrame();
await animationFrame();
expect(".o_field_code").toHaveText(/blip/);
});
test("leaving an untouched record with an unset ace field should not write", async () => {
for (const record of Partner._records) {
record.foo = false;
}
onRpc(({ args, method }) => {
if (method) {
expect.step(`${method}: ${JSON.stringify(args)}`);
}
});
await mountView({
resModel: "res.partner",
resId: 1,
resIds: [1, 2],
type: "form",
arch: `<form><field name="foo" widget="code"/></form>`,
});
expect.verifySteps(["get_views: []", "web_read: [[1]]"]);
await pagerNext();
expect.verifySteps(["web_read: [[2]]"]);
});
test.tags("focus required");
test("AceEditorField only trigger onchanges when blurred", async () => {
Partner._onChanges.foo = () => {};
for (const record of Partner._records) {
record.foo = false;
}
onRpc(({ args, method }) => {
expect.step(`${method}: ${JSON.stringify(args)}`);
});
await mountView({
resModel: "res.partner",
resId: 1,
resIds: [1, 2],
type: "form",
arch: `<form><field name="display_name"/><field name="foo" widget="code"/></form>`,
});
expect.verifySteps(["get_views: []", "web_read: [[1]]"]);
await editAce("a");
await contains(getFixture()).focus(); // blur ace editor
expect.verifySteps([`onchange: [[1],{"foo":"a"},["foo"],{"display_name":{},"foo":{}}]`]);
await clickSave();
expect.verifySteps([`web_save: [[1],{"foo":"a"}]`]);
});
test("Save and Discard buttons are displayed when necessary", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="foo" widget="code"/></form>`,
});
await editAce("a");
expect(`.o_form_status_indicator_buttons`).toHaveCount(1);
expect(`.o_form_status_indicator_buttons`).not.toHaveClass("invisible");
await clickSave();
expect(`.o_form_status_indicator_buttons`).toHaveCount(1);
expect(`.o_form_status_indicator_buttons`).toHaveClass("invisible");
});

View file

@ -0,0 +1,97 @@
import { expect, test } from "@odoo/hoot";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_name = "res.partner";
_rec_name = "display_name";
many2one_field = fields.Many2one({ relation: "res.partner" });
selection_field = fields.Selection({
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
});
_records = [
{
id: 1,
display_name: "first record",
many2one_field: 4,
selection_field: "blocked",
},
{
id: 2,
display_name: "second record",
many2one_field: 1,
selection_field: "normal",
},
{
id: 3,
display_name: "", // empty value
selection_field: "done",
},
{
id: 4,
display_name: "fourth record",
selection_field: "done",
},
];
}
defineModels([Partner]);
onRpc("has_group", () => true);
test("BadgeField component on a char field in list view", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `<list><field name="display_name" widget="badge"/></list>`,
});
expect(`.o_field_badge[name="display_name"]:contains(first record)`).toHaveCount(1);
expect(`.o_field_badge[name="display_name"]:contains(second record)`).toHaveCount(1);
expect(`.o_field_badge[name="display_name"]:contains(fourth record)`).toHaveCount(1);
});
test("BadgeField component on a selection field in list view", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `<list><field name="selection_field" widget="badge"/></list>`,
});
expect(`.o_field_badge[name="selection_field"]:contains(Blocked)`).toHaveCount(1);
expect(`.o_field_badge[name="selection_field"]:contains(Normal)`).toHaveCount(1);
expect(`.o_field_badge[name="selection_field"]:contains(Done)`).toHaveCount(2);
});
test("BadgeField component on a many2one field in list view", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `<list><field name="many2one_field" widget="badge"/></list>`,
});
expect(`.o_field_badge[name="many2one_field"]:contains(first record)`).toHaveCount(1);
expect(`.o_field_badge[name="many2one_field"]:contains(fourth record)`).toHaveCount(1);
});
test("BadgeField component with decoration-xxx attributes", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `
<list>
<field name="selection_field" widget="badge"/>
<field name="display_name" widget="badge" decoration-danger="selection_field == 'done'" decoration-warning="selection_field == 'blocked'"/>
</list>
`,
});
expect(`.o_field_badge[name="display_name"]`).toHaveCount(4);
expect(`.o_field_badge[name="display_name"] .text-bg-danger`).toHaveCount(1);
expect(`.o_field_badge[name="display_name"] .text-bg-warning`).toHaveCount(1);
});

View file

@ -0,0 +1,161 @@
import { expect, test } from "@odoo/hoot";
import {
clickSave,
contains,
defineModels,
fields,
MockServer,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_name = "res.partner";
product_id = fields.Many2one({ relation: "product" });
color = fields.Selection({
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
});
_records = [{ id: 1 }, { id: 2, product_id: 37 }];
}
class Product extends models.Model {
_rec_name = "display_name";
_records = [
{ id: 37, display_name: "xphone" },
{ id: 41, display_name: "xpad" },
];
}
defineModels([Partner, Product]);
test("BadgeSelectionField widget on a many2one in a new record", async () => {
onRpc("web_save", ({ args }) => {
expect.step(`saved product_id: ${args[1]["product_id"]}`);
});
await mountView({
resModel: "res.partner",
type: "form",
arch: `<form><field name="product_id" widget="selection_badge"/></form>`,
});
expect(`div.o_field_selection_badge`).toHaveCount(1, {
message: "should have rendered outer div",
});
expect(`span.o_selection_badge`).toHaveCount(2, { message: "should have 2 possible choices" });
expect(`span.o_selection_badge:contains(xphone)`).toHaveCount(1, {
message: "one of them should be xphone",
});
expect(`span.active`).toHaveCount(0, { message: "none of the input should be checked" });
await contains(`span.o_selection_badge`).click();
expect(`span.active`).toHaveCount(1, { message: "one of the input should be checked" });
await clickSave();
expect.verifySteps(["saved product_id: 37"]);
});
test("BadgeSelectionField widget on a selection in a new record", async () => {
onRpc("web_save", ({ args }) => {
expect.step(`saved color: ${args[1]["color"]}`);
});
await mountView({
resModel: "res.partner",
type: "form",
arch: `<form><field name="color" widget="selection_badge"/></form>`,
});
expect(`div.o_field_selection_badge`).toHaveCount(1, {
message: "should have rendered outer div",
});
expect("span.o_selection_badge").toHaveCount(2, { message: "should have 2 possible choices" });
expect(`span.o_selection_badge:contains(Red)`).toHaveCount(1, {
message: "one of them should be Red",
});
await contains(`span.o_selection_badge:last`).click();
await clickSave();
expect.verifySteps(["saved color: black"]);
});
test("BadgeSelectionField widget on a selection in a readonly mode", async () => {
await mountView({
resModel: "res.partner",
type: "form",
arch: `<form><field name="color" widget="selection_badge" readonly="1"/></form>`,
});
expect(`div.o_readonly_modifier span`).toHaveCount(1, {
message: "should have 1 possible value in readonly mode",
});
});
test("BadgeSelectionField widget on a selection unchecking selected value", async () => {
onRpc("res.partner", "web_save", ({ args }) => {
expect.step("web_save");
expect(args[1]).toEqual({ color: false });
});
await mountView({
type: "form",
resModel: "res.partner",
arch: '<form><field name="color" widget="selection_badge"/></form>',
});
expect("div.o_field_selection_badge").toHaveCount(1, {
message: "should have rendered outer div",
});
expect("span.o_selection_badge").toHaveCount(2, { message: "should have 2 possible choices" });
expect("span.o_selection_badge.active").toHaveCount(1, { message: "one is active" });
expect("span.o_selection_badge.active").toHaveText("Red", {
message: "the active one should be Red",
});
// click again on red option and save to update the server data
await contains("span.o_selection_badge.active").click();
expect.verifySteps([]);
await contains(".o_form_button_save").click();
expect.verifySteps(["web_save"]);
expect(MockServer.env["res.partner"].at(-1).color).toBe(false, {
message: "the new value should be false as we have selected same value as default",
});
});
test("BadgeSelectionField widget on a selection unchecking selected value (required field)", async () => {
Partner._fields.color.required = true;
onRpc("res.partner", "web_save", ({ args }) => {
expect.step("web_save");
expect(args[1]).toEqual({ color: "red" });
});
await mountView({
type: "form",
resModel: "res.partner",
arch: '<form><field name="color" widget="selection_badge"/></form>',
});
expect("div.o_field_selection_badge").toHaveCount(1, {
message: "should have rendered outer div",
});
expect("span.o_selection_badge").toHaveCount(2, { message: "should have 2 possible choices" });
expect("span.o_selection_badge.active").toHaveCount(1, { message: "one is active" });
expect("span.o_selection_badge.active").toHaveText("Red", {
message: "the active one should be Red",
});
// click again on red option and save to update the server data
await contains("span.o_selection_badge.active").click();
expect.verifySteps([]);
await contains(".o_form_button_save").click();
expect.verifySteps(["web_save"]);
expect(MockServer.env["res.partner"].at(-1).color).toBe("red", {
message: "the new value should be red",
});
});

View file

@ -0,0 +1,450 @@
import { after, expect, test } from "@odoo/hoot";
import { click, queryOne, queryValue, setInputFiles, waitFor } from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
makeServerError,
models,
mountView,
onRpc,
pagerNext,
} from "@web/../tests/web_test_helpers";
import { toBase64Length } from "@web/core/utils/binary";
import { MAX_FILENAME_SIZE_BYTES } from "@web/views/fields/binary/binary_field";
const BINARY_FILE =
"R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7";
class Partner extends models.Model {
_name = "res.partner";
foo = fields.Char({ default: "My little Foo Value" });
document = fields.Binary();
product_id = fields.Many2one({ relation: "product" });
_records = [{ foo: "coucou.txt", document: "coucou==\n" }];
}
class Product extends models.Model {
name = fields.Char();
_records = [
{ id: 37, name: "xphone" },
{ id: 41, name: "xpad" },
];
}
defineModels([Partner, Product]);
onRpc("has_group", () => true);
test("BinaryField is correctly rendered (readonly)", async () => {
onRpc("/web/content", async (request) => {
expect.step("/web/content");
const body = await request.text();
expect(body).toBeInstanceOf(FormData);
expect(body.get("field")).toBe("document", {
message: "we should download the field document",
});
expect(body.get("data")).toBe("coucou==\n", {
message: "we should download the correct data",
});
return new Blob([body.get("data")], { type: "text/plain" });
});
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `
<form edit="0">
<field name="document" filename="foo"/>
<field name="foo"/>
</form>
`,
});
expect(`.o_field_widget[name="document"] a > .fa-download`).toHaveCount(1, {
message: "the binary field should be rendered as a downloadable link in readonly",
});
expect(`.o_field_widget[name="document"]`).toHaveText("coucou.txt", {
message: "the binary field should display the name of the file in the link",
});
expect(`.o_field_char`).toHaveText("coucou.txt", {
message: "the filename field should have the file name as value",
});
// Testing the download button in the field
// We must avoid the browser to download the file effectively
const deferred = new Deferred();
const downloadOnClick = (ev) => {
const target = ev.target;
if (target.tagName === "A" && "download" in target.attributes) {
ev.preventDefault();
document.removeEventListener("click", downloadOnClick);
deferred.resolve();
}
};
document.addEventListener("click", downloadOnClick);
after(() => document.removeEventListener("click", downloadOnClick));
await contains(`.o_field_widget[name="document"] a`).click();
await deferred;
expect.verifySteps(["/web/content"]);
});
test("BinaryField is correctly rendered", async () => {
onRpc("/web/content", async (request) => {
expect.step("/web/content");
const body = await request.text();
expect(body).toBeInstanceOf(FormData);
expect(body.get("field")).toBe("document", {
message: "we should download the field document",
});
expect(body.get("data")).toBe("coucou==\n", {
message: "we should download the correct data",
});
return new Blob([body.get("data")], { type: "text/plain" });
});
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>
`,
});
expect(`.o_field_widget[name="document"] a > .fa-download`).toHaveCount(0, {
message: "the binary field should not be rendered as a downloadable link in edit",
});
expect(`.o_field_widget[name="document"].o_field_binary .o_input`).toHaveValue("coucou.txt", {
message: "the binary field should display the file name in the input edit mode",
});
expect(`.o_field_binary .o_clear_file_button`).toHaveCount(1, {
message: "there shoud be a button to clear the file",
});
expect(`.o_field_char input`).toHaveValue("coucou.txt", {
message: "the filename field should have the file name as value",
});
// Testing the download button in the field
// We must avoid the browser to download the file effectively
const deferred = new Deferred();
const downloadOnClick = (ev) => {
const target = ev.target;
if (target.tagName === "A" && "download" in target.attributes) {
ev.preventDefault();
document.removeEventListener("click", downloadOnClick);
deferred.resolve();
}
};
document.addEventListener("click", downloadOnClick);
after(() => document.removeEventListener("click", downloadOnClick));
await click(`.fa-download`);
await deferred;
expect.verifySteps(["/web/content"]);
await click(`.o_field_binary .o_clear_file_button`);
await animationFrame();
expect(`.o_field_binary input`).not.toBeVisible({ message: "the input should be hidden" });
expect(`.o_field_binary .o_select_file_button`).toHaveCount(1, {
message: "there should be a button to upload the file",
});
expect(`.o_field_char input`).toHaveValue("", {
message: "the filename field should be empty since we removed the file",
});
await clickSave();
expect(`.o_field_widget[name="document"] a > .fa-download`).toHaveCount(0, {
message:
"the binary field should not render as a downloadable link since we removed the file",
});
expect(`o_field_widget span`).toHaveCount(0, {
message:
"the binary field should not display a filename in the link since we removed the file",
});
});
test("BinaryField is correctly rendered (isDirty)", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>
`,
});
// Simulate a file upload
await click(`.o_select_file_button`);
await animationFrame();
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await setInputFiles([file]);
await waitFor(`.o_form_button_save:visible`);
expect(`.o_field_widget[name="document"] .fa-download`).toHaveCount(0, {
message:
"the binary field should not be rendered as a downloadable since the record is dirty",
});
await clickSave();
expect(`.o_field_widget[name="document"] .fa-download`).toHaveCount(1, {
message:
"the binary field should render as a downloadable link since the record is not dirty",
});
});
test("file name field is not defined", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="document" filename="foo"/></form>`,
});
expect(`.o_field_binary`).toHaveText("", {
message: "there should be no text since the name field is not in the view",
});
expect(`.o_field_binary .fa-download`).toBeDisplayed({
message: "download icon should be visible",
});
});
test("icons are displayed exactly once", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="document" filename="foo"/></form>`,
});
expect(queryOne`.o_field_binary .o_select_file_button`).toBeVisible({
message: "only one select file icon should be visible",
});
expect(queryOne`.o_field_binary .o_download_file_button`).toBeVisible({
message: "only one download file icon should be visible",
});
expect(queryOne`.o_field_binary .o_clear_file_button`).toBeVisible({
message: "only one clear file icon should be visible",
});
});
test("input value is empty when clearing after uploading", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>
`,
});
await click(`.o_select_file_button`);
await animationFrame();
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await setInputFiles([file]);
await waitFor(`.o_form_button_save:visible`);
expect(`.o_field_binary input[type=text]`).toHaveAttribute("readonly");
expect(`.o_field_binary input[type=text]`).toHaveValue("fake_file.txt");
expect(`.o_field_char input[type=text]`).toHaveValue("fake_file.txt");
await click(`.o_clear_file_button`);
await animationFrame();
expect(`.o_field_binary .o_input_file`).toHaveValue("");
expect(`.o_field_char input`).toHaveValue("");
});
test("option accepted_file_extensions", async () => {
await mountView({
resModel: "res.partner",
type: "form",
arch: `
<form>
<field name="document" widget="binary" options="{'accepted_file_extensions': '.dat,.bin'}"/>
</form>
`,
});
expect(`input.o_input_file`).toHaveAttribute("accept", ".dat,.bin", {
message: "the input should have the correct ``accept`` attribute",
});
});
test.tags("desktop");
test("readonly in create mode does not download", async () => {
onRpc("/web/content", () => {
expect.step("We shouldn't be getting the file.");
});
Partner._onChanges.product_id = (record) => {
record.document = "onchange==\n";
};
Partner._fields.document.readonly = true;
await mountView({
resModel: "res.partner",
type: "form",
arch: `
<form>
<field name="product_id"/>
<field name="document" filename="yooo"/>
</form>
`,
});
await click(`.o_field_many2one[name='product_id'] input`);
await animationFrame();
await click(`.o_field_many2one[name='product_id'] .dropdown-item`);
await animationFrame();
expect(`.o_field_widget[name="document"] a`).toHaveCount(0, {
message: "The link to download the binary should not be present",
});
expect(`.o_field_widget[name="document"] a > .fa-download`).toHaveCount(0, {
message: "The download icon should not be present",
});
expect.verifySteps([]);
});
test("BinaryField in list view (formatter)", async () => {
Partner._records[0]["document"] = BINARY_FILE;
await mountView({
resModel: "res.partner",
type: "list",
arch: `<list><field name="document"/></list>`,
});
expect(`.o_data_row .o_data_cell`).toHaveText("93.43 Bytes");
});
test("BinaryField in list view with filename", async () => {
Partner._records[0]["document"] = BINARY_FILE;
await mountView({
resModel: "res.partner",
type: "list",
arch: `
<list>
<field name="document" filename="foo" widget="binary"/>
<field name="foo"/>
</list>
`,
});
expect(`.o_data_row .o_data_cell`).toHaveText("coucou.txt");
});
test("new record has no download button", async () => {
Partner._fields.document.default = BINARY_FILE;
await mountView({
resModel: "res.partner",
type: "form",
arch: `<form><field name="document" filename="foo"/></form>`,
});
expect(`button.fa-download`).toHaveCount(0);
});
test("filename doesn't exceed 255 bytes", async () => {
const LARGE_BINARY_FILE = BINARY_FILE.repeat(5);
expect((LARGE_BINARY_FILE.length / 4) * 3).toBeGreaterThan(MAX_FILENAME_SIZE_BYTES, {
message:
"The initial binary file should be larger than max bytes that can represent the filename",
});
Partner._fields.document.default = LARGE_BINARY_FILE;
await mountView({
resModel: "res.partner",
type: "form",
arch: `<form><field name="document"/></form>`,
});
expect(queryValue(`.o_field_binary input[type=text]`)).toHaveLength(
toBase64Length(MAX_FILENAME_SIZE_BYTES),
{
message: "The filename shouldn't exceed the maximum size in bytes in base64",
}
);
});
test("filename is updated when using the pager", async () => {
Partner._records.push(
{ id: 1, document: "abc", foo: "abc.txt" },
{ id: 2, document: "def", foo: "def.txt" }
);
await mountView({
resModel: "res.partner",
resIds: [1, 2],
resId: 1,
type: "form",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>
`,
});
expect(`.o_field_binary input[type=text]`).toHaveValue("abc.txt", {
message: `displayed value should be "abc.txt"`,
});
await pagerNext();
expect(`.o_field_binary input[type=text]`).toHaveValue("def.txt", {
message: `displayed value should be "def.txt"`,
});
});
test("isUploading state should be set to false after upload", async () => {
expect.errors(1);
Partner._records.push({ id: 1 });
Partner._onChanges.document = (record) => {
if (record.document) {
throw makeServerError({ type: "ValidationError" });
}
};
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `<form><field name="document"/></form>`,
});
await click(`.o_select_file_button`);
await animationFrame();
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await setInputFiles([file]);
await waitFor(`.o_form_button_save:visible`);
await animationFrame();
expect.verifyErrors([/RPC_ERROR/]);
expect(`.o_select_file_button`).toHaveText("Upload your file");
});
test("doesn't crash if value is not a string", async () => {
class Dummy extends models.Model {
document = fields.Binary()
_applyComputesAndValidate() {}
}
defineModels([Dummy])
Dummy._records.push({ id: 1, document: {} });
await mountView({
type: "form",
resModel: "dummy",
resId: 1,
arch: `
<form>
<field name="document"/>
</form>`,
});
expect(".o_field_binary input").toHaveValue("");
});

View file

@ -0,0 +1,254 @@
import { expect, test } from "@odoo/hoot";
import { queryAllProperties, queryAllTexts } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
bar = fields.Boolean({ default: true });
_records = [
{ id: 1, bar: true },
{ id: 2, bar: true },
{ id: 3, bar: true },
{ id: 4, bar: true },
{ id: 5, bar: false },
];
}
defineModels([Partner]);
test("FavoriteField in kanban view", async () => {
await mountView({
resModel: "partner",
domain: [["id", "=", 1]],
type: "kanban",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="bar" widget="boolean_favorite"/>
</t>
</templates>
</kanban>
`,
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should be favorite",
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a`).toHaveText("Remove from Favorites", {
message: `the label should say "Remove from Favorites"`,
});
// click on favorite
await contains(`.o_field_widget .o_favorite`).click();
expect(`.o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(0, {
message: "should not be favorite",
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a`).toHaveText("Add to Favorites", {
message: `the label should say "Add to Favorites"`,
});
});
test("FavoriteField saves changes by default", async () => {
onRpc("web_save", ({ args }) => {
expect.step("save");
expect(args).toEqual([[1], { bar: false }]);
});
await mountView({
resModel: "partner",
domain: [["id", "=", 1]],
type: "kanban",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="bar" widget="boolean_favorite"/>
</t>
</templates>
</kanban>
`,
});
// click on favorite
await contains(`.o_field_widget .o_favorite`).click();
expect(`.o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(0, {
message: "should not be favorite",
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a`).toHaveText("Add to Favorites", {
message: `the label should say "Add to Favorites"`,
});
expect.verifySteps(["save"]);
});
test("FavoriteField does not save if autosave option is set to false", async () => {
onRpc("web_save", () => {
expect.step("save");
});
await mountView({
resModel: "partner",
domain: [["id", "=", 1]],
type: "kanban",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="bar" widget="boolean_favorite" options="{'autosave': False}"/>
</t>
</templates>
</kanban>
`,
});
// click on favorite
await contains(`.o_field_widget .o_favorite`).click();
expect(`.o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(0, {
message: "should not be favorite",
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a`).toHaveText("Add to Favorites", {
message: `the label should say "Add to Favorites"`,
});
expect.verifySteps([]);
});
test("FavoriteField in form view", async () => {
onRpc("web_save", () => {
expect.step("save");
});
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar" widget="boolean_favorite"/></form>`,
});
expect(`.o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should be favorite",
});
expect(`.o_field_widget .o_favorite > a`).toHaveText("Remove from Favorites", {
message: `the label should say "Remove from Favorites"`,
});
// click on favorite
await contains(`.o_field_widget .o_favorite`).click();
expect.verifySteps(["save"]);
expect(`.o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(0, {
message: "should not be favorite",
});
expect(`.o_field_widget .o_favorite > a i.fa.fa-star-o`).toHaveCount(1, {
message: "should not be favorite",
});
expect(`.o_field_widget .o_favorite > a`).toHaveText("Add to Favorites", {
message: `the label should say "Add to Favorites"`,
});
// click on favorite
await contains(`.o_field_widget .o_favorite`).click();
expect.verifySteps(["save"]);
expect(`.o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should be favorite",
});
expect(`.o_field_widget .o_favorite > a`).toHaveText("Remove from Favorites", {
message: `the label should say "Remove from Favorites"`,
});
});
test.tags("desktop");
test("FavoriteField in editable list view without label", async () => {
onRpc("has_group", () => true);
await mountView({
resModel: "partner",
type: "list",
arch: `
<list editable="bottom">
<field name="bar" widget="boolean_favorite" nolabel="1" options="{'autosave': False}"/>
</list>
`,
});
expect(`.o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should be favorite",
});
// switch to edit mode
await contains(`tbody td:not(.o_list_record_selector)`).click();
expect(`.o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should be favorite",
});
// click on favorite
await contains(`.o_data_row .o_field_widget .o_favorite > a`).click();
expect(`.o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(0, {
message: "should not be favorite",
});
// save
await contains(`.o_list_button_save`).click();
expect(`.o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star-o`).toHaveCount(1, {
message: "should not be favorite",
});
});
test.tags("desktop");
test("FavoriteField in list has a fixed width if no label", async () => {
onRpc("has_group", () => true);
Partner._fields.char = fields.Char();
await mountView({
resModel: "partner",
type: "list",
arch: `
<list editable="bottom">
<field name="bar" widget="boolean_favorite" nolabel="1"/>
<field name="bar" widget="boolean_favorite"/>
<field name="char"/>
</list>
`,
});
const columnWidths = queryAllProperties(".o_list_table thead th", "offsetWidth");
const columnLabels = queryAllTexts(".o_list_table thead th");
expect(columnWidths[1]).toBe(29);
expect(columnLabels[1]).toBe("");
expect(columnWidths[2]).toBeGreaterThan(29);
expect(columnLabels[2]).toBe("Bar");
});
test("FavoriteField in kanban view with readonly attribute", async () => {
onRpc("web_save", () => {
expect.step("should not save");
});
await mountView({
resModel: "partner",
domain: [["id", "=", 1]],
type: "kanban",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="bar" widget="boolean_favorite" readonly="1"/>
</t>
</templates>
</kanban>
`,
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should be favorite",
});
expect(`.o_kanban_record .o_field_widget .o_favorite > a`).toHaveClass("pe-none");
expect(`.o_kanban_record .o_field_widget`).toHaveText("");
// click on favorite
await contains(`.o_field_widget .o_favorite`).click();
// expect nothing to change since its readonly
expect(`.o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star`).toHaveCount(1, {
message: "should remain favorite",
});
expect.verifySteps([]);
});

View file

@ -0,0 +1,166 @@
import { expect, test } from "@odoo/hoot";
import { check, click, press, uncheck } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
bar = fields.Boolean({ default: true });
_records = [
{ id: 1, bar: true },
{ id: 2, bar: true },
{ id: 3, bar: true },
{ id: 4, bar: true },
{ id: 5, bar: false },
];
}
defineModels([Partner]);
test("boolean field in form view", async () => {
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `
<form>
<label for="bar" string="Awesome checkbox"/>
<field name="bar"/>
</form>
`,
});
expect(`.o_field_boolean input`).toBeChecked();
expect(`.o_field_boolean input`).toBeEnabled();
await uncheck(`.o_field_boolean input`);
await animationFrame();
expect(`.o_field_boolean input`).not.toBeChecked();
await clickSave();
expect(`.o_field_boolean input`).not.toBeChecked();
await check(`.o_field_boolean input`);
await animationFrame();
expect(`.o_field_boolean input`).toBeChecked();
await uncheck(`.o_field_boolean input`);
await animationFrame();
expect(`.o_field_boolean input`).not.toBeChecked();
await click(`.o_form_view label:not(.form-check-label)`);
await animationFrame();
expect(`.o_field_boolean input`).toBeChecked();
await click(`.o_form_view label:not(.form-check-label)`);
await animationFrame();
expect(`.o_field_boolean input`).not.toBeChecked();
await press("enter");
await animationFrame();
expect(`.o_field_boolean input`).toBeChecked();
await press("enter");
await animationFrame();
expect(`.o_field_boolean input`).not.toBeChecked();
await press("enter");
await animationFrame();
expect(`.o_field_boolean input`).toBeChecked();
await clickSave();
expect(`.o_field_boolean input`).toBeChecked();
});
test("boolean field in editable list view", async () => {
onRpc("has_group", () => true);
await mountView({
resModel: "partner",
type: "list",
arch: `<list editable="bottom"><field name="bar"/></list>`,
});
expect(`tbody td:not(.o_list_record_selector) .o-checkbox input`).toHaveCount(5);
expect(`tbody td:not(.o_list_record_selector) .o-checkbox input:checked`).toHaveCount(4);
// Edit a line
const cell = `tr.o_data_row td:not(.o_list_record_selector):first`;
expect(`${cell} .o-checkbox input:only`).toBeChecked();
expect(`${cell} .o-checkbox input:only`).not.toBeEnabled();
await click(`${cell} .o-checkbox`);
await animationFrame();
expect(`tr.o_data_row:nth-child(1)`).toHaveClass("o_selected_row", {
message: "the row is now selected, in edition",
});
expect(`${cell} .o-checkbox input:only`).not.toBeChecked();
expect(`${cell} .o-checkbox input:only`).toBeEnabled();
await click(`${cell} .o-checkbox`);
await click(cell);
await animationFrame();
expect(`${cell} .o-checkbox input:only`).toBeChecked();
expect(`${cell} .o-checkbox input:only`).toBeEnabled();
await click(`${cell} .o-checkbox`);
await animationFrame();
await click(`.o_list_button_save`);
await animationFrame();
expect(`${cell} .o-checkbox input:only`).not.toBeChecked();
expect(`${cell} .o-checkbox input:only`).not.toBeEnabled();
expect(`tbody td:not(.o_list_record_selector) .o-checkbox input`).toHaveCount(5);
expect(`tbody td:not(.o_list_record_selector) .o-checkbox input:checked`).toHaveCount(3);
// Fake-check the checkbox
await click(cell);
await animationFrame();
await click(`${cell} .o-checkbox`);
await animationFrame();
await click(`.o_list_button_save`);
await animationFrame();
expect(`tbody td:not(.o_list_record_selector) .o-checkbox input`).toHaveCount(5);
expect(`tbody td:not(.o_list_record_selector) .o-checkbox input:checked`).toHaveCount(3);
});
test("readonly boolean field", async () => {
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar" readonly="1"/></form>`,
});
expect(`.o_field_boolean input`).toBeChecked();
expect(`.o_field_boolean input`).not.toBeEnabled();
await click(`.o_field_boolean .o-checkbox`);
await animationFrame();
expect(`.o_field_boolean input`).toBeChecked();
expect(`.o_field_boolean input`).not.toBeEnabled();
});
test("onchange return value before toggle checkbox", async () => {
Partner._onChanges.bar = (record) => {
record["bar"] = true;
};
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar"/></form>`,
});
expect(`.o_field_boolean input`).toBeChecked();
await click(`.o_field_boolean .o-checkbox`);
await animationFrame();
await animationFrame();
expect(`.o_field_boolean input`).toBeChecked();
});

View file

@ -0,0 +1,34 @@
import { expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
bar = fields.Boolean({ string: "Bar field" });
foo = fields.Boolean();
_records = [{ id: 1, bar: true, foo: false }];
}
defineModels([Partner]);
test("BooleanIcon field in form view", async () => {
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `
<form>
<field name="bar" widget="boolean_icon" options="{'icon': 'fa-recycle'}" />
<field name="foo" widget="boolean_icon" options="{'icon': 'fa-trash'}" />
</form>`,
});
expect(".o_field_boolean_icon button").toHaveCount(2);
expect("[name='bar'] button").toHaveAttribute("data-tooltip", "Bar field");
expect("[name='bar'] button").toHaveClass("btn-primary fa-recycle");
expect("[name='foo'] button").toHaveClass("btn-outline-secondary fa-trash");
await click("[name='bar'] button");
await animationFrame();
expect("[name='bar'] button").toHaveClass("btn-outline-secondary fa-recycle");
});

View file

@ -0,0 +1,88 @@
import { expect, test } from "@odoo/hoot";
import { check, click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
bar = fields.Boolean({ default: true });
_records = [{ id: 1, bar: false }];
}
defineModels([Partner]);
test("use BooleanToggleField in form view", async () => {
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar" widget="boolean_toggle"/></form>`,
});
expect(`.form-check.o_boolean_toggle`).toHaveCount(1);
expect(`.o_boolean_toggle input`).toBeEnabled();
expect(`.o_boolean_toggle input`).not.toBeChecked();
await check(`.o_field_widget[name='bar'] input`);
await animationFrame();
expect(`.o_boolean_toggle input`).toBeEnabled();
expect(`.o_boolean_toggle input`).toBeChecked();
});
test("BooleanToggleField is disabled with a readonly attribute", async () => {
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar" widget="boolean_toggle" readonly="1"/></form>`,
});
expect(`.form-check.o_boolean_toggle`).toHaveCount(1);
expect(`.o_boolean_toggle input`).not.toBeEnabled();
});
test("BooleanToggleField is disabled if readonly in editable list", async () => {
Partner._fields.bar.readonly = true;
onRpc("has_group", () => true);
await mountView({
resModel: "partner",
type: "list",
arch: `
<list editable="bottom">
<field name="bar" widget="boolean_toggle"/>
</list>
`,
});
expect(`.o_boolean_toggle input`).not.toBeEnabled();
expect(`.o_boolean_toggle input`).not.toBeChecked();
await click(`.o_boolean_toggle`);
await animationFrame();
expect(`.o_boolean_toggle input`).not.toBeEnabled();
expect(`.o_boolean_toggle input`).not.toBeChecked();
});
test("BooleanToggleField - auto save record when field toggled", async () => {
onRpc("web_save", () => expect.step("web_save"));
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar" widget="boolean_toggle"/></form>`,
});
await click(`.o_field_widget[name='bar'] input`);
await animationFrame();
expect.verifySteps(["web_save"]);
});
test("BooleanToggleField - autosave option set to false", async () => {
onRpc("web_save", () => expect.step("web_save"));
await mountView({
resModel: "partner",
resId: 1,
type: "form",
arch: `<form><field name="bar" widget="boolean_toggle" options="{'autosave': false}"/></form>`,
});
await click(`.o_field_widget[name='bar'] input`);
await animationFrame();
expect.verifySteps([]);
});

View file

@ -0,0 +1,938 @@
import { expect, test } from "@odoo/hoot";
import { queryAll, queryFirst } from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
onRpc,
serverState,
} from "@web/../tests/web_test_helpers";
class Currency extends models.Model {
digits = fields.Integer();
symbol = fields.Char({ string: "Currency Symbol" });
position = fields.Char({ string: "Currency Position" });
_records = [
{
id: 1,
display_name: "$",
symbol: "$",
position: "before",
},
{
id: 2,
display_name: "€",
symbol: "€",
position: "after",
},
];
}
class Partner extends models.Model {
_name = "res.partner";
_inherit = [];
name = fields.Char({
string: "Name",
default: "My little Name Value",
trim: true,
});
int_field = fields.Integer();
partner_ids = fields.One2many({
string: "one2many field",
relation: "res.partner",
});
product_id = fields.Many2one({ relation: "product" });
placeholder_name = fields.Char();
_records = [
{
id: 1,
display_name: "first record",
name: "yop",
int_field: 10,
partner_ids: [],
placeholder_name: "Placeholder Name",
},
{
id: 2,
display_name: "second record",
name: "blip",
int_field: 0,
partner_ids: [],
},
{ id: 3, name: "gnap", int_field: 80 },
{
id: 4,
display_name: "aaa",
name: "abc",
},
{ id: 5, name: "blop", int_field: -4 },
];
_views = {
form: /* xml */ `
<form>
<sheet>
<group>
<field name="name"/>
</group>
</sheet>
</form>
`,
};
}
class PartnerType extends models.Model {
color = fields.Integer({ string: "Color index" });
name = fields.Char({ string: "Partner Type" });
_records = [
{ id: 12, display_name: "gold", color: 2 },
{ id: 14, display_name: "silver", color: 5 },
];
}
class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
_records = [
{
id: 37,
name: "xphone",
},
{
id: 41,
name: "xpad",
},
];
}
class Users extends models.Model {
_name = "res.users";
name = fields.Char();
has_group() {
return true;
}
_records = [
{
id: 1,
name: "Aline",
},
{
id: 2,
name: "Christine",
},
];
}
defineModels([Currency, Partner, PartnerType, Product, Users]);
test("char field in form view", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_widget input[type='text']").toHaveCount(1, {
message: "should have an input for the char field",
});
expect(".o_field_widget input[type='text']").toHaveValue("yop", {
message: "input should contain field value in edit mode",
});
await fieldInput("name").edit("limbo");
await clickSave();
expect(".o_field_widget input[type='text']").toHaveValue("limbo", {
message: "the new value should be displayed",
});
});
test("setting a char field to empty string is saved as a false value", async () => {
expect.assertions(1);
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
onRpc("web_save", ({ args }) => {
expect(args[1].name).toBe(false);
});
await fieldInput("name").clear();
await clickSave();
});
test("char field with size attribute", async () => {
Partner._fields.name.size = 5;
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect("input").toHaveAttribute("maxlength", "5", {
message: "maxlength attribute should have been set correctly on the input",
});
});
test.tags("desktop");
test("char field in editable list view", async () => {
await mountView({
type: "list",
resModel: "res.partner",
arch: `
<list editable="bottom">
<field name="name" />
</list>`,
});
expect("tbody td:not(.o_list_record_selector)").toHaveCount(5, {
message: "should have 5 cells",
});
expect("tbody td:not(.o_list_record_selector):first").toHaveText("yop", {
message: "value should be displayed properly as text",
});
const cellSelector = "tbody td:not(.o_list_record_selector)";
await contains(cellSelector).click();
expect(queryFirst(cellSelector).parentElement).toHaveClass("o_selected_row", {
message: "should be set as edit mode",
});
expect(`${cellSelector} input`).toHaveValue("yop", {
message: "should have the corect value in internal input",
});
await fieldInput("name").edit("brolo", { confirm: false });
await contains(".o_list_button_save").click();
expect(cellSelector).not.toHaveClass("o_selected_row", {
message: "should not be in edit mode anymore",
});
});
test("char field translatable", async () => {
Partner._fields.name.translate = true;
serverState.lang = "en_US";
serverState.multiLang = true;
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
});
let callGetFieldTranslations = 0;
onRpc("res.lang", "get_installed", () => [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
["es_ES", "Spanish"],
]);
onRpc("res.partner", "get_field_translations", () => {
if (callGetFieldTranslations++ === 0) {
return [
[
{ lang: "en_US", source: "yop", value: "yop" },
{ lang: "fr_BE", source: "yop", value: "yop français" },
{ lang: "es_ES", source: "yop", value: "yop español" },
],
{ translation_type: "char", translation_show_source: false },
];
} else {
return [
[
{ lang: "en_US", source: "bar", value: "bar" },
{ lang: "fr_BE", source: "bar", value: "yop français" },
{ lang: "es_ES", source: "bar", value: "bar" },
],
{ translation_type: "char", translation_show_source: false },
];
}
});
onRpc("res.partner", "update_field_translations", function ({ args, kwargs }) {
expect(args[2]).toEqual(
{ en_US: "bar", es_ES: false },
{
message:
"the new translation value should be written and the value false voids the translation",
}
);
for (const record of this.env["res.partner"].browse(args[0])) {
record[args[1]] = args[2][kwargs.context.lang];
}
return true;
});
expect("[name=name] input").toHaveClass("o_field_translate");
await contains("[name=name] input").click();
expect(".o_field_char .btn.o_field_translate").toHaveCount(1, {
message: "should have a translate button",
});
expect(".o_field_char .btn.o_field_translate").toHaveText("EN", {
message: "the button should have as test the current language",
});
await contains(".o_field_char .btn.o_field_translate").click();
expect(".modal").toHaveCount(1, {
message: "a translate modal should be visible",
});
expect(".modal .o_translation_dialog .translation").toHaveCount(3, {
message: "three rows should be visible",
});
let translations = queryAll(".modal .o_translation_dialog .translation input");
expect(translations[0]).toHaveValue("yop", {
message: "English translation should be filled",
});
expect(translations[1]).toHaveValue("yop français", {
message: "French translation should be filled",
});
expect(translations[2]).toHaveValue("yop español", {
message: "Spanish translation should be filled",
});
await contains(translations[0]).edit("bar");
await contains(translations[2]).clear();
await contains("footer .btn.btn-primary").click();
expect(".o_field_widget.o_field_char input").toHaveValue("bar", {
message: "the new translation should be transfered to modified record",
});
await fieldInput("name").edit("baz");
await contains(".o_field_char .btn.o_field_translate").click();
translations = queryAll(".modal .o_translation_dialog .translation input");
expect(translations[0]).toHaveValue("baz", {
message: "Modified value should be used instead of translation",
});
expect(translations[1]).toHaveValue("yop français", {
message: "French translation shouldn't be changed",
});
expect(translations[2]).toHaveValue("bar", {
message: "Spanish translation should fallback to the English translation",
});
});
test("translation dialog should close if field is not there anymore", async () => {
expect.assertions(4);
// In this test, we simulate the case where the field is removed from the view
// this can happen for example if the user click the back button of the browser.
Partner._fields.name.translate = true;
serverState.lang = "en_US";
serverState.multiLang = true;
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<group>
<field name="int_field" />
<field name="name" invisible="int_field == 9"/>
</group>
</sheet>
</form>`,
});
onRpc(async ({ method, model }) => {
if (method === "get_installed" && model === "res.lang") {
return [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
["es_ES", "Spanish"],
];
}
if (method === "get_field_translations" && model === "res.partner") {
return [
[
{ lang: "en_US", source: "yop", value: "yop" },
{ lang: "fr_BE", source: "yop", value: "valeur français" },
{ lang: "es_ES", source: "yop", value: "yop español" },
],
{ translation_type: "char", translation_show_source: false },
];
}
});
expect("[name=name] input").toHaveClass("o_field_translate");
await contains("[name=name] input").click();
await contains(".o_field_char .btn.o_field_translate").click();
expect(".modal").toHaveCount(1, {
message: "a translate modal should be visible",
});
await fieldInput("int_field").edit("9");
await animationFrame();
expect("[name=name] input").toHaveCount(0, {
message: "the field name should be invisible",
});
expect(".modal").toHaveCount(0, {
message: "a translate modal should not be visible",
});
});
test("html field translatable", async () => {
expect.assertions(5);
Partner._fields.name.translate = true;
serverState.lang = "en_US";
serverState.multiLang = true;
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
onRpc(async ({ args, method, model }) => {
if (method === "get_installed" && model === "res.lang") {
return [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
];
}
if (method === "get_field_translations" && model === "res.partner") {
return [
[
{
lang: "en_US",
source: "first paragraph",
value: "first paragraph",
},
{
lang: "en_US",
source: "second paragraph",
value: "second paragraph",
},
{
lang: "fr_BE",
source: "first paragraph",
value: "premier paragraphe",
},
{
lang: "fr_BE",
source: "second paragraph",
value: "deuxième paragraphe",
},
],
{
translation_type: "char",
translation_show_source: true,
},
];
}
if (method === "update_field_translations" && model === "res.partner") {
expect(args[2]).toEqual(
{ en_US: { "first paragraph": "first paragraph modified" } },
{
message: "the new translation value should be written",
}
);
return true;
}
});
// this will not affect the translate_fields effect until the record is
// saved but is set for consistency of the test
await fieldInput("name").edit("<p>first paragraph</p><p>second paragraph</p>");
await contains(".o_field_char .btn.o_field_translate").click();
expect(".modal").toHaveCount(1, {
message: "a translate modal should be visible",
});
expect(".modal .o_translation_dialog .translation").toHaveCount(4, {
message: "four rows should be visible",
});
const enField = queryFirst(".modal .o_translation_dialog .translation input");
expect(enField).toHaveValue("first paragraph", {
message: "first part of english translation should be filled",
});
await contains(enField).edit("first paragraph modified");
await contains(".modal button.btn-primary").click();
expect(".o_field_char input[type='text']").toHaveValue(
"<p>first paragraph</p><p>second paragraph</p>",
{
message: "the new partial translation should not be transfered",
}
);
});
test("char field translatable in create mode", async () => {
Partner._fields.name.translate = true;
serverState.multiLang = true;
await mountView({ type: "form", resModel: "res.partner" });
expect(".o_field_char .btn.o_field_translate").toHaveCount(1, {
message: "should have a translate button in create mode",
});
});
test("char field does not allow html injections", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
await fieldInput("name").edit("<script>throw Error();</script>");
await clickSave();
expect(".o_field_widget input").toHaveValue("<script>throw Error();</script>", {
message: "the value should have been properly escaped",
});
});
test("char field trim (or not) characters", async () => {
Partner._fields.foo2 = fields.Char({ trim: false });
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<group>
<field name="name" />
<field name="foo2" />
</group>
</sheet>
</form>`,
});
await fieldInput("name").edit(" abc ");
await fieldInput("foo2").edit(" def ");
await clickSave();
expect(".o_field_widget[name='name'] input").toHaveValue("abc", {
message: "Name value should have been trimmed",
});
expect(".o_field_widget[name='foo2'] input:only").toHaveValue(" def ");
});
test.tags("desktop");
test("input field: change value before pending onchange returns", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<field name="partner_ids">
<list editable="bottom">
<field name="product_id" />
<field name="name" />
</list>
</field>
</sheet>
</form>`,
});
let def;
onRpc("onchange", () => def);
await contains(".o_field_x2many_list_row_add a").click();
expect(".o_field_widget[name='name'] input").toHaveValue("My little Name Value", {
message: "should contain the default value",
});
def = new Deferred();
await contains(".o-autocomplete--input").click();
await contains(".o-autocomplete--dropdown-item").click();
await fieldInput("name").edit("tralala", { confirm: false });
expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "should contain tralala",
});
def.resolve();
await animationFrame();
expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "should contain the same value as before onchange",
});
});
test("input field: change value before pending onchange returns (2)", async () => {
Partner._onChanges.int_field = (obj) => {
if (obj.int_field === 7) {
obj.name = "blabla";
} else {
obj.name = "tralala";
}
};
const def = new Deferred();
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<field name="int_field" />
<field name="name" />
</sheet>
</form>`,
});
onRpc("onchange", () => def);
expect(".o_field_widget[name='name'] input").toHaveValue("yop", {
message: "should contain the correct value",
});
// trigger a deferred onchange
await fieldInput("int_field").edit("7");
await fieldInput("name").edit("test", { confirm: false });
def.resolve();
await animationFrame();
expect(".o_field_widget[name='name'] input").toHaveValue("test", {
message: "The onchage value should not be applied because the input is in edition",
});
await fieldInput("name").press("Enter");
await expect(".o_field_widget[name='name'] input").toHaveValue("test");
await fieldInput("int_field").edit("10");
await expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "The onchange value should be applied because the input is not in edition",
});
});
test.tags("desktop");
test("input field: change value before pending onchange returns (with fieldDebounce)", async () => {
// this test is exactly the same as the previous one, except that in
// this scenario the onchange return *before* we validate the change
// on the input field (before the "change" event is triggered).
Partner._onChanges.product_id = (obj) => {
obj.int_field = obj.product_id ? 7 : false;
};
let def;
await mountView({
type: "form",
resModel: "res.partner",
arch: `
<form>
<field name="partner_ids">
<list editable="bottom">
<field name="product_id"/>
<field name="name"/>
<field name="int_field"/>
</list>
</field>
</form>`,
});
onRpc("onchange", () => def);
await contains(".o_field_x2many_list_row_add a").click();
expect(".o_field_widget[name='name'] input").toHaveValue("My little Name Value", {
message: "should contain the default value",
});
def = new Deferred();
await contains(".o-autocomplete--input").click();
await contains(".o-autocomplete--dropdown-item").click();
await fieldInput("name").edit("tralala", { confirm: false });
expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "should contain tralala",
});
expect(".o_field_widget[name='int_field'] input").toHaveValue("");
def.resolve();
await animationFrame();
expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "should contain the same value as before onchange",
});
expect(".o_field_widget[name='int_field'] input").toHaveValue("7", {
message: "should contain the value returned by the onchange",
});
});
test("onchange return value before editing input", async () => {
Partner._onChanges.name = (obj) => {
obj.name = "yop";
};
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_widget[name='name'] input").toHaveValue("yop");
await fieldInput("name").edit("tralala");
await expect("[name='name'] input").toHaveValue("yop");
});
test.tags("desktop");
test("input field: change value before pending onchange renaming", async () => {
Partner._onChanges.product_id = (obj) => {
obj.name = "on change value";
};
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<field name="product_id" />
<field name="name" />
</sheet>
</form>`,
});
onRpc("onchange", () => def);
const def = new Deferred();
expect(".o_field_widget[name='name'] input").toHaveValue("yop", {
message: "should contain the correct value",
});
await contains(".o-autocomplete--input").click();
await contains(".o-autocomplete--dropdown-item").click();
// set name before onchange
await fieldInput("name").edit("tralala");
await expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "should contain tralala",
});
// complete the onchange
def.resolve();
await animationFrame();
expect(".o_field_widget[name='name'] input").toHaveValue("tralala", {
message: "input should contain the same value as before onchange",
});
});
test("support autocomplete attribute", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name" autocomplete="coucou"/>
</form>`,
});
expect(".o_field_widget[name='name'] input").toHaveAttribute("autocomplete", "coucou", {
message: "attribute autocomplete should be set",
});
});
test("input autocomplete attribute set to none by default", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name"/>
</form>`,
});
expect(".o_field_widget[name='name'] input").toHaveAttribute("autocomplete", "off", {
message: "attribute autocomplete should be set to none by default",
});
});
test("support password attribute", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name" password="True"/>
</form>`,
});
expect(".o_field_widget[name='name'] input").toHaveValue("yop", {
message: "input value should be the password",
});
expect(".o_field_widget[name='name'] input").toHaveAttribute("type", "password", {
message: "input should be of type password",
});
});
test("input field: readonly password", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name" password="True" readonly="1"/>
</form>`,
});
expect(".o_field_char").not.toHaveText("yop", {
message: "password field value should be visible in read mode",
});
expect(".o_field_char").toHaveText("***", {
message: "password field value should be hidden with '*' in read mode",
});
});
test("input field: change password value", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name" password="True"/>
</form>`,
});
expect(".o_field_char input").toHaveAttribute("type", "password", {
message: "password field input value should with type 'password' in edit mode",
});
expect(".o_field_char input").toHaveValue("yop", {
message: "password field input value should be the (hidden) password value",
});
});
test("input field: empty password", async () => {
Partner._records[0].name = false;
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name" password="True"/>
</form>`,
});
expect(".o_field_char input").toHaveAttribute("type", "password", {
message: "password field input value should with type 'password' in edit mode",
});
expect(".o_field_char input").toHaveValue("", {
message: "password field input value should be the (non-hidden, empty) password value",
});
});
test.tags("desktop");
test("input field: set and remove value, then wait for onchange", async () => {
Partner._onChanges.product_id = (obj) => {
obj.name = obj.product_id ? "onchange value" : false;
};
await mountView({
type: "form",
resModel: "res.partner",
arch: `
<form>
<field name="partner_ids">
<list editable="bottom">
<field name="product_id"/>
<field name="name"/>
</list>
</field>
</form>`,
});
await contains(".o_field_x2many_list_row_add a").click();
expect(".o_field_widget[name=name] input").toHaveValue("");
await fieldInput("name").edit("test", { confirm: false });
await fieldInput("name").clear({ confirm: false });
// trigger the onchange by setting a product
await contains(".o-autocomplete--input").click();
await contains(".o-autocomplete--dropdown-item").click();
expect(".o_field_widget[name=name] input").toHaveValue("onchange value", {
message: "input should contain correct value after onchange",
});
});
test("char field with placeholder", async () => {
Partner._fields.name.default = false;
await mountView({
type: "form",
resModel: "res.partner",
arch: `
<form>
<sheet>
<group>
<field name="name" placeholder="Placeholder" />
</group>
</sheet>
</form>`,
});
expect(".o_field_widget[name='name'] input").toHaveAttribute("placeholder", "Placeholder", {
message: "placeholder attribute should be set",
});
});
test("Form: placeholder_field shows as placeholder", async () => {
Partner._records[0].name = false;
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<group>
<field name="placeholder_name" invisible="1" />
<field name="name" options="{'placeholder_field': 'placeholder_name'}" />
</group>
</sheet>
</form>`,
});
expect("input").toHaveValue("", {
message: "should have no value in input",
});
expect("input").toHaveAttribute("placeholder", "Placeholder Name", {
message: "placeholder_field should be the placeholder",
});
});
test("char field: correct value is used to evaluate the modifiers", async () => {
Partner._records[0].name = false;
Partner._records[0].display_name = false;
Partner._onChanges.name = (obj) => {
if (obj.name === "a") {
obj.display_name = false;
} else if (obj.name === "b") {
obj.display_name = "";
}
};
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="name" />
<field name="display_name" invisible="'' == display_name"/>
</form>`,
});
expect("[name='display_name']").toHaveCount(1);
await fieldInput("name").edit("a");
await animationFrame();
expect("[name='display_name']").toHaveCount(1);
await fieldInput("name").edit("b");
await animationFrame();
expect("[name='display_name']").toHaveCount(0);
});
test("edit a char field should display the status indicator buttons without flickering", async () => {
Partner._records[0].partner_ids = [2];
Partner._onChanges.name = (obj) => {
obj.display_name = "cc";
};
const def = new Deferred();
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="partner_ids">
<list editable="bottom">
<field name="name"/>
</list>
</field>
</form>`,
});
onRpc("onchange", () => {
expect.step("onchange");
return def;
});
expect(".o_form_status_indicator_buttons").not.toBeVisible({
message: "form view is not dirty",
});
await contains(".o_data_cell").click();
await fieldInput("name").edit("a");
expect(".o_form_status_indicator_buttons").toBeVisible({
message: "form view is dirty",
});
def.resolve();
expect.verifySteps(["onchange"]);
await animationFrame();
expect(".o_form_status_indicator_buttons").toBeVisible({
message: "form view is dirty",
});
expect.verifySteps(["onchange"]);
});

View file

@ -0,0 +1,139 @@
import { expect, test } from "@odoo/hoot";
import {
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Color extends models.Model {
hex_color = fields.Char({ string: "hexadecimal color" });
text = fields.Char();
_records = [
{
id: 1,
},
{
id: 2,
hex_color: "#ff4444",
},
];
_views = {
form: /* xml */ `
<form>
<group>
<field name="hex_color" widget="color" />
</group>
</form>`,
list: /* xml */ `
<list editable="bottom">
<field name="hex_color" widget="color" />
</list>`,
};
}
class User extends models.Model {
_name = "res.users";
name = fields.Char();
has_group() {
return true;
}
}
defineModels([Color, User]);
test("field contains a color input", async () => {
Color._onChanges.hex_color = () => {};
await mountView({ type: "form", resModel: "color", resId: 1 });
onRpc("onchange", ({ args }) => {
expect.step(`onchange ${JSON.stringify(args)}`);
});
expect(".o_field_color input[type='color']").toHaveCount(1);
expect(".o_field_color div").toHaveStyle(
{ backgroundColor: "rgba(0, 0, 0, 0)" },
{
message: "field has the transparent background if no color value has been selected",
}
);
expect(".o_field_color input").toHaveValue("#000000");
await contains(".o_field_color input", { visible: false }).edit("#fefefe");
expect.verifySteps([
'onchange [[1],{"hex_color":"#fefefe"},["hex_color"],{"hex_color":{},"display_name":{}}]',
]);
expect(".o_field_color input").toHaveValue("#fefefe");
expect(".o_field_color div").toHaveStyle({ backgroundColor: "rgb(254, 254, 254)" });
});
test("color field in editable list view", async () => {
await mountView({ type: "list", resModel: "color", resId: 1 });
expect(".o_field_color input[type='color']").toHaveCount(2);
await contains(".o_field_color input", { visible: false }).click();
expect(".o_data_row").not.toHaveClass("o_selected_row");
});
test("read-only color field in editable list view", async () => {
await mountView({
type: "list",
resModel: "color",
arch: `
<list editable="bottom">
<field name="hex_color" readonly="1" widget="color" />
</list>`,
});
expect(".o_field_color input:disabled").toHaveCount(2);
});
test("color field read-only in model definition, in non-editable list", async () => {
Color._fields.hex_color.readonly = true;
await mountView({ type: "list", resModel: "color" });
expect(".o_field_color input:disabled").toHaveCount(2);
});
test("color field change via anoter field's onchange", async () => {
Color._onChanges.text = (obj) => {
obj.hex_color = "#fefefe";
};
await mountView({
type: "form",
resModel: "color",
resId: 1,
arch: `
<form>
<field name="text" />
<field name="hex_color" widget="color" />
</form>
`,
});
onRpc("onchange", ({ args }) => {
expect.step(`onchange ${JSON.stringify(args)}`);
});
expect(".o_field_color div").toHaveStyle(
{ backgroundColor: "rgba(0, 0, 0, 0)" },
{
message: "field has the transparent background if no color value has been selected",
}
);
expect(".o_field_color input").toHaveValue("#000000");
await fieldInput("text").edit("someValue");
expect.verifySteps([
'onchange [[1],{"text":"someValue"},["text"],{"text":{},"hex_color":{},"display_name":{}}]',
]);
expect(".o_field_color input").toHaveValue("#fefefe");
expect(".o_field_color div").toHaveStyle({ backgroundColor: "rgb(254, 254, 254)" });
});

View file

@ -0,0 +1,148 @@
import { expect, test } from "@odoo/hoot";
import { queryAll } from "@odoo/hoot-dom";
import { contains, defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_name = "res.partner";
name = fields.Char();
int_field = fields.Integer();
_records = [
{
id: 1,
name: "partnerName",
int_field: 0,
},
];
_views = {
form: /* xml */ `
<form>
<group>
<field name="int_field" widget="color_picker"/>
</group>
</form>
`,
list: /* xml */ `
<list>
<field name="int_field" widget="color_picker"/>
<field name="display_name" />
</list>`,
};
}
class User extends models.Model {
_name = "res.users";
name = fields.Char();
has_group() {
return true;
}
}
defineModels([Partner, User]);
test("No chosen color is a red line with a white background (color 0)", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_color_picker button.o_colorlist_item_color_0").toHaveCount(1);
await contains(".o_field_color_picker button").click();
expect(".o_field_color_picker button.o_colorlist_item_color_0").toHaveCount(1);
await contains(".o_field_color_picker .o_colorlist_item_color_3").click();
await contains(".o_field_color_picker button").click();
expect(".o_field_color_picker button.o_colorlist_item_color_0").toHaveCount(1);
});
test("closes when color selected or outside click", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<group>
<field name="int_field" widget="color_picker"/>
<field name="name"/>
</group>
</form>`,
});
await contains(".o_field_color_picker button").click();
expect(queryAll(".o_field_color_picker button").length).toBeGreaterThan(1);
await contains(".o_field_color_picker .o_colorlist_item_color_3").click();
expect(".o_field_color_picker button").toHaveCount(1);
await contains(".o_field_color_picker button").click();
await contains(".o_field_widget[name='name'] input").click();
expect(".o_field_color_picker button").toHaveCount(1);
});
test("color picker on list view", async () => {
await mountView({
type: "list",
resModel: "res.partner",
selectRecord() {
expect.step("record selected to open");
},
});
await contains(".o_field_color_picker button").click();
expect.verifySteps(["record selected to open"]);
});
test("color picker in editable list view", async () => {
Partner._records.push({
int_field: 1,
});
await mountView({
type: "list",
resModel: "res.partner",
arch: `
<list editable="bottom">
<field name="int_field" widget="color_picker"/>
<field name="display_name" />
</list>`,
});
expect(".o_data_row:nth-child(1) .o_field_color_picker button").toHaveCount(1);
await contains(".o_data_row:nth-child(1) .o_field_color_picker button").click();
expect(".o_data_row:nth-child(1).o_selected_row").toHaveCount(1);
expect(".o_data_row:nth-child(1) .o_field_color_picker button").toHaveCount(12);
await contains(
".o_data_row:nth-child(1) .o_field_color_picker .o_colorlist_item_color_6"
).click();
expect(".o_data_row:nth-child(1) .o_field_color_picker button").toHaveCount(12);
await contains(".o_data_row:nth-child(2) .o_data_cell").click();
expect(".o_data_row:nth-child(1) .o_field_color_picker button").toHaveCount(1);
expect(".o_data_row:nth-child(2) .o_field_color_picker button").toHaveCount(12);
});
test("column widths: dont overflow color picker in list", async () => {
Partner._fields.date_field = fields.Date({ string: "Date field" });
await mountView({
type: "list",
resModel: "res.partner",
arch: `
<list editable="top">
<field name="date_field"/>
<field name="int_field" widget="color_picker"/>
</list>`,
domain: [["id", "<", 0]],
});
await contains(".o_control_panel_main_buttons .o_list_button_add", {
visible: false,
}).click();
const date_column_width = queryAll(
'.o_list_table thead th[data-name="date_field"]'
)[0].style.width.replace("px", "");
const int_field_column_width = queryAll(
'.o_list_table thead th[data-name="int_field"]'
)[0].style.width.replace("px", "");
// Default values for date and int fields are: date: '92px', integer: '74px'
// With the screen growing, the proportion is kept and thus int_field would remain smaller than date if
// the color_picker wouldn't have widthInList set to '1'. With that property set, int_field size will be bigger
// than date's one.
expect(parseFloat(date_column_width)).toBeLessThan(parseFloat(int_field_column_width), {
message: "colorpicker should display properly (Horizontly)",
});
});

View file

@ -0,0 +1,158 @@
import { expect, test } from "@odoo/hoot";
import {
contains,
defineModels,
fieldInput,
fields,
mockService,
models,
mountView,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_name = "res.partner";
char_field = fields.Char({
string: "Char",
default: "My little Char Value",
trim: true,
});
_records = [
{
id: 1,
char_field: "char value",
},
];
_views = {
form: /* xml */ `
<form>
<sheet>
<group>
<field name="char_field" widget="CopyClipboardChar"/>
</group>
</sheet>
</form>`,
};
}
defineModels([Partner]);
test("Char Field: Copy to clipboard button", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
});
expect(".o_clipboard_button.o_btn_char_copy").toHaveCount(1);
});
test("Show copy button even on empty field", async () => {
Partner._records.push({
char_field: false,
});
await mountView({ type: "form", resModel: "res.partner", resId: 2 });
expect(".o_field_CopyClipboardChar[name='char_field'] .o_clipboard_button").toHaveCount(1);
});
test("Show copy button even on readonly empty field", async () => {
Partner._fields.char_field.readonly = true;
await mountView({
type: "form",
resModel: "res.partner",
arch: `
<form>
<sheet>
<group>
<field name="char_field" widget="CopyClipboardChar" />
</group>
</sheet>
</form>`,
});
expect(".o_field_CopyClipboardChar[name='char_field'] .o_clipboard_button").toHaveCount(1);
});
test("Display a tooltip on click", async () => {
mockService("popover", {
add(el, comp, params) {
expect(params).toEqual({ tooltip: "Copied" });
expect.step("copied tooltip");
return () => {};
},
});
patchWithCleanup(navigator.clipboard, {
async writeText(text) {
expect.step(text);
},
});
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
});
await expect(".o_clipboard_button.o_btn_char_copy").toHaveCount(1);
await contains(".o_clipboard_button", { visible: false }).click();
expect.verifySteps(["char value", "copied tooltip"]);
});
test("CopyClipboardButtonField in form view", async () => {
patchWithCleanup(navigator.clipboard, {
async writeText(text) {
expect.step(text);
},
});
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<group>
<field name="char_field" widget="CopyClipboardButton"/>
</group>
</form>`,
});
expect(".o_field_widget[name=char_field] input").toHaveCount(0);
expect(".o_clipboard_button.o_btn_char_copy").toHaveCount(1);
await contains(".o_clipboard_button.o_btn_char_copy").click();
expect.verifySteps(["char value"]);
});
test("CopyClipboardButtonField can be disabled", async () => {
patchWithCleanup(navigator.clipboard, {
async writeText(text) {
expect.step(text);
},
});
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<sheet>
<group>
<field name="char_field" disabled="char_field == 'char value'" widget="CopyClipboardButton"/>
<field name="char_field" widget="char"/>
</group>
</sheet>
</form>`,
});
expect(".o_clipboard_button.o_btn_char_copy[disabled]").toHaveCount(1);
await fieldInput("char_field").edit("another char value");
expect(".o_clipboard_button.o_btn_char_copy[disabled]").toHaveCount(0);
});

View file

@ -0,0 +1,544 @@
import { expect, test } from "@odoo/hoot";
import { click, edit, press, queryAllTexts, queryOne, scroll } from "@odoo/hoot-dom";
import { animationFrame, mockDate, mockTimeZone } from "@odoo/hoot-mock";
import {
assertDateTimePicker,
getPickerCell,
zoomOut,
} from "@web/../tests/core/datetime/datetime_test_helpers";
import {
clickSave,
contains,
defineModels,
defineParams,
fieldInput,
fields,
models,
mountView,
onRpc,
serverState,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_name = "res.partner";
date = fields.Date();
char_field = fields.Char({ string: "Char" });
_records = [
{
id: 1,
date: "2017-02-03",
char_field: "first char field",
},
];
_views = {
form: /* xml */ `
<form>
<sheet>
<group>
<field name="date"/>
<field name="char_field"/>
</group>
</sheet>
</form>
`,
};
}
defineModels([Partner]);
test("toggle datepicker", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_datetime_picker").toHaveCount(0);
await contains(".o_field_date input").click();
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
await fieldInput("char_field").click();
expect(".o_datetime_picker").toHaveCount(0);
});
test.tags("desktop");
test("open datepicker on Control+Enter", async () => {
defineParams({
lang_parameters: {
date_format: "%d/%m/%Y",
time_format: "%H:%M:%S",
},
});
await mountView({
resModel: "res.partner",
type: "form",
arch: `
<form>
<field name="date"/>
</form>
`,
});
expect(".o_field_date input").toHaveCount(1);
await press(["ctrl", "enter"]);
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
//edit the input and open the datepicker again with ctrl+enter
await contains(".o_field_date .o_input").click();
await edit("09/01/1997");
await press(["ctrl", "enter"]);
await animationFrame();
assertDateTimePicker({
title: "January 1997",
date: [
{
cells: [
[0, 0, 0, 1, 2, 3, 4],
[5, 6, 7, 8, [9], 10, 11],
[12, 13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24, 25],
[26, 27, 28, 29, 30, 31, 0],
],
daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
weekNumbers: [1, 2, 3, 4, 5],
},
],
});
});
test("toggle datepicker far in the future", async () => {
Partner._records[0].date = "9999-12-31";
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_datetime_picker").toHaveCount(0);
await contains(".o_field_date input").click();
expect(".o_datetime_picker").toHaveCount(1);
// focus another field
await fieldInput("char_field").click();
expect(".o_datetime_picker").toHaveCount(0);
});
test("date field is empty if no date is set", async () => {
Partner._records[0].date = false;
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_date input").toHaveCount(1);
expect(".o_field_date input").toHaveValue("");
});
test("set an invalid date when the field is already set", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_widget[name='date'] input").toHaveValue("02/03/2017");
await fieldInput("date").edit("invalid date");
expect(".o_field_widget[name='date'] input").toHaveValue("02/03/2017", {
message: "Should have been reset to the original value",
});
});
test("set an invalid date when the field is not set yet", async () => {
Partner._records[0].date = false;
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_widget[name='date'] input").toHaveValue("");
await fieldInput("date").edit("invalid date");
expect(".o_field_widget[name='date'] input").toHaveValue("");
});
test("value should not set on first click", async () => {
Partner._records[0].date = false;
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
await contains(".o_field_date input").click();
expect(".o_field_widget[name='date'] input").toHaveValue("");
await contains(getPickerCell(22)).click();
await contains(".o_field_date input").click();
expect(".o_date_item_cell.o_selected").toHaveText("22");
});
test("date field in form view (with positive time zone offset)", async () => {
mockTimeZone(2); // should be ignored by date fields
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
onRpc("web_save", ({ args }) => {
expect.step(args[1].date);
});
expect(".o_field_date input").toHaveValue("02/03/2017");
// open datepicker and select another value
await contains(".o_field_date input").click();
expect(".o_datetime_picker").toHaveCount(1);
expect(".o_date_item_cell.o_selected").toHaveCount(1);
// select 22 Feb 2017
await zoomOut();
await zoomOut();
await contains(getPickerCell("2017")).click();
await contains(getPickerCell("Feb")).click();
await contains(getPickerCell("22")).click();
expect(".o_datetime_picker").toHaveCount(0);
expect(".o_field_date input").toHaveValue("02/22/2017");
await clickSave();
expect.verifySteps(["2017-02-22"]);
expect(".o_field_date input").toHaveValue("02/22/2017");
});
test("date field in form view (with negative time zone offset)", async () => {
mockTimeZone(-2); // should be ignored by date fields
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_date input").toHaveValue("02/03/2017");
});
test("date field dropdown doesn't dissapear on scroll", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<div class="scrollable overflow-auto" style="height: 50px;">
<div style="height: 2000px;">
<field name="date" />
</div>
</div>
</form>`,
});
await contains(".o_field_date input").click();
expect(".o_datetime_picker").toHaveCount(1);
await scroll(".scrollable", { top: 50 });
expect(".scrollable").toHaveProperty("scrollTop", 50);
expect(".o_datetime_picker").toHaveCount(1);
});
test("date field with label opens datepicker on click", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<label for="date" string="What date is it" />
<field name="date" />
</form>`,
});
await contains("label.o_form_label").click();
expect(".o_datetime_picker").toHaveCount(1);
});
test("date field with warn_future option ", async () => {
Partner._records[0] = { id: 1 };
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<field name="date" options="{'warn_future': true}" />
</form>`,
});
await contains(".o_field_date input").click();
await zoomOut();
await zoomOut();
await contains(getPickerCell("2020")).click();
await contains(getPickerCell("Dec")).click();
await contains(getPickerCell("22")).click();
expect(".fa-exclamation-triangle").toHaveCount(1);
await fieldInput("date").clear();
expect(".fa-exclamation-triangle").toHaveCount(0);
});
test("date field with warn_future option: do not overwrite datepicker option", async () => {
Partner._onChanges.date = () => {};
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
// Do not let the date field get the focus in the first place
arch: `
<form>
<group>
<field name="char_field" />
<field name="date" options="{'warn_future': true}" />
</group>
</form>`,
});
expect(".o_field_widget[name='date'] input").toHaveValue("02/03/2017");
await contains(".o_form_button_create").click();
expect(".o_field_widget[name='date'] input").toHaveValue("");
});
test.tags("desktop");
test("date field in editable list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "res.partner",
arch: `
<list editable="bottom">
<field name="date"/>
</list>`,
});
const cell = queryOne("tr.o_data_row td:not(.o_list_record_selector)");
expect(cell).toHaveText("02/03/2017");
await contains(cell).click();
expect(".o_field_date input").toHaveCount(1);
expect(".o_field_date input").toBeFocused();
expect(".o_field_date input").toHaveValue("02/03/2017");
// open datepicker and select another value
await contains(".o_field_date input").click();
expect(".o_datetime_picker").toHaveCount(1);
await zoomOut();
await zoomOut();
await contains(getPickerCell("2017")).click();
await contains(getPickerCell("Feb")).click();
await contains(getPickerCell("22")).click();
expect(".o_datetime_picker").toHaveCount(0);
expect(".o_field_date input").toHaveValue("02/22/2017");
await contains(".o_list_button_save").click();
expect("tr.o_data_row td:not(.o_list_record_selector)").toHaveText("02/22/2017");
});
test.tags("desktop");
test("multi edition of date field in list view: clear date in input", async () => {
onRpc("has_group", () => true);
Partner._records = [
{ id: 1, date: "2017-02-03" },
{ id: 2, date: "2017-02-03" },
];
await mountView({
type: "list",
resModel: "res.partner",
arch: `
<list multi_edit="1">
<field name="date"/>
</list>`,
});
await contains(".o_data_row:eq(0) .o_list_record_selector input").click();
await contains(".o_data_row:eq(1) .o_list_record_selector input").click();
await contains(".o_data_row:eq(0) .o_data_cell").click();
expect(".o_field_date input").toHaveCount(1);
await fieldInput("date").clear();
expect(".modal").toHaveCount(1);
await contains(".modal .modal-footer .btn-primary").click();
expect(".o_data_row:first-child .o_data_cell").toHaveText("");
expect(".o_data_row:nth-child(2) .o_data_cell").toHaveText("");
});
test("date field remove value", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
onRpc("web_save", ({ args }) => {
expect.step(args[1].date);
});
expect(".o_field_date input").toHaveValue("02/03/2017");
await fieldInput("date").clear();
expect(".o_field_date input").toHaveValue("");
await clickSave();
expect(".o_field_date").toHaveText("");
expect.verifySteps([false]);
});
test("date field should select its content onclick when there is one", async () => {
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
await contains(".o_field_date input").click();
expect(".o_datetime_picker").toHaveCount(1);
const active = document.activeElement;
expect(active.tagName).toBe("INPUT");
expect(active.value.slice(active.selectionStart, active.selectionEnd)).toBe("02/03/2017");
});
test("date field supports custom formats", async () => {
defineParams({ lang_parameters: { date_format: "dd-MM-yyyy" } });
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
const dateViewValue = queryOne(".o_field_date input").value;
await contains(".o_field_date input").click();
expect(".o_field_date input").toHaveValue(dateViewValue);
await contains(getPickerCell("22")).click();
const dateEditValue = queryOne(".o_field_date input").value;
await clickSave();
expect(".o_field_date input").toHaveValue(dateEditValue);
});
test("date field supports internationalization", async () => {
serverState.lang = "nb_NO";
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
const dateViewForm = queryOne(".o_field_date input").value;
await contains(".o_field_date input").click();
expect(".o_field_date input").toHaveValue(dateViewForm);
expect(".o_zoom_out strong").toHaveText("februar 2017");
await contains(getPickerCell("22")).click();
const dateEditForm = queryOne(".o_field_date input").value;
await clickSave();
expect(".o_field_date input").toHaveValue(dateEditForm);
});
test("hit enter should update value", async () => {
mockTimeZone(2);
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
const year = new Date().getFullYear();
await contains(".o_field_date input").edit("01/08");
expect(".o_field_widget[name='date'] input").toHaveValue(`01/08/${year}`);
await contains(".o_field_date input").edit("08/01");
expect(".o_field_widget[name='date'] input").toHaveValue(`08/01/${year}`);
});
test("allow to use compute dates (+5d for instance)", async () => {
mockDate({ year: 2021, month: 2, day: 15 });
Partner._fields.date.default = "2019-09-15";
await mountView({ type: "form", resModel: "res.partner" });
expect(".o_field_date input").toHaveValue("09/15/2019");
await fieldInput("date").edit("+5d");
expect(".o_field_date input").toHaveValue("02/20/2021");
// Discard and do it again
await contains(".o_form_button_cancel").click();
expect(".o_field_date input").toHaveValue("09/15/2019");
await fieldInput("date").edit("+5d");
expect(".o_field_date input").toHaveValue("02/20/2021");
// Save and do it again
await clickSave();
expect(".o_field_date input").toHaveValue("02/20/2021");
await fieldInput("date").edit("+5d");
expect(".o_field_date input").toHaveValue("02/20/2021");
});
test("date field with min_precision option", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
// Do not let the date field get the focus in the first place
arch: `
<form>
<group>
<field name="date" options="{'min_precision': 'months'}" />
</group>
</form>`,
});
await click(".o_field_date input");
await animationFrame();
expect(".o_date_item_cell").toHaveCount(12);
expect(queryAllTexts(".o_date_item_cell")).toEqual([
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]);
expect(".o_date_item_cell.o_selected").toHaveText("Feb");
await click(getPickerCell("Jan"));
await animationFrame();
// The picker should be closed
expect(".o_date_item_cell").toHaveCount(0);
expect(".o_field_widget[name='date'] input").toHaveValue("01/01/2017");
});
test("date field with max_precision option", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
// Do not let the date field get the focus in the first place
arch: `
<form>
<group>
<field name="date" options="{'max_precision': 'months'}" />
</group>
</form>`,
});
await click(".o_field_date input");
await animationFrame();
// Try to zoomOut twice to be in the year selector
await zoomOut();
// Currently in the month selector
expect(".o_datetime_picker_header").toHaveText("2017");
await zoomOut();
// Stay in the month selector according to the max precision value
expect(".o_datetime_picker_header").toHaveText("2017");
expect(".o_date_item_cell.o_selected").toHaveText("Feb");
await click(getPickerCell("Jan"));
await animationFrame();
await click(getPickerCell("12"));
await animationFrame();
expect(".o_field_widget[name='date'] input").toHaveValue("01/12/2017");
});
test("DateField with onchange forcing a specific date", async () => {
mockDate("2009-05-04 10:00:00", +1);
Partner._onChanges.date = (obj) => {
if (obj.char_field === "force today") {
obj.date = "2009-05-04";
}
};
await mountView({
type: "form",
resModel: "res.partner",
arch: /* xml */ `
<form>
<field name="char_field"/>
<field name="date"/>
</form>`,
});
expect(".o_field_date input").toHaveValue("");
// enable the onchange
await contains(".o_field_widget[name=char_field] input").edit("force today");
// open the picker and try to set a value different from today
await click(".o_field_date input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
await contains(getPickerCell("22")).click(); // 22 May 2009
expect(".o_field_date input").toHaveValue("05/04/2009"); // value forced by the onchange
// do it again (the technical flow is a bit different as now the current value is already today)
await click(".o_field_date input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
await contains(getPickerCell("22")).click(); // 22 May 2009
expect(".o_field_date input").toHaveValue("05/04/2009"); // value forced by the onchange
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,662 @@
import { after, expect, test } from "@odoo/hoot";
import {
click,
edit,
queryAll,
queryAllProperties,
queryAllTexts,
resize,
select,
} from "@odoo/hoot-dom";
import { animationFrame, mockTimeZone } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
defineParams,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import {
getPickerApplyButton,
getPickerCell,
getTimePickers,
zoomOut,
} from "@web/../tests/core/datetime/datetime_test_helpers";
import { resetDateFieldWidths } from "@web/views/list/column_width_hook";
class Partner extends models.Model {
date = fields.Date({ string: "A date", searchable: true });
datetime = fields.Datetime({ string: "A datetime", searchable: true });
p = fields.One2many({
string: "one2many field",
relation: "partner",
searchable: true,
});
_records = [
{
id: 1,
date: "2017-02-03",
datetime: "2017-02-08 10:00:00",
p: [],
},
{
id: 2,
date: false,
datetime: false,
},
];
}
class User extends models.Model {
_name = "res.users";
name = fields.Char();
has_group() {
return true;
}
}
defineModels([Partner, User]);
test("DatetimeField in form view", async () => {
mockTimeZone(+2); // UTC+2
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: '<form><field name="datetime"/></form>',
});
const expectedDateString = "02/08/2017 12:00:00"; // 10:00:00 without timezone
expect(".o_field_datetime input").toHaveValue(expectedDateString, {
message: "the datetime should be correctly displayed",
});
// datepicker should not open on focus
expect(".o_datetime_picker").toHaveCount(0);
await click(".o_field_datetime input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
// select 22 April 2018 at 8:25
await zoomOut();
await zoomOut();
await click(getPickerCell("2018"));
await animationFrame();
await click(getPickerCell("Apr"));
await animationFrame();
await click(getPickerCell("22"));
await animationFrame();
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await select("8", { target: hourSelect });
await animationFrame();
await select("25", { target: minuteSelect });
await animationFrame();
// Close the datepicker
await click(".o_form_view_container");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0, { message: "datepicker should be closed" });
const newExpectedDateString = "04/22/2018 08:25:00";
expect(".o_field_datetime input").toHaveValue(newExpectedDateString, {
message: "the selected date should be displayed in the input",
});
// save
await clickSave();
expect(".o_field_datetime input").toHaveValue(newExpectedDateString, {
message: "the selected date should be displayed after saving",
});
});
test("DatetimeField only triggers fieldChange when a day is picked and when an hour/minute is selected", async () => {
mockTimeZone(+2);
Partner._onChanges.datetime = () => {};
onRpc("onchange", () => expect.step("onchange"));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form><field name="datetime"/></form>',
});
await click(".o_field_datetime input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
// select 22 April 2018 at 8:25
await zoomOut();
await zoomOut();
await click(getPickerCell("2018"));
await animationFrame();
await click(getPickerCell("Apr"));
await animationFrame();
await click(getPickerCell("22"));
await animationFrame();
expect.verifySteps([]);
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await select("8", { target: hourSelect });
await animationFrame();
await select("25", { target: minuteSelect });
await animationFrame();
expect.verifySteps([]);
// Close the datepicker
await click(document.body);
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
expect(".o_field_datetime input").toHaveValue("04/22/2018 08:25:00");
expect.verifySteps(["onchange"]);
});
test("DatetimeField with datetime formatted without second", async () => {
mockTimeZone(0);
Partner._fields.datetime = fields.Datetime({
string: "A datetime",
searchable: true,
default: "2017-08-02 12:00:05",
required: true,
});
defineParams({
lang_parameters: {
date_format: "%m/%d/%Y",
time_format: "%H:%M",
},
});
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="datetime"/></form>',
});
const expectedDateString = "08/02/2017 12:00";
expect(".o_field_datetime input").toHaveValue(expectedDateString, {
message: "the datetime should be correctly displayed",
});
await click(".o_form_button_cancel");
expect(".modal").toHaveCount(0, { message: "there should not be a Warning dialog" });
});
test("DatetimeField in editable list view", async () => {
mockTimeZone(+2);
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list editable="bottom"><field name="datetime"/></list>`,
});
const expectedDateString = "02/08/2017 12:00:00"; // 10:00:00 without timezone
expect("tr.o_data_row td:not(.o_list_record_selector):first").toHaveText(expectedDateString, {
message: "the datetime should be correctly displayed",
});
// switch to edit mode
await click(".o_data_row .o_data_cell");
await animationFrame();
expect(".o_field_datetime input").toHaveCount(1, {
message: "the view should have a date input for editable mode",
});
expect(".o_field_datetime input").toBeFocused({
message: "date input should have the focus",
});
expect(".o_field_datetime input").toHaveValue(expectedDateString, {
message: "the date should be correct in edit mode",
});
expect(".o_datetime_picker").toHaveCount(0);
await click(".o_field_datetime input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
// select 22 April 2018 at 8:25
await zoomOut();
await zoomOut();
await click(getPickerCell("2018"));
await animationFrame();
await click(getPickerCell("Apr"));
await animationFrame();
await click(getPickerCell("22"));
await animationFrame();
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await select("8", { target: hourSelect });
await animationFrame();
await select("25", { target: minuteSelect });
await animationFrame();
// Apply changes
await click(getPickerApplyButton());
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0, { message: "datepicker should be closed" });
const newExpectedDateString = "04/22/2018 08:25:00";
expect(".o_field_datetime input:first").toHaveValue(newExpectedDateString, {
message: "the date should be correct in edit mode",
});
// save
await click(".o_list_button_save");
await animationFrame();
expect("tr.o_data_row td:not(.o_list_record_selector):first").toHaveText(
newExpectedDateString,
{ message: "the selected datetime should be displayed after saving" }
);
});
test.tags("desktop");
test("multi edition of DatetimeField in list view: edit date in input", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: '<list multi_edit="1"><field name="datetime"/></list>',
});
// select two records and edit them
await click(".o_data_row:eq(0) .o_list_record_selector input");
await animationFrame();
await click(".o_data_row:eq(1) .o_list_record_selector input");
await animationFrame();
await click(".o_data_row:eq(0) .o_data_cell");
await animationFrame();
expect(".o_field_datetime input").toHaveCount(1);
await click(".o_field_datetime input");
await edit("10/02/2019 09:00:00", { confirm: "Enter" });
await animationFrame();
expect(".modal").toHaveCount(1);
await click(".modal .modal-footer .btn-primary");
await animationFrame();
expect(".o_data_row:first-child .o_data_cell:first").toHaveText("10/02/2019 09:00:00");
expect(".o_data_row:nth-child(2) .o_data_cell:first").toHaveText("10/02/2019 09:00:00");
});
test.tags("desktop");
test("multi edition of DatetimeField in list view: clear date in input", async () => {
Partner._records[1].datetime = "2017-02-08 10:00:00";
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: '<list multi_edit="1"><field name="datetime"/></list>',
});
// select two records and edit them
await click(".o_data_row:eq(0) .o_list_record_selector input");
await animationFrame();
await click(".o_data_row:eq(1) .o_list_record_selector input");
await animationFrame();
await click(".o_data_row:eq(0) .o_data_cell");
await animationFrame();
expect(".o_field_datetime input").toHaveCount(1);
await click(".o_field_datetime input");
await animationFrame();
await edit("", { confirm: "Enter" });
await animationFrame();
expect(".modal").toHaveCount(1);
await click(".modal .modal-footer .btn-primary");
await animationFrame();
expect(".o_data_row:first-child .o_data_cell:first").toHaveText("");
expect(".o_data_row:nth-child(2) .o_data_cell:first").toHaveText("");
});
test("DatetimeField remove value", async () => {
expect.assertions(4);
mockTimeZone(+2);
onRpc("web_save", ({ args }) => {
expect(args[1].datetime).toBe(false, { message: "the correct value should be saved" });
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form><field name="datetime"/></form>',
});
expect(".o_field_datetime input:first").toHaveValue("02/08/2017 12:00:00", {
message: "the date should be correct in edit mode",
});
await click(".o_field_datetime input");
await edit("");
await animationFrame();
await click(document.body);
await animationFrame();
expect(".o_field_datetime input:first").toHaveValue("", {
message: "should have an empty input",
});
// save
await clickSave();
expect(".o_field_datetime:first").toHaveText("", {
message: "the selected date should be displayed after saving",
});
});
test("DatetimeField with date/datetime widget (with day change) does not care about widget", async () => {
mockTimeZone(-4);
onRpc("has_group", () => true);
Partner._records[0].p = [2];
Partner._records[1].datetime = "2017-02-08 02:00:00"; // UTC
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="p">
<list><field name="datetime" /></list>
<form><field name="datetime" widget="date" /></form>
</field>
</form>`,
});
const expectedDateString = "02/07/2017 22:00:00"; // local time zone
expect(".o_field_widget[name='p'] .o_data_cell").toHaveText(expectedDateString, {
message: "the datetime (datetime widget) should be correctly displayed in list view",
});
// switch to form view
await click(".o_field_widget[name='p'] .o_data_cell");
await animationFrame();
expect(".modal .o_field_date[name='datetime'] input").toHaveValue("02/07/2017 22:00:00", {
message: "the datetime (date widget) should be correctly displayed in form view",
});
});
test("DatetimeField with date/datetime widget (without day change) does not care about widget", async () => {
mockTimeZone(-4);
Partner._records[0].p = [2];
Partner._records[1].datetime = "2017-02-08 10:00:00"; // without timezone
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="p">
<list><field name="datetime" /></list>
<form><field name="datetime" widget="date" /></form>
</field>
</form>`,
});
const expectedDateString = "02/08/2017 06:00:00"; // with timezone
expect(".o_field_widget[name='p'] .o_data_cell:first").toHaveText(expectedDateString, {
message: "the datetime (datetime widget) should be correctly displayed in list view",
});
// switch to form view
await click(".o_field_widget[name='p'] .o_data_cell");
await animationFrame();
expect(".modal .o_field_date[name='datetime'] input:first").toHaveValue("02/08/2017 06:00:00", {
message: "the datetime (date widget) should be correctly displayed in form view",
});
});
test("datetime field: hit enter should update value", async () => {
// This test verifies that the field datetime is correctly computed when:
// - we press enter to validate our entry
// - we click outside the field to validate our entry
// - we save
mockTimeZone(+2);
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="datetime"/></form>',
resId: 1,
});
// Enter a beginning of date and press enter to validate
await click(".o_field_datetime input");
await edit("01/08/22 14:30:40", { confirm: "Enter" });
const datetimeValue = `01/08/2022 14:30:40`;
expect(".o_field_datetime input:first").toHaveValue(datetimeValue);
// Click outside the field to check that the field is not changed
await click(document.body);
expect(".o_field_datetime input:first").toHaveValue(datetimeValue);
// Save and check that it's still ok
await clickSave();
expect(".o_field_datetime input:first").toHaveValue(datetimeValue);
});
test("DateTimeField with label opens datepicker on click", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<label for="datetime" string="When is it" />
<field name="datetime" />
</form>`,
});
await click("label.o_form_label");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1, { message: "datepicker should be opened" });
});
test("datetime field: use picker with arabic numbering system", async () => {
defineParams({ lang: "ar_001" }); // Select Arab language
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form string="Partners"><field name="datetime" /></form>`,
});
expect("[name=datetime] input:first").toHaveValue("٠٢/٠٨/٢٠١٧ ١١:٠٠:٠٠");
await click("[name=datetime] input");
await animationFrame();
await select(45, { target: getTimePickers()[0][1] });
await animationFrame();
expect("[name=datetime] input:first").toHaveValue("٠٢/٠٨/٢٠١٧ ١١:٤٥:٠٠");
});
test("datetime field in list view with show_seconds option", async () => {
mockTimeZone(+2);
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list>
<field name="datetime" widget="datetime" options="{'show_seconds': false}" string="show_seconds as false"/>
<field name="datetime" widget="datetime" string="show_seconds as true"/>
</list>`,
});
expect(queryAllTexts(".o_data_row:first .o_field_datetime")).toEqual([
"02/08/2017 12:00",
"02/08/2017 12:00:00",
]);
});
test("edit a datetime field in form view with show_seconds option", async () => {
mockTimeZone(+2);
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="datetime" widget="datetime" options="{'show_seconds': false}" string="show_seconds as false"/>
<field name="datetime" widget="datetime" string="show_seconds as true"/>
</form>`,
});
const [dateField1, dateField2] = queryAll(".o_input.cursor-pointer");
await click(dateField1);
await animationFrame();
expect(".o_time_picker_select").toHaveCount(3); // 3rd 'o_time_picker_select' is for the seconds
await edit("02/08/2017 11:00:00", { confirm: "Enter" });
await animationFrame();
expect(dateField1).toHaveValue("02/08/2017 11:00", {
message: "seconds should be hidden for showSeconds false",
});
expect(dateField2).toHaveValue("02/08/2017 11:00:00", {
message: "seconds should be visible for showSeconds true",
});
});
test("datetime field (with widget) in kanban with show_time option", async () => {
mockTimeZone(+2);
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="datetime" widget="datetime" options="{'show_time': false}"/>
</t>
</templates>
</kanban>`,
resId: 1,
});
expect(".o_kanban_record:first").toHaveText("02/08/2017");
});
test("datetime field in list with show_time option", async () => {
mockTimeZone(+2);
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="bottom">
<field name="datetime" options="{'show_time': false}"/>
<field name="datetime" />
</list>
`,
});
const dates = queryAll(".o_field_cell");
expect(dates[0]).toHaveText("02/08/2017", {
message: "for date field only date should be visible with date widget",
});
expect(dates[1]).toHaveText("02/08/2017 12:00:00", {
message: "for datetime field only date should be visible with date widget",
});
await click(dates[0]);
await animationFrame();
expect(".o_field_datetime input:first").toHaveValue("02/08/2017 12:00:00", {
message: "for datetime field both date and time should be visible with datetime widget",
});
});
test("datetime field in form view with condensed option", async () => {
mockTimeZone(-2); // UTC-2
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="datetime" options="{'condensed': true}"/>
<field name="datetime" options="{'condensed': true}" readonly="1"/>
</form>`,
});
const expectedDateString = "2/8/2017 8:00:00"; // 10:00:00 without timezone
expect(".o_field_datetime input").toHaveValue(expectedDateString);
expect(".o_field_datetime.o_readonly_modifier").toHaveText(expectedDateString);
});
test("datetime field in kanban view with condensed option", async () => {
mockTimeZone(-2); // UTC-2
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="datetime" options="{'condensed': true}"/>
</t>
</templates>
</kanban>`,
});
const expectedDateString = "2/8/2017 8:00:00"; // 10:00:00 without timezone
expect(".o_kanban_record:first").toHaveText(expectedDateString);
});
test("list datetime: column widths (show_time=false)", async () => {
await resize({ width: 800 });
document.body.style.fontFamily = "sans-serif";
resetDateFieldWidths();
after(resetDateFieldWidths);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list>
<field name="datetime" widget="datetime" options="{'show_time': false }" />
<field name="display_name" />
</list>`,
});
expect(queryAllTexts(".o_data_row:eq(0) .o_data_cell")).toEqual(["02/08/2017", "partner,1"]);
expect(queryAllProperties(".o_list_table thead th", "offsetWidth")).toEqual([40, 83, 677]);
});

View file

@ -0,0 +1,130 @@
import { expect, test } from "@odoo/hoot";
import { Deferred, press, waitFor, waitUntil } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { onWillStart } from "@odoo/owl";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { DynamicPlaceholderPopover } from "@web/views/fields/dynamic_placeholder_popover";
class Partner extends models.Model {
char = fields.Char();
placeholder = fields.Char({ default: "partner" });
product_id = fields.Many2one({ relation: "product" });
_records = [
{ id: 1, char: "yop", product_id: 37 },
{ id: 2, char: "blip", product_id: false },
{ id: 4, char: "abc", product_id: 41 },
];
_views = {
form: /* xml */ `
<form>
<field name="placeholder" invisible="1"/>
<sheet>
<group>
<field
name="char"
options="{
'dynamic_placeholder': true,
'dynamic_placeholder_model_reference_field': 'placeholder'
}"
/>
</group>
</sheet>
</form>
`,
};
}
class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
_records = [
{ id: 37, name: "xphone" },
{ id: 41, name: "xpad" },
];
}
defineModels([Partner, Product]);
onRpc("has_group", () => true);
onRpc("mail_allowed_qweb_expressions", () => []);
test("dynamic placeholder close with click out", async () => {
await mountView({ type: "form", resModel: "partner", resId: 1 });
await contains(".o_field_char input").edit("#", { confirm: false });
expect(".o_model_field_selector_popover").toHaveCount(1);
await contains(".o_content").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
await contains(".o_field_char input").edit("#", { confirm: false });
await contains(".o_model_field_selector_popover_item_relation").click();
await contains(".o_content").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
});
test("dynamic placeholder close with escape", async () => {
await mountView({ type: "form", resModel: "partner", resId: 1 });
await contains(".o_field_char input").edit("#", { confirm: false });
expect(".o_model_field_selector_popover").toHaveCount(1);
press("Escape");
await animationFrame();
expect(".o_model_field_selector_popover").toHaveCount(0);
await contains(".o_field_char input").edit("#", { confirm: false });
await contains(".o_model_field_selector_popover_item_relation").click();
press("Escape");
await animationFrame();
expect(".o_model_field_selector_popover").toHaveCount(0);
});
test("dynamic placeholder close when clicking on the cross", async () => {
await mountView({ type: "form", resModel: "partner", resId: 1 });
await contains(".o_field_char input").edit("#", { confirm: false });
expect(".o_model_field_selector_popover").toHaveCount(1);
await contains(".o_model_field_selector_popover_close").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
await contains(".o_field_char input").edit("#", { confirm: false });
await contains(".o_model_field_selector_popover_item_relation").click();
await contains(".o_model_field_selector_popover_close").click();
expect(".o_model_field_selector_popover").toHaveCount(0);
});
test("correctly cache model qweb variables and don't prevent opening of other popovers", async () => {
const def = new Deferred();
let willStarts = 0;
patchWithCleanup(DynamicPlaceholderPopover.prototype, {
setup() {
super.setup();
onWillStart(() => {
willStarts++;
});
},
});
onRpc("partner", "mail_allowed_qweb_expressions", async () => {
expect.step("mail_allowed_qweb_expressions");
await def;
return [];
});
await mountView({ type: "form", resModel: "partner", resId: 1 });
await contains(".o_field_char input").edit("#", { confirm: false });
await waitUntil(() => willStarts === 1);
await contains(".o_field_char input").edit("#", { confirm: false });
await waitUntil(() => willStarts === 2);
def.resolve();
await waitFor(".o_model_field_selector_popover");
expect(willStarts).toBe(2);
expect.verifySteps(["mail_allowed_qweb_expressions"]);
});

View file

@ -0,0 +1,116 @@
import { expect, getFixture, test } from "@odoo/hoot";
import {
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
onRpc,
} from "../../web_test_helpers";
import { queryAllTexts, queryFirst } from "@odoo/hoot-dom";
class Contact extends models.Model {
email = fields.Char();
}
defineModels([Contact]);
onRpc("has_group", () => true);
test("in form view", async () => {
Contact._records = [{ id: 1, email: "john.doe@odoo.com" }];
await mountView({
type: "form",
resModel: "contact",
resId: 1,
arch: `<form><field name="email" widget="email"/></form>`,
});
expect(`.o_field_email input[type="email"]`).toHaveCount(1);
expect(`.o_field_email input[type="email"]`).toHaveValue("john.doe@odoo.com");
expect(`.o_field_email a`).toHaveCount(1);
expect(`.o_field_email a`).toHaveAttribute("href", "mailto:john.doe@odoo.com");
expect(`.o_field_email a`).toHaveAttribute("target", "_blank");
await fieldInput("email").edit("new@odoo.com");
expect(`.o_field_email input[type="email"]`).toHaveValue("new@odoo.com");
});
test("in editable list view", async () => {
Contact._records = [
{ id: 1, email: "john.doe@odoo.com" },
{ id: 2, email: "jane.doe@odoo.com" },
];
await mountView({
type: "list",
resModel: "contact",
arch: '<list editable="bottom"><field name="email" widget="email"/></list>',
});
expect(`tbody td:not(.o_list_record_selector) a`).toHaveCount(2);
expect(`.o_field_email a`).toHaveCount(2);
expect(queryAllTexts(`tbody td:not(.o_list_record_selector) a`)).toEqual([
"john.doe@odoo.com",
"jane.doe@odoo.com",
]);
expect(".o_field_email a:first").toHaveAttribute("href", "mailto:john.doe@odoo.com");
let cell = queryFirst("tbody td:not(.o_list_record_selector)");
await contains(cell).click();
expect(cell.parentElement).toHaveClass("o_selected_row");
expect(`.o_field_email input[type="email"]`).toHaveValue("john.doe@odoo.com");
await fieldInput("email").edit("new@odoo.com");
await contains(getFixture()).click();
cell = queryFirst("tbody td:not(.o_list_record_selector)");
expect(cell.parentElement).not.toHaveClass("o_selected_row");
expect(queryAllTexts(`tbody td:not(.o_list_record_selector) a`)).toEqual([
"new@odoo.com",
"jane.doe@odoo.com",
]);
expect(".o_field_email a:first").toHaveAttribute("href", "mailto:new@odoo.com");
});
test("with empty value", async () => {
await mountView({
type: "form",
resModel: "contact",
arch: `<form><field name="email" widget="email" placeholder="Placeholder"/></form>`,
});
expect(`.o_field_email input`).toHaveValue("");
});
test("with placeholder", async () => {
await mountView({
type: "form",
resModel: "contact",
arch: `<form><field name="email" widget="email" placeholder="Placeholder"/></form>`,
});
expect(`.o_field_email input`).toHaveAttribute("placeholder", "Placeholder");
});
test("trim user value", async () => {
await mountView({
type: "form",
resModel: "contact",
arch: '<form><field name="email" widget="email"/></form>',
});
await fieldInput("email").edit(" hello@gmail.com ");
await contains(getFixture()).click();
expect(`.o_field_email input`).toHaveValue("hello@gmail.com");
});
test("onchange scenario with readonly", async () => {
Contact._fields.phone = fields.Char({
onChange: (record) => {
record.email = "onchange@domain.ext";
},
});
Contact._records = [{ id: 1, email: "default@domain.ext" }];
await mountView({
type: "form",
resModel: "contact",
resId: 1,
arch: `<form><field name="phone"/><field name="email" widget="email" readonly="1"/></form>`,
});
expect(`.o_field_email`).toHaveText("default@domain.ext");
await fieldInput("phone").edit("047412345");
expect(`.o_field_email`).toHaveText("onchange@domain.ext");
});

View file

@ -0,0 +1,97 @@
import { expect, test } from "@odoo/hoot";
import { contains, defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Program extends models.Model {
type = fields.Selection({
required: true,
selection: [
["coupon", "Coupons"],
["promotion", "Promotion"],
["gift_card", "Gift card"],
],
});
available_types = fields.Json({
required: true,
});
_records = [
{ id: 1, type: "coupon", available_types: "['coupon', 'promotion']" },
{ id: 2, type: "gift_card", available_types: "['gift_card', 'promotion']" },
];
}
defineModels([Program]);
// Note: the `toHaveCount` always check for one more as there will be an invisible empty option every time.
test(`FilterableSelectionField test whitelist`, async () => {
await mountView({
resModel: "program",
type: "form",
arch: `
<form>
<field name="type" widget="filterable_selection" options="{'whitelisted_values': ['coupons', 'promotion']}"/>
</form>
`,
resId: 1,
});
expect(`select option`).toHaveCount(3);
expect(`.o_field_widget[name="type"] select option[value='"coupon"']`).toHaveCount(1);
expect(`.o_field_widget[name="type"] select option[value='"promotion"']`).toHaveCount(1);
});
test(`FilterableSelectionField test blacklist`, async () => {
await mountView({
resModel: "program",
type: "form",
arch: `
<form>
<field name="type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
</form>
`,
resId: 1,
});
expect(`select option`).toHaveCount(3);
expect(`.o_field_widget[name="type"] select option[value='"coupon"']`).toHaveCount(1);
expect(`.o_field_widget[name="type"] select option[value='"promotion"']`).toHaveCount(1);
});
test(`FilterableSelectionField test with invalid value`, async () => {
// The field should still display the current value in the list
await mountView({
resModel: "program",
type: "form",
arch: `
<form>
<field name="type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
</form>
`,
resId: 2,
});
expect(`select option`).toHaveCount(4);
expect(`.o_field_widget[name="type"] select option[value='"gift_card"']`).toHaveCount(1);
expect(`.o_field_widget[name="type"] select option[value='"coupon"']`).toHaveCount(1);
expect(`.o_field_widget[name="type"] select option[value='"promotion"']`).toHaveCount(1);
await contains(`.o_field_widget[name="type"] select`).select(`"coupon"`);
expect(`select option`).toHaveCount(3);
expect(`.o_field_widget[name="type"] select option[value='"gift_card"']`).toHaveCount(0);
expect(`.o_field_widget[name="type"] select option[value='"coupon"']`).toHaveCount(1);
expect(`.o_field_widget[name="type"] select option[value='"promotion"']`).toHaveCount(1);
});
test(`FilterableSelectionField test whitelist_fname`, async () => {
await mountView({
resModel: "program",
type: "form",
arch: `
<form>
<field name="available_types" invisible="1"/>
<field name="type" widget="filterable_selection" options="{'whitelist_fname': 'available_types'}"/>
</form>
`,
resId: 1,
});
expect(`select option`).toHaveCount(3);
expect(`.o_field_widget[name="type"] select option[value='"coupon"']`).toHaveCount(1);
expect(`.o_field_widget[name="type"] select option[value='"promotion"']`).toHaveCount(1);
});

View file

@ -0,0 +1,82 @@
import { expect, test } from "@odoo/hoot";
import {
clickSave,
contains,
defineModels,
defineParams,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
qux = fields.Float();
_records = [{ id: 1, qux: 9.1 }];
}
defineModels([Partner]);
test("FloatFactorField in form view", async () => {
expect.assertions(3);
onRpc("partner", "web_save", ({ args }) => {
// 2.3 / 0.5 = 4.6
expect(args[1].qux).toBe(4.6, { message: "the correct float value should be saved" });
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<sheet>
<field name="qux" widget="float_factor" options="{'factor': 0.5}" digits="[16,2]" />
</sheet>
</form>`,
});
expect(".o_field_widget[name='qux'] input").toHaveValue("4.55", {
message: "The value should be rendered correctly in the input.",
});
await contains(".o_field_widget[name='qux'] input").edit("2.3");
await clickSave();
expect(".o_field_widget input").toHaveValue("2.30", {
message: "The new value should be saved and displayed properly.",
});
});
test("FloatFactorField comma as decimal point", async () => {
expect.assertions(2);
// patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "" });
defineParams({
lang_parameters: {
decimal_point: ",",
thousands_sep: "",
},
});
onRpc("partner", "web_save", ({ args }) => {
// 2.3 / 0.5 = 4.6
expect(args[1].qux).toBe(4.6);
expect.step("save");
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<sheet>
<field name="qux" widget="float_factor" options="{'factor': 0.5}" digits="[16,2]" />
</sheet>
</form>`,
});
await contains(".o_field_widget[name='qux'] input").edit("2,3");
await clickSave();
expect.verifySteps(["save"]);
});

View file

@ -0,0 +1,459 @@
import { expect, test } from "@odoo/hoot";
import {
clickSave,
contains,
defineModels,
defineParams,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { Component, xml } from "@odoo/owl";
import { registry } from "@web/core/registry";
class Partner extends models.Model {
float_field = fields.Float({ string: "Float field" });
_records = [
{ id: 1, float_field: 0.36 },
{ id: 2, float_field: 0 },
{ id: 3, float_field: -3.89859 },
{ id: 4, float_field: 0 },
{ id: 5, float_field: 9.1 },
{ id: 100, float_field: 2.034567e3 },
{ id: 101, float_field: 3.75675456e6 },
{ id: 102, float_field: 6.67543577586e12 },
];
}
defineModels([Partner]);
onRpc("has_group", () => true);
test("human readable format 1", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 101,
arch: `<form><field name="float_field" options="{'human_readable': 'true'}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("4M", {
message: "The value should be rendered in human readable format (k, M, G, T).",
});
});
test("human readable format 2", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 100,
arch: `<form><field name="float_field" options="{'human_readable': 'true', 'decimals': 1}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("2.0k", {
message: "The value should be rendered in human readable format (k, M, G, T).",
});
});
test("human readable format 3", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 102,
arch: `<form><field name="float_field" options="{'human_readable': 'true', 'decimals': 4}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("6.6754T", {
message: "The value should be rendered in human readable format (k, M, G, T).",
});
});
test("still human readable when readonly", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 102,
arch: `<form><field readonly="true" name="float_field" options="{'human_readable': 'true', 'decimals': 4}"/></form>`,
});
expect(".o_field_widget span").toHaveText("6.6754T", {
message: "The value should be rendered in human readable format when input is readonly.",
});
});
test("unset field should be set to 0", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 4,
arch: '<form><field name="float_field"/></form>',
});
expect(".o_field_widget").not.toHaveClass("o_field_empty", {
message: "Non-set float field should be considered as 0.00",
});
expect(".o_field_widget input").toHaveValue("0.00", {
message: "Non-set float field should be considered as 0.",
});
});
test("use correct digit precision from field definition", async () => {
Partner._fields.float_field = fields.Float({ string: "Float field", digits: [0, 1] });
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: '<form><field name="float_field"/></form>',
});
expect(".o_field_float input").toHaveValue("0.4", {
message: "should contain a number rounded to 1 decimal",
});
});
test("use correct digit precision from options", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `<form><field name="float_field" options="{ 'digits': [0, 1] }" /></form>`,
});
expect(".o_field_float input").toHaveValue("0.4", {
message: "should contain a number rounded to 1 decimal",
});
});
test("use correct digit precision from field attrs", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: '<form><field name="float_field" digits="[0, 1]" /></form>',
});
expect(".o_field_float input").toHaveValue("0.4", {
message: "should contain a number rounded to 1 decimal",
});
});
test("with 'step' option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `<form><field name="float_field" options="{'type': 'number', 'step': 0.3}"/></form>`,
});
expect(".o_field_widget input").toHaveAttribute("step", "0.3", {
message: 'Integer field with option type must have a step attribute equals to "3".',
});
});
test("basic flow in form view", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: `<form><field name="float_field" options="{ 'digits': [0, 3] }" /></form>`,
});
expect(".o_field_widget").not.toHaveClass("o_field_empty", {
message: "Float field should be considered set for value 0.",
});
expect(".o_field_widget input").toHaveValue("0.000", {
message: "The value should be displayed properly.",
});
await contains('div[name="float_field"] input').edit("108.2451938598598");
expect(".o_field_widget[name=float_field] input").toHaveValue("108.245", {
message: "The value should have been formatted on blur.",
});
await contains(".o_field_widget[name=float_field] input").edit("18.8958938598598");
await clickSave();
expect(".o_field_widget input").toHaveValue("18.896", {
message: "The new value should be rounded properly.",
});
});
test("use a formula", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: `<form><field name="float_field" options="{ 'digits': [0, 3] }" /></form>`,
});
await contains(".o_field_widget[name=float_field] input").edit("=20+3*2");
await clickSave();
expect(".o_field_widget input").toHaveValue("26.000", {
message: "The new value should be calculated properly.",
});
await contains(".o_field_widget[name=float_field] input").edit("=2**3");
await clickSave();
expect(".o_field_widget input").toHaveValue("8.000", {
message: "The new value should be calculated properly.",
});
await contains(".o_field_widget[name=float_field] input").edit("=2^3");
await clickSave();
expect(".o_field_widget input").toHaveValue("8.000", {
message: "The new value should be calculated properly.",
});
await contains(".o_field_widget[name=float_field] input").edit("=100/3");
await clickSave();
expect(".o_field_widget input").toHaveValue("33.333", {
message: "The new value should be calculated properly.",
});
});
test("use incorrect formula", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: `<form><field name="float_field" options="{ 'digits': [0, 3] }" /></form>`,
});
await contains(".o_field_widget[name=float_field] input").edit("=abc", { confirm: false });
await clickSave();
expect(".o_field_widget[name=float_field]").toHaveClass("o_field_invalid", {
message: "fload field should be displayed as invalid",
});
expect(".o_form_editable").toHaveCount(1, { message: "form view should still be editable" });
await contains(".o_field_widget[name=float_field] input").edit("=3:2?+4", { confirm: false });
await clickSave();
expect(".o_form_editable").toHaveCount(1, { message: "form view should still be editable" });
expect(".o_field_widget[name=float_field]").toHaveClass("o_field_invalid", {
message: "float field should be displayed as invalid",
});
});
test.tags("desktop");
test("float field in editable list view", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="bottom">
<field name="float_field" widget="float" digits="[5,3]" />
</list>`,
});
// switch to edit mode
await contains("tr.o_data_row td:not(.o_list_record_selector)").click();
expect('div[name="float_field"] input').toHaveCount(1, {
message: "The view should have 1 input for editable float.",
});
await contains('div[name="float_field"] input').edit("108.2458938598598", { confirm: "blur" });
expect(".o_field_widget:eq(0)").toHaveText("108.246", {
message: "The value should have been formatted on blur.",
});
await contains("tr.o_data_row td:not(.o_list_record_selector)").click();
await contains('div[name="float_field"] input').edit("18.8958938598598", { confirm: false });
await contains(".o_control_panel_main_buttons .o_list_button_save").click();
expect(".o_field_widget:eq(0)").toHaveText("18.896", {
message: "The new value should be rounded properly.",
});
});
test("float field with type number option", async () => {
defineParams({
lang_parameters: {
grouping: [3, 0],
},
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" options="{'type': 'number'}"/>
</form>`,
resId: 4,
});
expect(".o_field_widget input").toHaveAttribute("type", "number", {
message: 'Float field with option type must have a type attribute equals to "number".',
});
await contains(".o_field_widget input").fill("123456.7890", { instantly: true });
await clickSave();
expect(".o_field_widget input").toHaveValue(123456.789, {
message:
"Float value must be not formatted if input type is number. (but the trailing 0 is gone)",
});
});
test("float field with type number option and comma decimal separator", async () => {
defineParams({
lang_parameters: {
thousands_sep: ".",
decimal_point: ",",
grouping: [3, 0],
},
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" options="{'type': 'number'}"/>
</form>`,
resId: 4,
});
expect(".o_field_widget input").toHaveAttribute("type", "number", {
message: 'Float field with option type must have a type attribute equals to "number".',
});
await contains(".o_field_widget[name=float_field] input").fill("123456.789", {
instantly: true,
});
await clickSave();
expect(".o_field_widget input").toHaveValue(123456.789, {
message: "Float value must be not formatted if input type is number.",
});
});
test("float field without type number option", async () => {
defineParams({
lang_parameters: {
grouping: [3, 0],
},
});
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="float_field"/></form>',
resId: 4,
});
expect(".o_field_widget input").toHaveAttribute("type", "text", {
message: "Float field with option type must have a text type (default type).",
});
await contains(".o_field_widget[name=float_field] input").edit("123456.7890");
await clickSave();
expect(".o_field_widget input").toHaveValue("123,456.79", {
message: "Float value must be formatted if input type isn't number.",
});
});
test("field with enable_formatting option as false", async () => {
defineParams({
lang_parameters: {
grouping: [3, 0],
},
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `<form><field name="float_field" options="{'enable_formatting': false}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("0.36", {
message: "Integer value must not be formatted",
});
await contains(".o_field_widget[name=float_field] input").edit("123456.789");
await clickSave();
expect(".o_field_widget input").toHaveValue("123456.789", {
message: "Integer value must be not formatted if input type is number.",
});
});
test.tags("desktop");
test("field with enable_formatting option as false in editable list view", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="bottom">
<field name="float_field" widget="float" digits="[5,3]" options="{'enable_formatting': false}" />
</list>`,
});
// switch to edit mode
await contains("tr.o_data_row td:not(.o_list_record_selector)").click();
expect('div[name="float_field"] input').toHaveCount(1, {
message: "The view should have 1 input for editable float.",
});
await contains('div[name="float_field"] input').edit("108.2458938598598", {
confirm: "blur",
});
expect(".o_field_widget:eq(0)").toHaveText("108.2458938598598", {
message: "The value should not be formatted on blur.",
});
await contains("tr.o_data_row td:not(.o_list_record_selector)").click();
await contains('div[name="float_field"] input').edit("18.8958938598598", {
confirm: false,
});
await contains(".o_control_panel_main_buttons .o_list_button_save").click();
expect(".o_field_widget:eq(0)").toHaveText("18.8958938598598", {
message: "The new value should not be rounded as well.",
});
});
test("float_field field with placeholder", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="float_field" placeholder="Placeholder"/></form>',
});
await contains(".o_field_widget[name='float_field'] input").clear();
expect(".o_field_widget[name='float_field'] input").toHaveAttribute(
"placeholder",
"Placeholder"
);
});
test("float field can be updated by another field/widget", async () => {
class MyWidget extends Component {
static template = xml`<button t-on-click="onClick">do it</button>`;
static props = ["*"];
onClick() {
const val = this.props.record.data.float_field;
this.props.record.update({ float_field: val + 1 });
}
}
const myWidget = {
component: MyWidget,
};
registry.category("view_widgets").add("wi", myWidget);
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field"/>
<field name="float_field"/>
<widget name="wi"/>
</form>`,
});
await contains(".o_field_widget[name=float_field] input").edit("40");
expect(".o_field_widget[name=float_field] input:eq(0)").toHaveValue("40.00");
expect(".o_field_widget[name=float_field] input:eq(1)").toHaveValue("40.00");
await contains(".o_widget button").click();
expect(".o_field_widget[name=float_field] input:eq(0)").toHaveValue("41.00");
expect(".o_field_widget[name=float_field] input:eq(1)").toHaveValue("41.00");
});

View file

@ -0,0 +1,137 @@
import { expect, test } from "@odoo/hoot";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
qux = fields.Float();
_records = [{ id: 5, qux: 9.1 }];
}
defineModels([Partner]);
test("FloatTimeField in form view", async () => {
expect.assertions(4);
onRpc("partner", "web_save", ({ args }) => {
// 48 / 60 = 0.8
expect(args[1].qux).toBe(-11.8, {
message: "the correct float value should be saved",
});
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<field name="qux" widget="float_time"/>
</sheet>
</form>`,
resId: 5,
});
// 9 + 0.1 * 60 = 9.06
expect(".o_field_float_time[name=qux] input").toHaveValue("09:06", {
message: "The value should be rendered correctly in the input.",
});
await contains(".o_field_float_time[name=qux] input").edit("-11:48");
expect(".o_field_float_time[name=qux] input").toHaveValue("-11:48", {
message: "The new value should be displayed properly in the input.",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("-11:48", {
message: "The new value should be saved and displayed properly.",
});
});
test("FloatTimeField value formatted on blur", async () => {
expect.assertions(4);
onRpc("partner", "web_save", ({ args }) => {
expect(args[1].qux).toBe(9.5, {
message: "the correct float value should be saved",
});
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time"/>
</form>`,
resId: 5,
});
expect(".o_field_widget input").toHaveValue("09:06", {
message: "The formatted time value should be displayed properly.",
});
await contains(".o_field_float_time[name=qux] input").edit("9.5");
expect(".o_field_float_time[name=qux] input").toHaveValue("09:30", {
message: "The new value should be displayed properly in the input.",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("09:30", {
message: "The new value should be saved and displayed properly.",
});
});
test("FloatTimeField with invalid value", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time"/>
</form>`,
});
await contains(".o_field_float_time[name=qux] input").edit("blabla");
await clickSave();
expect(".o_notification_title").toHaveText("Invalid fields:");
expect(".o_notification_content").toHaveInnerHTML("<ul><li>Qux</li></ul>");
expect(".o_notification_bar").toHaveClass("bg-danger");
expect(".o_field_float_time[name=qux]").toHaveClass("o_field_invalid");
await contains(".o_field_float_time[name=qux] input").edit("6.5");
expect(".o_field_float_time[name=qux] input").not.toHaveClass("o_field_invalid", {
message: "date field should not be displayed as invalid now",
});
});
test("float_time field with placeholder", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time" placeholder="Placeholder"/>
</form>`,
});
await contains(".o_field_widget[name='qux'] input").clear();
expect(".o_field_widget[name='qux'] input").toHaveAttribute("placeholder", "Placeholder");
});
test("float_time field does not have an inputmode attribute", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time" placeholder="Placeholder"/>
</form>`,
});
expect(".o_field_widget[name='qux'] input").not.toHaveAttribute("inputmode");
});

View file

@ -0,0 +1,90 @@
import { expect, test } from "@odoo/hoot";
import { queryText } from "@odoo/hoot-dom";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
float_field = fields.Float({ string: "Float field" });
_records = [{ id: 1, float_field: 0.44444 }];
}
defineModels([Partner]);
test("basic flow in form view", async () => {
onRpc("partner", "web_save", ({ args }) => {
// 1.000 / 0.125 = 8
expect.step(args[1].float_field.toString());
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="float_field" widget="float_toggle" options="{'factor': 0.125, 'range': [0, 1, 0.75, 0.5, 0.25]}" digits="[5,3]"/>
</form>`,
});
expect(".o_field_widget").toHaveText("0.056", {
message: "The formatted time value should be displayed properly.",
});
expect("button.o_field_float_toggle").toHaveText("0.056", {
message: "The value should be rendered correctly on the button.",
});
await contains("button.o_field_float_toggle").click();
expect("button.o_field_float_toggle").toHaveText("0.000", {
message: "The value should be rendered correctly on the button.",
});
// note, 0 will not be written, it's kept in the _changes of the datapoint.
// because save has not been clicked.
await contains("button.o_field_float_toggle").click();
expect("button.o_field_float_toggle").toHaveText("1.000", {
message: "The value should be rendered correctly on the button.",
});
await clickSave();
expect(".o_field_widget").toHaveText("1.000", {
message: "The new value should be saved and displayed properly.",
});
expect.verifySteps(["8"]);
});
test("kanban view (readonly) with option force_button", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="float_field" widget="float_toggle" options="{'force_button': true}"/>
</t>
</templates>
</kanban>`,
});
expect("button.o_field_float_toggle").toHaveCount(1, {
message: "should have rendered toggle button",
});
const value = queryText("button.o_field_float_toggle");
await contains("button.o_field_float_toggle").click();
expect("button.o_field_float_toggle").not.toHaveText(value, {
message: "float_field field value should be changed",
});
});

View file

@ -0,0 +1,189 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { patchTranslations, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { markup } from "@odoo/owl";
import { currencies } from "@web/core/currency";
import { localization } from "@web/core/l10n/localization";
import {
formatFloat,
formatFloatFactor,
formatFloatTime,
formatJson,
formatInteger,
formatMany2one,
formatMany2oneReference,
formatMonetary,
formatPercentage,
formatReference,
formatText,
formatX2many,
} from "@web/views/fields/formatters";
describe.current.tags("headless");
beforeEach(() => {
patchTranslations();
patchWithCleanup(localization, {
decimalPoint: ".",
thousandsSep: ",",
grouping: [3, 0],
});
});
test("formatFloat", () => {
expect(formatFloat(false)).toBe("");
});
test("formatFloatFactor", () => {
expect(formatFloatFactor(false)).toBe("");
expect(formatFloatFactor(6000)).toBe("6,000.00");
expect(formatFloatFactor(6000, { factor: 3 })).toBe("18,000.00");
expect(formatFloatFactor(6000, { factor: 0.5 })).toBe("3,000.00");
});
test("formatFloatTime", () => {
expect(formatFloatTime(2)).toBe("02:00");
expect(formatFloatTime(3.5)).toBe("03:30");
expect(formatFloatTime(0.25)).toBe("00:15");
expect(formatFloatTime(0.58)).toBe("00:35");
expect(formatFloatTime(2 / 60, { displaySeconds: true })).toBe("00:02:00");
expect(formatFloatTime(2 / 60 + 1 / 3600, { displaySeconds: true })).toBe("00:02:01");
expect(formatFloatTime(2 / 60 + 2 / 3600, { displaySeconds: true })).toBe("00:02:02");
expect(formatFloatTime(2 / 60 + 3 / 3600, { displaySeconds: true })).toBe("00:02:03");
expect(formatFloatTime(0.25, { displaySeconds: true })).toBe("00:15:00");
expect(formatFloatTime(0.25 + 15 / 3600, { displaySeconds: true })).toBe("00:15:15");
expect(formatFloatTime(0.25 + 45 / 3600, { displaySeconds: true })).toBe("00:15:45");
expect(formatFloatTime(56 / 3600, { displaySeconds: true })).toBe("00:00:56");
expect(formatFloatTime(-0.5)).toBe("-00:30");
const options = { noLeadingZeroHour: true };
expect(formatFloatTime(2, options)).toBe("2:00");
expect(formatFloatTime(3.5, options)).toBe("3:30");
expect(formatFloatTime(3.5, { ...options, displaySeconds: true })).toBe("3:30:00");
expect(formatFloatTime(3.5 + 15 / 3600, { ...options, displaySeconds: true })).toBe("3:30:15");
expect(formatFloatTime(3.5 + 45 / 3600, { ...options, displaySeconds: true })).toBe("3:30:45");
expect(formatFloatTime(56 / 3600, { ...options, displaySeconds: true })).toBe("0:00:56");
expect(formatFloatTime(-0.5, options)).toBe("-0:30");
});
test("formatJson", () => {
expect(formatJson(false)).toBe("");
expect(formatJson({})).toBe("{}");
expect(formatJson({ 1: 111 })).toBe('{"1":111}');
expect(formatJson({ 9: 11, 666: 42 })).toBe('{"9":11,"666":42}');
});
test("formatInteger", () => {
expect(formatInteger(false)).toBe("");
expect(formatInteger(0)).toBe("0");
patchWithCleanup(localization, { grouping: [3, 3, 3, 3] });
expect(formatInteger(1000000)).toBe("1,000,000");
patchWithCleanup(localization, { grouping: [3, 2, -1] });
expect(formatInteger(106500)).toBe("1,06,500");
patchWithCleanup(localization, { grouping: [1, 2, -1] });
expect(formatInteger(106500)).toBe("106,50,0");
const options = { grouping: [2, 0], thousandsSep: "€" };
expect(formatInteger(6000, options)).toBe("60€00");
});
test("formatMany2one", () => {
expect(formatMany2one(false)).toBe("");
expect(formatMany2one([false, "M2O value"])).toBe("M2O value");
expect(formatMany2one([1, false])).toBe("Unnamed");
expect(formatMany2one([1, "M2O value"])).toBe("M2O value");
expect(formatMany2one([1, "M2O value"], { escape: true })).toBe("M2O%20value");
});
test("formatText", () => {
expect(formatText(false)).toBe("");
expect(formatText("value")).toBe("value");
expect(formatText(1)).toBe("1");
expect(formatText(1.5)).toBe("1.5");
expect(formatText(markup("<p>This is a Test</p>"))).toBe("<p>This is a Test</p>");
expect(formatText([1, 2, 3, 4, 5])).toBe("1,2,3,4,5");
expect(formatText({ a: 1, b: 2 })).toBe("[object Object]");
});
test("formatX2many", () => {
// Results are cast as strings since they're lazy translated.
expect(String(formatX2many({ currentIds: [] }))).toBe("No records");
expect(String(formatX2many({ currentIds: [1] }))).toBe("1 record");
expect(String(formatX2many({ currentIds: [1, 3] }))).toBe("2 records");
});
test("formatMonetary", () => {
patchWithCleanup(currencies, {
10: {
digits: [69, 2],
position: "after",
symbol: "€",
},
11: {
digits: [69, 2],
position: "before",
symbol: "$",
},
12: {
digits: [69, 2],
position: "after",
symbol: "&",
},
});
expect(formatMonetary(false)).toBe("");
const field = {
type: "monetary",
currency_field: "c_x",
};
let data = {
c_x: [11],
c_y: 12,
};
expect(formatMonetary(200, { field, currencyId: 10, data })).toBe("200.00\u00a0€");
expect(formatMonetary(200, { field, data })).toBe("$\u00a0200.00");
expect(formatMonetary(200, { field, currencyField: "c_y", data })).toBe("200.00\u00a0&");
const floatField = { type: "float" };
data = {
currency_id: [11],
};
expect(formatMonetary(200, { field: floatField, data })).toBe("$\u00a0200.00");
});
test("formatPercentage", () => {
expect(formatPercentage(false)).toBe("0%");
expect(formatPercentage(0)).toBe("0%");
expect(formatPercentage(0.5)).toBe("50%");
expect(formatPercentage(1)).toBe("100%");
expect(formatPercentage(-0.2)).toBe("-20%");
expect(formatPercentage(2.5)).toBe("250%");
expect(formatPercentage(0.125)).toBe("12.5%");
expect(formatPercentage(0.666666)).toBe("66.67%");
expect(formatPercentage(125)).toBe("12500%");
expect(formatPercentage(50, { humanReadable: true })).toBe("5k%");
expect(formatPercentage(0.5, { noSymbol: true })).toBe("50");
patchWithCleanup(localization, { grouping: [3, 0], decimalPoint: ",", thousandsSep: "." });
expect(formatPercentage(0.125)).toBe("12,5%");
expect(formatPercentage(0.666666)).toBe("66,67%");
});
test("formatReference", () => {
expect(formatReference(false)).toBe("");
const value = { resModel: "product", resId: 2, displayName: "Chair" };
expect(formatReference(value)).toBe("Chair");
});
test("formatMany2oneReference", () => {
expect(formatMany2oneReference(false)).toBe("");
expect(formatMany2oneReference({ resId: 9, displayName: "Chair" })).toBe("Chair");
});

View file

@ -0,0 +1,80 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { onMounted } from "@odoo/owl";
import {
defineModels,
fields,
models,
mountView,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { GaugeField } from "@web/views/fields/gauge/gauge_field";
import { setupChartJsForTests } from "../graph/graph_test_helpers";
class Partner extends models.Model {
int_field = fields.Integer({ string: "int_field" });
another_int_field = fields.Integer({ string: "another_int_field" });
_records = [
{ id: 1, int_field: 10, another_int_field: 45 },
{ id: 2, int_field: 4, another_int_field: 10 },
];
}
defineModels([Partner]);
setupChartJsForTests();
test("GaugeField in kanban view", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<field name="another_int_field"/>
<templates>
<t t-name="card">
<field name="int_field" widget="gauge" options="{'max_field': 'another_int_field'}"/>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(2);
expect(".o_field_widget[name=int_field] .oe_gauge canvas").toHaveCount(2);
expect(queryAllTexts(".o_gauge_value")).toEqual(["10", "4"]);
});
test("GaugeValue supports max_value option", async () => {
patchWithCleanup(GaugeField.prototype, {
setup() {
super.setup();
onMounted(() => {
expect.step("gauge mounted");
expect(this.chart.config.options.plugins.tooltip.callbacks.label({})).toBe(
"Max: 120"
);
});
},
});
Partner._records = Partner._records.slice(0, 1);
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<div>
<field name="int_field" widget="gauge" options="{'max_value': 120}"/>
</div>
</t>
</templates>
</kanban>`,
});
expect.verifySteps(["gauge mounted"]);
expect(".o_field_widget[name=int_field] .oe_gauge canvas").toHaveCount(1);
expect(".o_gauge_value").toHaveText("10");
});

View file

@ -0,0 +1,116 @@
import { expect, test } from "@odoo/hoot";
import { click, queryFirst } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
defineModels,
fields,
MockServer,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
display_name = fields.Char({ string: "Displayed name", searchable: true });
p = fields.One2many({ string: "one2many field", relation: "partner", searchable: true });
sequence = fields.Integer({ string: "Sequence", searchable: true });
_records = [
{
id: 1,
display_name: "first record",
p: [],
},
{
id: 2,
display_name: "second record",
p: [],
sequence: 4,
},
{
id: 4,
display_name: "aaa",
sequence: 9,
},
];
}
defineModels([Partner]);
test("HandleField in x2m", async () => {
Partner._records[0].p = [2, 4];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="p">
<list editable="bottom">
<field name="sequence" widget="handle" />
<field name="display_name" />
</list>
</field>
</form>`,
});
expect("td span.o_row_handle").toHaveText("", {
message: "handle should not have any content",
});
expect(queryFirst("td span.o_row_handle")).toBeVisible({
message: "handle should be invisible",
});
expect("span.o_row_handle").toHaveCount(2, { message: "should have 2 handles" });
expect(queryFirst("td")).toHaveClass("o_handle_cell", {
message: "column widget should be displayed in css class",
});
await click("td:eq(1)");
await animationFrame();
expect("td:eq(0) span.o_row_handle").toHaveCount(1, {
message: "content of the cell should have been replaced",
});
});
test("HandleField with falsy values", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list>
<field name="sequence" widget="handle" />
<field name="display_name" />
</list>`,
});
expect(".o_row_handle:visible").toHaveCount(MockServer.env["partner"].length, {
message: "there should be a visible handle for each record",
});
});
test("HandleField in a readonly one2many", async () => {
Partner._records[0].p = [1, 2, 4];
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="p" readonly="1">
<list editable="top">
<field name="sequence" widget="handle" />
<field name="display_name" />
</list>
</field>
</form>`,
resId: 1,
});
expect(".o_row_handle").toHaveCount(3, {
message: "there should be 3 handles, one for each row",
});
expect(queryFirst("td span.o_row_handle")).not.toBeVisible({
message: "handle should be invisible",
});
});

View file

@ -0,0 +1,333 @@
import { expect, test } from "@odoo/hoot";
import { click, edit, pointerDown, queryAll, queryFirst } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
serverState,
} from "@web/../tests/web_test_helpers";
const RED_TEXT = /* html */ `<div class="kek" style="color:red">some text</div>`;
const GREEN_TEXT = /* html */ `<div class="kek" style="color:green">hello</div>`;
const BLUE_TEXT = /* html */ `<div class="kek" style="color:blue">hello world</div>`;
class Partner extends models.Model {
txt = fields.Html({ string: "txt", trim: true });
_records = [{ id: 1, txt: RED_TEXT }];
}
defineModels([Partner]);
test("html fields are correctly rendered in form view (readonly)", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="txt" readonly="1" /></form>`,
});
expect("div.kek").toHaveCount(1);
expect(".o_field_html .kek").toHaveStyle({ color: "rgb(255, 0, 0)" });
expect(".o_field_html").toHaveText("some text");
});
test("html field with required attribute", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="txt" required="1"/></form>`,
});
expect(".o_field_html textarea").toHaveCount(1, { message: "should have a text area" });
await click(".o_field_html textarea");
await edit("");
await animationFrame();
expect(".o_field_html textarea").toHaveValue("");
await clickSave();
expect(".o_notification_title").toHaveText("Invalid fields:");
expect(queryFirst(".o_notification_content")).toHaveInnerHTML("<ul><li>txt</li></ul>");
});
test("html fields are correctly rendered (edit)", async () => {
onRpc("has_group", () => true);
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="txt" /></form>`,
});
expect(".o_field_html textarea").toHaveCount(1, { message: "should have a text area" });
expect(".o_field_html textarea").toHaveValue(RED_TEXT);
await click(".o_field_html textarea");
await edit(GREEN_TEXT);
await animationFrame();
expect(".o_field_html textarea").toHaveValue(GREEN_TEXT);
expect(".o_field_html .kek").toHaveCount(0);
await edit(BLUE_TEXT);
await animationFrame();
expect(".o_field_html textarea").toHaveValue(BLUE_TEXT);
});
test("html fields are correctly rendered in list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list editable="top"><field name="txt"/></list>`,
});
expect(".o_data_row [name='txt']").toHaveText("some text");
expect(".o_data_row [name='txt'] .kek").toHaveStyle({ color: "rgb(255, 0, 0)" });
await click(".o_data_row [name='txt']");
await animationFrame();
expect(".o_data_row [name='txt'] textarea").toHaveValue(
'<div class="kek" style="color:red">some text</div>'
);
});
test("html field displays an empty string for the value false in list view", async () => {
Partner._records[0].txt = false;
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list editable="top"><field name="txt"/></list>`,
});
expect(".o_data_row [name='txt']").toHaveText("");
await click(".o_data_row [name='txt']");
await animationFrame();
expect(".o_data_row [name='txt'] textarea").toHaveValue("");
});
test("html fields are correctly rendered in kanban view", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban class="o_kanban_test">
<templates>
<t t-name="card">
<field name="txt"/>
</t>
</templates>
</kanban>`,
});
expect(".kek").toHaveText("some text");
expect(".kek").toHaveStyle({ color: "rgb(255, 0, 0)" });
});
test("field html translatable", async () => {
expect.assertions(10);
Partner._fields.txt = fields.Html({ string: "txt", trim: true, translate: true });
serverState.lang = "en_US";
serverState.multiLang = true;
onRpc("has_group", () => true);
onRpc("get_field_translations", function ({ args }) {
expect(args).toEqual([[1], "txt"], {
message: "should translate the txt field of the record",
});
return [
[
{ lang: "en_US", source: "first paragraph", value: "first paragraph" },
{
lang: "en_US",
source: "second paragraph",
value: "second paragraph",
},
{
lang: "fr_BE",
source: "first paragraph",
value: "",
},
{
lang: "fr_BE",
source: "second paragraph",
value: "deuxième paragraphe",
},
],
{ translation_type: "char", translation_show_source: true },
];
});
onRpc("get_installed", () => {
return [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
];
});
onRpc("update_field_translations", ({ args }) => {
expect(args).toEqual(
[
[1],
"txt",
{
en_US: { "first paragraph": "first paragraph modified" },
fr_BE: {
"first paragraph": "premier paragraphe modifié",
"second paragraph": "deuxième paragraphe modifié",
},
},
],
{ message: "the new translation value should be written" }
);
return [];
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form string="Partner">
<sheet>
<group>
<field name="txt" widget="html"/>
</group>
</sheet>
</form>`,
});
expect("[name=txt] textarea").toHaveClass("o_field_translate");
await contains("[name=txt] textarea").click();
expect(".o_field_html .btn.o_field_translate").toHaveCount(1, {
message: "should have a translate button",
});
expect(".o_field_html .btn.o_field_translate").toHaveText("EN", {
message: "the button should have as test the current language",
});
await click(".o_field_html .btn.o_field_translate");
await animationFrame();
expect(".modal").toHaveCount(1, { message: "a translate modal should be visible" });
expect(".translation").toHaveCount(4, { message: "four rows should be visible" });
const translations = queryAll(".modal .o_translation_dialog .translation input");
const enField1 = translations[0];
expect(enField1).toHaveValue("first paragraph", {
message: "first part of english translation should be filled",
});
await click(enField1);
await edit("first paragraph modified");
const frField1 = translations[2];
expect(frField1).toHaveValue("", {
message: "first part of french translation should not be filled",
});
await click(frField1);
await edit("premier paragraphe modifié");
const frField2 = translations[3];
expect(frField2).toHaveValue("deuxième paragraphe", {
message: "second part of french translation should be filled",
});
await click(frField2);
await edit("deuxième paragraphe modifié");
await click(".modal button.btn-primary"); // save
await animationFrame();
});
test("html fields: spellcheck is disabled on blur", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="txt" /></form>`,
});
const textarea = queryFirst(".o_field_html textarea");
expect(textarea).toHaveProperty("spellcheck", true, {
message: "by default, spellcheck is enabled",
});
await click(textarea);
await edit("nev walue");
await pointerDown(document.body);
await animationFrame();
expect(textarea).toHaveProperty("spellcheck", false, {
message: "spellcheck is disabled once the field has lost its focus",
});
await pointerDown(textarea);
expect(textarea).toHaveProperty("spellcheck", true, {
message: "spellcheck is re-enabled once the field is focused",
});
});
test("Setting an html field to empty string is saved as a false value", async () => {
expect.assertions(1);
onRpc("web_save", ({ args }) => {
expect(args[1].txt).toBe(false, { message: "the txt value should be false" });
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="txt" />
</group>
</sheet>
</form>`,
resId: 1,
});
await click(".o_field_widget[name=txt] textarea");
await edit("");
await clickSave();
});
test("html field: correct value is used to evaluate the modifiers", async () => {
Partner._fields.foo = fields.Char({
string: "foo",
onChange: (obj) => {
if (obj.foo === "a") {
obj.txt = false;
} else if (obj.foo === "b") {
obj.txt = "";
}
},
});
Partner._records[0].foo = false;
Partner._records[0].txt = false;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="foo" />
<field name="txt" invisible="'' == txt"/>
</form>`,
});
expect("[name='txt'] textarea").toHaveCount(1);
await click("[name='foo'] input");
await edit("a", { confirm: "enter" });
await animationFrame();
expect("[name='txt'] textarea").toHaveCount(1);
await edit("b", { confirm: "enter" });
await animationFrame();
expect("[name='txt'] textarea").toHaveCount(0);
});

View file

@ -0,0 +1,55 @@
import { expect, test } from "@odoo/hoot";
import { click, edit, queryFirst } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Report extends models.Model {
int_field = fields.Integer();
html_field = fields.Html();
_records = [
{
id: 1,
html_field: /* html */ `
<html>
<head>
<style>
body { color : rgb(255, 0, 0); }
</style>
</head>
<body>
<div class="nice_div"><p>Some content</p></div>
</body>
</html>
`,
},
];
}
defineModels([Report]);
test("IframeWrapperField in form view with onchange", async () => {
Report._onChanges.int_field = (record) => {
record.html_field = record.html_field.replace("Some content", "New content");
};
await mountView({
type: "form",
resModel: "report",
resId: 1,
arch: /* xml */ `
<form>
<field name="int_field"/>
<field name="html_field" widget="iframe_wrapper"/>
</form>
`,
});
expect("iframe:iframe .nice_div:first").toHaveInnerHTML("<p>Some content</p>");
expect("iframe:iframe .nice_div p:first").toHaveStyle({
color: "rgb(255, 0, 0)",
});
await click(".o_field_widget[name=int_field] input");
await edit(264, { confirm: "enter" });
await animationFrame();
expect(queryFirst("iframe:iframe .nice_div")).toHaveInnerHTML("<p>New content</p>");
});

View file

@ -0,0 +1,879 @@
import { expect, test } from "@odoo/hoot";
import {
click,
edit,
manuallyDispatchProgrammaticEvent,
queryAll,
queryFirst,
setInputFiles,
waitFor,
} from "@odoo/hoot-dom";
import { animationFrame, runAllTimers, mockDate } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
pagerNext,
} from "@web/../tests/web_test_helpers";
import { getOrigin } from "@web/core/utils/urls";
const { DateTime } = luxon;
const MY_IMAGE =
"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
const PRODUCT_IMAGE =
"R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7";
function getUnique(target) {
const src = target.dataset.src;
return new URL(src).searchParams.get("unique");
}
async function setFiles(files, name = "document") {
await click("input[type=file]", { visible: false });
await setInputFiles(files);
await waitFor(`div[name=${name}] img[data-src^="data:image/"]`, { timeout: 1000 });
}
class Partner extends models.Model {
name = fields.Char();
timmy = fields.Many2many({ relation: "partner.type" });
foo = fields.Char();
document = fields.Binary();
_records = [
{ id: 1, name: "first record", timmy: [], document: "coucou==" },
{ id: 2, name: "second record", timmy: [] },
{ id: 4, name: "aaa" },
];
}
class PartnerType extends models.Model {
_name = "partner.type";
name = fields.Char();
color = fields.Integer();
_records = [
{ id: 12, name: "gold", color: 2 },
{ id: 14, name: "silver", color: 5 },
];
}
defineModels([Partner, PartnerType]);
test("ImageField is correctly rendered", async () => {
expect.assertions(10);
Partner._records[0].write_date = "2017-02-08 10:00:00";
Partner._records[0].document = MY_IMAGE;
onRpc("web_read", ({ kwargs }) => {
expect(kwargs.specification).toEqual(
{
display_name: {},
document: {},
write_date: {},
},
{
message:
"The fields document, name and write_date should be present when reading an image",
}
);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>
`,
});
expect(".o_field_widget[name='document']").toHaveClass("o_field_image", {
message: "the widget should have the correct class",
});
expect(".o_field_widget[name='document'] img").toHaveCount(1, {
message: "the widget should contain an image",
});
expect('div[name="document"] img').toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`,
{ message: "the image should have the correct src" }
);
expect(".o_field_widget[name='document'] img").toHaveClass("img-fluid", {
message: "the image should have the correct class",
});
expect(".o_field_widget[name='document'] img").toHaveAttribute("width", "90", {
message: "the image should correctly set its attributes",
});
expect(".o_field_widget[name='document'] img").toHaveStyle(
{
maxWidth: "90px",
width: "90px",
height: "90px",
},
{
message: "the image should correctly set its attributes",
}
);
expect(".o_field_image .o_select_file_button").toHaveCount(1, {
message: "the image can be edited",
});
expect(".o_field_image .o_clear_file_button").toHaveCount(1, {
message: "the image can be deleted",
});
expect("input.o_input_file").toHaveAttribute("accept", "image/*", {
message:
'the default value for the attribute "accept" on the "image" widget must be "image/*"',
});
});
test("ImageField with img_class option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="document" widget="image" options="{'img_class': 'my_custom_class'}"/>
</form>`,
});
expect(".o_field_image img").toHaveClass("my_custom_class");
});
test("ImageField with alt attribute", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="document" widget="image" alt="something"/>
</form>`,
});
expect(".o_field_widget[name='document'] img").toHaveAttribute("alt", "something", {
message: "the image should correctly set its alt attribute",
});
});
test("ImageField on a many2one", async () => {
Partner._fields.parent_id = fields.Many2one({ relation: "partner" });
Partner._records[1].parent_id = 1;
mockDate("2017-02-06 10:00:00");
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: /* xml */ `
<form>
<field name="parent_id" widget="image" options="{'preview_image': 'document'}"/>
</form>`,
});
expect(".o_field_widget[name=parent_id] img").toHaveCount(1);
expect('div[name="parent_id"] img').toHaveAttribute(
"data-src",
`${getOrigin()}/web/image/partner/1/document?unique=1486375200000`
);
expect(".o_field_widget[name='parent_id'] img").toHaveAttribute("alt", "first record");
});
test("url should not use the record last updated date when the field is related", async () => {
Partner._fields.related = fields.Binary({ related: "parent_id.document" });
Partner._fields.parent_id = fields.Many2one({ relation: "partner" });
Partner._records[1].parent_id = 1;
Partner._records[0].write_date = "2017-02-04 10:00:00";
Partner._records[0].document = "3 kb";
mockDate("2017-02-06 10:00:00");
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: `
<form>
<field name="foo"/>
<field name="related" widget="image" readonly="0"/>
</form>`,
});
const initialUnique = Number(getUnique(queryFirst('div[name="related"] img')));
expect(DateTime.fromMillis(initialUnique).hasSame(DateTime.fromISO("2017-02-06"), "days")).toBe(
true
);
await click(".o_field_widget[name='foo'] input");
await edit("grrr");
await animationFrame();
expect(Number(getUnique(queryFirst('div[name="related"] img')))).toBe(initialUnique);
mockDate("2017-02-09 10:00:00");
await click("input[type=file]", { visible: false });
await setFiles(
new File(
[Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file.png",
{ type: "png" }
),
"related"
);
expect("div[name=related] img").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`
);
await clickSave();
const unique = Number(getUnique(queryFirst('div[name="related"] img')));
expect(DateTime.fromMillis(unique).hasSame(DateTime.fromISO("2017-02-09"), "days")).toBe(true);
});
test("url should use the record last updated date when the field is related on the same model", async () => {
Partner._fields.related = fields.Binary({ related: "document" });
Partner._records[0].write_date = "2017-02-04 10:00:00"; // 1486202400000
Partner._records[0].document = "3 kb";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="related" widget="image"/>
</form>`,
});
expect('div[name="related"] img').toHaveAttribute(
"data-src",
`${getOrigin()}/web/image/partner/1/related?unique=1486202400000`
);
});
test("ImageField is correctly replaced when given an incorrect value", async () => {
Partner._records[0].document = "incorrect_base64_value";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>
`,
});
expect(`div[name="document"] img`).toHaveAttribute(
"data-src",
"data:image/png;base64,incorrect_base64_value",
{
message: "the image has the invalid src by default",
}
);
// As GET requests can't occur in tests, we must generate an error
// on the img element to check whether the data-src is replaced with
// a placeholder, here knowing that the GET request would fail
manuallyDispatchProgrammaticEvent(queryFirst('div[name="document"] img'), "error");
await animationFrame();
expect('.o_field_widget[name="document"]').toHaveClass("o_field_image", {
message: "the widget should have the correct class",
});
expect(".o_field_widget[name='document'] img").toHaveCount(1, {
message: "the widget should contain an image",
});
expect('div[name="document"] img').toHaveAttribute(
"data-src",
"/web/static/img/placeholder.png",
{ message: "the image should have the correct src" }
);
expect(".o_field_widget[name='document'] img").toHaveClass("img-fluid", {
message: "the image should have the correct class",
});
expect(".o_field_widget[name='document'] img").toHaveAttribute("width", "90", {
message: "the image should correctly set its attributes",
});
expect(".o_field_widget[name='document'] img").toHaveStyle("maxWidth: 90px", {
message: "the image should correctly set its attributes",
});
expect(".o_field_image .o_select_file_button").toHaveCount(1, {
message: "the image can be edited",
});
expect(".o_field_image .o_clear_file_button").toHaveCount(0, {
message: "the image cannot be deleted as it has not been uploaded",
});
});
test("ImageField preview is updated when an image is uploaded", async () => {
const imageFile = new File(
[Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file.png",
{ type: "png" }
);
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>
`,
});
expect('div[name="document"] img').toHaveAttribute(
"data-src",
"data:image/png;base64,coucou==",
{ message: "the image should have the initial src" }
);
// Whitebox: replace the event target before the event is handled by the field so that we can modify
// the files that it will take into account. This relies on the fact that it reads the files from
// event.target and not from a direct reference to the input element.
await click(".o_select_file_button");
await setInputFiles(imageFile);
// It can take some time to encode the data as a base64 url
await runAllTimers();
// Wait for a render
await animationFrame();
expect("div[name=document] img").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`,
{ message: "the image should have the new src" }
);
});
test("clicking save manually after uploading new image should change the unique of the image src", async () => {
Partner._onChanges.foo = () => {};
const rec = Partner._records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.write_date = "2022-08-05 08:37:00"; // 1659688620000
// 1659692220000, 1659695820000
const lastUpdates = ["2022-08-05 09:37:00", "2022-08-05 10:37:00"];
let index = 0;
onRpc("web_save", ({ args }) => {
args[1].write_date = lastUpdates[index];
args[1].document = "4 kb";
index++;
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="foo"/>
<field name="document" widget="image" />
</form>
`,
});
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
await click("input[type=file]", { visible: false });
await setFiles(
new File(
[Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file.png",
{ type: "png" }
)
);
expect("div[name=document] img").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`
);
await click(".o_field_widget[name='foo'] input");
await edit("grrr");
await animationFrame();
expect("div[name=document] img").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`
);
await clickSave();
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659692220000");
// Change the image again. After clicking save, it should have the correct new url.
await click("input[type=file]", { visible: false });
await setFiles(
new File(
[Uint8Array.from([...atob(PRODUCT_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file2.gif",
{ type: "gif" }
)
);
expect("div[name=document] img").toHaveAttribute(
"data-src",
`data:image/gif;base64,${PRODUCT_IMAGE}`
);
await clickSave();
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659695820000");
});
test("save record with image field modified by onchange", async () => {
Partner._onChanges.foo = (data) => {
data.document = MY_IMAGE;
};
const rec = Partner._records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.write_date = "2022-08-05 08:37:00"; // 1659688620000
// 1659692220000
const lastUpdates = ["2022-08-05 09:37:00"];
let index = 0;
onRpc("web_save", ({ args }) => {
args[1].write_date = lastUpdates[index];
args[1].document = "3 kb";
index++;
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="foo"/>
<field name="document" widget="image" />
</form>
`,
});
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
await click("[name='foo'] input");
await edit("grrr", { confirm: "enter" });
await animationFrame();
expect("div[name=document] img").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`
);
await clickSave();
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659692220000");
});
test("ImageField: option accepted_file_extensions", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'accepted_file_extensions': '.png,.jpeg'}" />
</form>
`,
});
// The view must be in edit mode
expect("input.o_input_file").toHaveAttribute("accept", ".png,.jpeg", {
message: "the input should have the correct ``accept`` attribute",
});
});
test("ImageField: set 0 width/height in the size option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'size': [0, 0]}" />
<field name="document" widget="image" options="{'size': [0, 50]}" />
<field name="document" widget="image" options="{'size': [50, 0]}" />
</form>
`,
});
const imgs = queryAll(".o_field_widget img");
expect([imgs[0].attributes.width, imgs[0].attributes.height]).toEqual([undefined, undefined], {
message: "if both size are set to 0, both attributes are undefined",
});
expect([imgs[1].attributes.width, imgs[1].attributes.height.value]).toEqual([undefined, "50"], {
message: "if only the width is set to 0, the width attribute is not set on the img",
});
expect([
imgs[1].style.width,
imgs[1].style.maxWidth,
imgs[1].style.height,
imgs[1].style.maxHeight,
]).toEqual(["auto", "100%", "", "50px"], {
message: "the image should correctly set its attributes",
});
expect([imgs[2].attributes.width.value, imgs[2].attributes.height]).toEqual(["50", undefined], {
message: "if only the height is set to 0, the height attribute is not set on the img",
});
expect([
imgs[2].style.width,
imgs[2].style.maxWidth,
imgs[2].style.height,
imgs[2].style.maxHeight,
]).toEqual(["", "50px", "auto", "100%"], {
message: "the image should correctly set its attributes",
});
});
test("ImageField: zoom and zoom_delay options (readonly)", async () => {
Partner._records[0].document = MY_IMAGE;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'zoom': true, 'zoom_delay': 600}" readonly="1" />
</form>
`,
});
// data-tooltip attribute is used by the tooltip service
expect(".o_field_image img").toHaveAttribute(
"data-tooltip-info",
`{"url":"data:image/png;base64,${MY_IMAGE}"}`,
{ message: "shows a tooltip on hover" }
);
expect(".o_field_image img").toHaveAttribute("data-tooltip-delay", "600", {
message: "tooltip has the right delay",
});
});
test("ImageField: zoom and zoom_delay options (edit)", async () => {
Partner._records[0].document = "3 kb";
Partner._records[0].write_date = "2022-08-05 08:37:00";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'zoom': true, 'zoom_delay': 600}" />
</form>
`,
});
expect(".o_field_image img").toHaveAttribute(
"data-tooltip-info",
`{"url":"${getOrigin()}/web/image/partner/1/document?unique=1659688620000"}`,
{ message: "tooltip show the full image from the field value" }
);
expect(".o_field_image img").toHaveAttribute("data-tooltip-delay", "600", {
message: "tooltip has the right delay",
});
});
test("ImageField displays the right images with zoom and preview_image options (readonly)", async () => {
Partner._records[0].document = "3 kb";
Partner._records[0].write_date = "2022-08-05 08:37:00";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'zoom': true, 'preview_image': 'document_preview', 'zoom_delay': 600}" readonly="1" />
</form>
`,
});
expect(".o_field_image img").toHaveAttribute(
"data-tooltip-info",
`{"url":"${getOrigin()}/web/image/partner/1/document?unique=1659688620000"}`,
{ message: "tooltip show the full image from the field value" }
);
expect(".o_field_image img").toHaveAttribute("data-tooltip-delay", "600", {
message: "tooltip has the right delay",
});
});
test("ImageField in subviews is loaded correctly", async () => {
Partner._records[0].write_date = "2017-02-08 10:00:00";
Partner._records[0].document = MY_IMAGE;
PartnerType._fields.image = fields.Binary({});
PartnerType._records[0].image = PRODUCT_IMAGE;
Partner._records[0].timmy = [12];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}" />
<field name="timmy" widget="many2many" mode="kanban">
<kanban>
<templates>
<t t-name="card">
<field name="name" />
</t>
</templates>
</kanban>
<form>
<field name="image" widget="image" />
</form>
</field>
</form>
`,
});
expect(`img[data-src="data:image/png;base64,${MY_IMAGE}"]`).toHaveCount(1);
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
// Actual flow: click on an element of the m2m to get its form view
await click(".o_kanban_record:not(.o_kanban_ghost)");
await animationFrame();
expect(".modal").toHaveCount(1, { message: "The modal should have opened" });
expect(`img[data-src="data:image/gif;base64,${PRODUCT_IMAGE}"]`).toHaveCount(1);
});
test("ImageField in x2many list is loaded correctly", async () => {
PartnerType._fields.image = fields.Binary({});
PartnerType._records[0].image = PRODUCT_IMAGE;
Partner._records[0].timmy = [12];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="timmy" widget="many2many">
<list>
<field name="image" widget="image" />
</list>
</field>
</form>
`,
});
expect("tr.o_data_row").toHaveCount(1, {
message: "There should be one record in the many2many",
});
expect(`img[data-src="data:image/gif;base64,${PRODUCT_IMAGE}"]`).toHaveCount(1, {
message: "The list's image is in the DOM",
});
});
test("ImageField with required attribute", async () => {
onRpc("create", () => {
throw new Error("Should not do a create RPC with unset required image field");
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="document" widget="image" required="1" />
</form>
`,
});
await clickSave();
expect(".o_form_view .o_form_editable").toHaveCount(1, {
message: "form view should still be editable",
});
expect(".o_field_widget").toHaveClass("o_field_invalid", {
message: "image field should be displayed as invalid",
});
});
test("ImageField is reset when changing record", async () => {
const imageData = Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)));
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>
`,
});
const imageFile = new File([imageData], "fake_file.png", { type: "png" });
expect("img[alt='Binary file']").toHaveAttribute(
"data-src",
"/web/static/img/placeholder.png",
{ message: "image field should not be set" }
);
await setFiles(imageFile);
expect("img[alt='Binary file']").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`,
{
message: "image field should be set",
}
);
await clickSave();
await click(".o_control_panel_main_buttons .o_form_button_create");
await runAllTimers();
await animationFrame();
expect("img[alt='Binary file']").toHaveAttribute(
"data-src",
"/web/static/img/placeholder.png",
{ message: "image field should be reset" }
);
await setFiles(imageFile);
expect("img[alt='Binary file']").toHaveAttribute(
"data-src",
`data:image/png;base64,${MY_IMAGE}`,
{
message: "image field should be set",
}
);
});
test("unique in url doesn't change on onchange", async () => {
Partner._onChanges.foo = () => {};
const rec = Partner._records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.write_date = "2022-08-05 08:37:00";
onRpc(({ method, args }) => {
expect.step(method);
if (method === "web_save") {
args[1].write_date = "2022-08-05 09:37:00"; // 1659692220000
}
});
await mountView({
resId: 1,
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="foo" />
<field name="document" widget="image" required="1" />
</form>
`,
});
expect.verifySteps(["get_views", "web_read"]);
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
expect.verifySteps([]);
// same unique as before
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
await click(".o_field_widget[name='foo'] input");
await edit("grrr", { confirm: "enter" });
await animationFrame();
expect.verifySteps(["onchange"]);
// also same unique
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
await clickSave();
expect.verifySteps(["web_save"]);
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659692220000");
});
test("unique in url change on record change", async () => {
const rec = Partner._records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.write_date = "2022-08-05 08:37:00";
const rec2 = Partner._records.find((rec) => rec.id === 2);
rec2.document = "3 kb";
rec2.write_date = "2022-08-05 09:37:00";
await mountView({
resIds: [1, 2],
resId: 1,
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="document" widget="image" required="1" />
</form>
`,
});
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
await pagerNext();
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659692220000");
});
test("unique in url does not change on record change if reload option is set to false", async () => {
const rec = Partner._records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.write_date = "2022-08-05 08:37:00";
await mountView({
resIds: [1, 2],
resId: 1,
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="document" widget="image" required="1" options="{'reload': false}" />
<field name="write_date" readonly="0"/>
</form>
`,
});
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
await click("div[name='write_date'] > div > input");
await edit("2022-08-05 08:39:00", { confirm: "enter" });
await animationFrame();
await clickSave();
expect(getUnique(queryFirst(".o_field_image img"))).toBe("1659688620000");
});
test("convert image to webp", async () => {
onRpc("ir.attachment", "create_unique", ({ args }) => {
// This RPC call is done two times - once for storing webp and once for storing jpeg
// This handles first RPC call to store webp
if (!args[0][0].res_id) {
// Here we check the image data we pass and generated data.
// Also we check the file type
expect(args[0][0].datas).not.toBe(imageData);
expect(args[0][0].mimetype).toBe("image/webp");
return [1];
}
// This handles second RPC call to store jpeg
expect(args[0][0].datas).not.toBe(imageData);
expect(args[0][0].mimetype).toBe("image/jpeg");
return true;
});
const imageData = Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)));
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="document" widget="image" required="1" options="{'convert_to_webp': True}" />
</form>
`,
});
const imageFile = new File([imageData], "fake_file.jpeg", { type: "jpeg" });
expect("img[alt='Binary file']").toHaveAttribute(
"data-src",
"/web/static/img/placeholder.png",
{ message: "image field should not be set" }
);
await setFiles(imageFile);
});

View file

@ -0,0 +1,219 @@
import { expect, test } from "@odoo/hoot";
import { click, edit } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
defineModels,
fields,
models,
mountView,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { KanbanController } from "@web/views/kanban/kanban_controller";
const FR_FLAG_URL = "/base/static/img/country_flags/fr.png";
const EN_FLAG_URL = "/base/static/img/country_flags/gb.png";
class Partner extends models.Model {
name = fields.Char();
foo = fields.Char();
p = fields.One2many({ relation: "partner" });
timmy = fields.Many2many({ relation: "partner.type" });
_records = [{ id: 1, foo: FR_FLAG_URL, timmy: [] }];
}
class PartnerType extends models.Model {
name = fields.Char();
color = fields.Integer();
_records = [
{ id: 12, display_name: "gold", color: 2 },
{ id: 14, display_name: "silver", color: 5 },
];
}
defineModels([Partner, PartnerType]);
test("image fields are correctly rendered", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
</form>
`,
resId: 1,
});
expect(`div[name="foo"]`).toHaveClass("o_field_image_url", {
message: "the widget should have the correct class",
});
expect(`div[name="foo"] > img`).toHaveCount(1, {
message: "the widget should contain an image",
});
expect(`div[name="foo"] > img`).toHaveAttribute("data-src", FR_FLAG_URL, {
message: "the image should have the correct src",
});
expect(`div[name="foo"] > img`).toHaveClass("img-fluid", {
message: "the image should have the correct class",
});
expect(`div[name="foo"] > img`).toHaveAttribute("width", "90", {
message: "the image should correctly set its attributes",
});
expect(`div[name="foo"] > img`).toHaveStyle("maxWidth: 90px", {
message: "the image should correctly set its attributes",
});
});
test("ImageUrlField in subviews are loaded correctly", async () => {
PartnerType._fields.image = fields.Char();
PartnerType._records[0].image = EN_FLAG_URL;
Partner._records[0].timmy = [12];
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
<field name="timmy" widget="many2many" mode="kanban">
<kanban>
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>
<form>
<field name="image" widget="image_url"/>
</form>
</field>
</form>
`,
resId: 1,
});
expect(`img[data-src="${FR_FLAG_URL}"]`).toHaveCount(1, {
message: "The view's image is in the DOM",
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1, {
message: "There should be one record in the many2many",
});
// Actual flow: click on an element of the m2m to get its form view
await click(".o_kanban_record:not(.o_kanban_ghost)");
await animationFrame();
expect(".modal").toHaveCount(1, { message: "The modal should have opened" });
expect(`img[data-src="${EN_FLAG_URL}"]`).toHaveCount(1, {
message: "The dialog's image is in the DOM",
});
});
test("image fields in x2many list are loaded correctly", async () => {
PartnerType._fields.image = fields.Char();
PartnerType._records[0].image = EN_FLAG_URL;
Partner._records[0].timmy = [12];
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="timmy" widget="many2many">
<list>
<field name="image" widget="image_url"/>
</list>
</field>
</form>
`,
resId: 1,
});
expect("tr.o_data_row").toHaveCount(1, {
message: "There should be one record in the many2many",
});
expect(`img[data-src="${EN_FLAG_URL}"]`).toHaveCount(1, {
message: "The list's image is in the DOM",
});
});
test("image url fields in kanban don't stop opening record", async () => {
patchWithCleanup(KanbanController.prototype, {
openRecord() {
expect.step("open record");
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="foo" widget="image_url"/>
</t>
</templates>
</kanban>
`,
});
await click(".o_kanban_record");
await animationFrame();
expect.verifySteps(["open record"]);
});
test("image fields with empty value", async () => {
Partner._records[0].foo = false;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
</form>
`,
resId: 1,
});
expect(`div[name="foo"]`).toHaveClass("o_field_image_url", {
message: "the widget should have the correct class",
});
expect(`div[name="foo"] > img`).toHaveCount(0, {
message: "the widget should not contain an image",
});
});
test("onchange update image fields", async () => {
const srcTest = "/my/test/src";
Partner._onChanges.name = (record) => {
record.foo = srcTest;
};
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="name"/>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
</form>
`,
resId: 1,
});
expect(`div[name="foo"] > img`).toHaveAttribute("data-src", FR_FLAG_URL, {
message: "the image should have the correct src",
});
await click(`[name="name"] input`);
await edit("test", { confirm: "enter" });
await runAllTimers();
await animationFrame();
expect(`div[name="foo"] > img`).toHaveAttribute("data-src", srcTest, {
message: "the image should have the onchange src",
});
});

View file

@ -0,0 +1,294 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import {
clickSave,
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Product extends models.Model {
price = fields.Integer();
}
defineModels([Product]);
test("human readable format 1", async () => {
Product._records = [{ id: 1, price: 3.756754e6 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'human_readable': 'true'}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("4M", {
message: "The value should be rendered in human readable format (k, M, G, T)",
});
});
test("human readable format 2", async () => {
Product._records = [{ id: 1, price: 2.034e3 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'human_readable': 'true', 'decimals': 1}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("2.0k", {
message: "The value should be rendered in human readable format (k, M, G, T)",
});
});
test("human readable format 3", async () => {
Product._records = [{ id: 1, price: 6.67543577586e12 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'human_readable': 'true', 'decimals': 4}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("6.6754T", {
message: "The value should be rendered in human readable format (k, M, G, T)",
});
});
test("still human readable when readonly", async () => {
Product._records = [{ id: 1, price: 6.67543577586e12 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" readonly="true" options="{'human_readable': 'true', 'decimals': 4}"/></form>`,
});
expect(".o_field_widget span").toHaveText("6.6754T");
});
test("should be 0 when unset", async () => {
Product._records = [{ id: 1 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").not.toHaveClass("o_field_empty");
expect(".o_field_widget input").toHaveValue("0");
});
test("basic form view flow", async () => {
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").toHaveValue("10");
await fieldInput("price").edit("30");
expect(".o_field_widget input").toHaveValue("30");
await clickSave();
expect(".o_field_widget input").toHaveValue("30");
});
test("no need to focus out of the input to save the record after correcting an invalid input", async () => {
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").toHaveValue("10");
await fieldInput("price").edit("a");
expect(".o_field_widget input").toHaveValue("a");
expect(".o_form_status_indicator span i.fa-warning").toHaveCount(1);
expect(".o_form_button_save[disabled]").toHaveCount(1);
await fieldInput("price").edit("1");
expect(".o_field_widget input").toHaveValue("1");
expect(".o_form_status_indicator span i.fa-warning").toHaveCount(0);
expect(".o_form_button_save[disabled]").toHaveCount(0);
await clickSave(); // makes sure there is an enabled save button
});
test("rounded when using formula in form view", async () => {
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
await fieldInput("price").edit("=100/3");
expect(".o_field_widget input").toHaveValue("33");
});
test("with input type 'number' option", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'type': 'number'}"/></form>`,
});
expect(".o_field_widget input").toHaveAttribute("type", "number");
await fieldInput("price").edit("1234567890");
expect(".o_field_widget input").toHaveValue(1234567890, {
message: "Integer value must be not formatted if input type is number",
});
});
test("with 'step' option", async () => {
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'type': 'number', 'step': 3}"/></form>`,
});
expect(".o_field_widget input").toHaveAttribute("step", "3");
});
test("without input type option", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").toHaveAttribute("type", "text");
await fieldInput("price").edit("1234567890");
expect(".o_field_widget input").toHaveValue("1,234,567,890");
});
test("is formatted by default", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
Product._records = [{ id: 1, price: 8069 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'enable_formatting': 'false'}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("8,069");
});
test("basic flow in editable list view", async () => {
Product._records = [{ id: 1 }, { id: 2, price: 10 }];
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "product",
arch: '<list editable="bottom"><field name="price"/></list>',
});
const zeroValues = queryAllTexts("td").filter((text) => text === "0");
expect(zeroValues).toHaveLength(1, {
message: "Unset integer values should not be rendered as zeros",
});
await contains("td.o_data_cell").click();
expect('.o_field_widget[name="price"] input').toHaveCount(1);
await contains('.o_field_widget[name="price"] input').edit("-28");
expect("td.o_data_cell:first").toHaveText("-28");
expect('.o_field_widget[name="price"] input').toHaveValue("10");
await contains(getFixture()).click();
expect(queryAllTexts("td.o_data_cell")).toEqual(["-28", "10"]);
});
test("with placeholder", async () => {
await mountView({
type: "form",
resModel: "product",
arch: `<form><field name="price" placeholder="Placeholder"/></form>`,
});
expect(".o_field_widget input").toHaveAttribute("placeholder", "Placeholder");
});
test("with enable_formatting option as false", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
Product._records = [{ id: 1, price: 8069 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'enable_formatting': false}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("8069");
await fieldInput("price").edit("1234567890");
expect(".o_field_widget input").toHaveValue("1234567890");
});
test("value is formatted on Enter", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
await mountView({
type: "form",
resModel: "product",
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").toHaveValue("0");
await fieldInput("price").edit("1000", { confirm: "Enter" });
expect(".o_field_widget input").toHaveValue("1,000");
});
test("value is formatted on Enter (even if same value)", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
Product._records = [{ id: 1, price: 8069 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").toHaveValue("8,069");
await fieldInput("price").edit("8069", { confirm: "Enter" });
expect(".o_field_widget input").toHaveValue("8,069");
});
test("value is formatted on click out (even if same value)", async () => {
// `localization > grouping` required for this test is [3, 0], which is the default in mock server
Product._records = [{ id: 1, price: 8069 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="price"/></form>',
});
expect(".o_field_widget input").toHaveValue("8,069");
await fieldInput("price").edit("8069", { confirm: false });
expect(".o_field_widget input").toHaveValue("8069");
await contains(".o_control_panel").click();
expect(".o_field_widget input").toHaveValue("8,069");
});
test("Value should not be a boolean when enable_formatting is false", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "product",
arch: `
<list editable="bottom">
<field name="id" options="{'enable_formatting': false}"/>
<field name="price"/>
</list>
`,
});
await contains(`.o_list_button_add`).click();
expect(".o_selected_row .o_field_integer").toHaveText("");
});

View file

@ -0,0 +1,147 @@
import { expect, test } from "@odoo/hoot";
import { click, press } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
defineModels,
fields,
findComponent,
models,
mountView,
} from "@web/../tests/web_test_helpers";
import { KanbanController } from "@web/views/kanban/kanban_controller";
const graph_values = [
{ value: 300, label: "5-11 Dec" },
{ value: 500, label: "12-18 Dec" },
{ value: 100, label: "19-25 Dec" },
];
class Partner extends models.Model {
int_field = fields.Integer({
string: "int_field",
sortable: true,
searchable: true,
});
selection = fields.Selection({
string: "Selection",
searchable: true,
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
});
graph_data = fields.Text({ string: "Graph Data" });
graph_type = fields.Selection({
string: "Graph Type",
selection: [
["line", "Line"],
["bar", "Bar"],
],
});
_records = [
{
id: 1,
int_field: 10,
selection: "blocked",
graph_type: "bar",
graph_data: JSON.stringify([
{
color: "blue",
title: "Partner 1",
values: graph_values,
key: "A key",
area: true,
},
]),
},
{
id: 2,
display_name: "second record",
int_field: 0,
selection: "normal",
graph_type: "line",
graph_data: JSON.stringify([
{
color: "red",
title: "Partner 0",
values: graph_values,
key: "A key",
area: true,
},
]),
},
];
}
defineModels([Partner]);
// Kanban
// WOWL remove this helper and user the control panel instead
const reload = async (kanban, params = {}) => {
kanban.env.searchModel.reload(params);
kanban.env.searchModel.search();
await animationFrame();
};
test.tags("desktop");
test("JournalDashboardGraphField is rendered correctly", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<field name="graph_type"/>
<templates>
<t t-name="card">
<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>
</t>
</templates>
</kanban>`,
domain: [["id", "in", [1, 2]]],
});
expect(".o_dashboard_graph canvas").toHaveCount(2, {
message: "there should be two graphs rendered",
});
expect(".o_kanban_record:nth-child(1) .o_graph_barchart").toHaveCount(1, {
message: "graph of first record should be a barchart",
});
expect(".o_kanban_record:nth-child(2) .o_graph_linechart").toHaveCount(1, {
message: "graph of second record should be a linechart",
});
// reload kanban
await click("input.o_searchview_input");
await press("Enter");
await animationFrame();
expect(".o_dashboard_graph canvas").toHaveCount(2, {
message: "there should be two graphs rendered",
});
});
test("rendering of a JournalDashboardGraphField in an updated grouped kanban view", async () => {
const view = await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<field name="graph_type"/>
<templates>
<t t-name="card">
<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>
</t>
</templates>
</kanban>`,
domain: [["id", "in", [1, 2]]],
});
const kanban = findComponent(view, (component) => component instanceof KanbanController);
expect(".o_dashboard_graph canvas").toHaveCount(2, {
message: "there should be two graph rendered",
});
await reload(kanban, { groupBy: ["selection"], domain: [["int_field", "=", 10]] });
expect(".o_dashboard_graph canvas").toHaveCount(1, {
message: "there should be one graph rendered",
});
});

View file

@ -0,0 +1,148 @@
import { expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
foo = fields.Char();
selection = fields.Selection({
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
});
_records = [
{
foo: "yop",
selection: "blocked",
},
{
foo: "blip",
selection: "normal",
},
{
foo: "abc",
selection: "done",
},
];
}
defineModels([Partner]);
test("LabelSelectionField in form view", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="label_selection"
options="{'classes': {'normal': 'secondary', 'blocked': 'warning','done': 'success'}}"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_widget .badge.text-bg-warning").toHaveCount(1, {
message: "should have a warning status label since selection is the second, blocked state",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveCount(0, {
message: "should not have a default status since selection is the second, blocked state",
});
expect(".o_field_widget .badge.text-bg-success").toHaveCount(0, {
message: "should not have a success status since selection is the second, blocked state",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveText("Blocked", {
message: "the label should say 'Blocked' since this is the label value for that state",
});
});
test("LabelSelectionField in editable list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list editable="bottom">
<field name="foo"/>
<field name="selection" widget="label_selection"
options="{'classes': {'normal': 'secondary', 'blocked': 'warning','done': 'success'}}"/>
</list>`,
});
expect(".o_field_widget .badge:not(:empty)").toHaveCount(3, {
message: "should have three visible status labels",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveCount(1, {
message: "should have one warning status label",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveText("Blocked", {
message: "the warning label should read 'Blocked'",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveCount(1, {
message: "should have one default status label",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveText("Normal", {
message: "the default label should read 'Normal'",
});
expect(".o_field_widget .badge.text-bg-success").toHaveCount(1, {
message: "should have one success status label",
});
expect(".o_field_widget .badge.text-bg-success").toHaveText("Done", {
message: "the success label should read 'Done'",
});
// switch to edit mode and check the result
await click("tbody td:not(.o_list_record_selector)");
await animationFrame();
expect(".o_field_widget .badge:not(:empty)").toHaveCount(3, {
message: "should have three visible status labels",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveCount(1, {
message: "should have one warning status label",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveText("Blocked", {
message: "the warning label should read 'Blocked'",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveCount(1, {
message: "should have one default status label",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveText("Normal", {
message: "the default label should read 'Normal'",
});
expect(".o_field_widget .badge.text-bg-success").toHaveCount(1, {
message: "should have one success status label",
});
expect(".o_field_widget .badge.text-bg-success").toHaveText("Done", {
message: "the success label should read 'Done'",
});
// save and check the result
await click(".o_control_panel_main_buttons .o_list_button_save");
await animationFrame();
expect(".o_field_widget .badge:not(:empty)").toHaveCount(3, {
message: "should have three visible status labels",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveCount(1, {
message: "should have one warning status label",
});
expect(".o_field_widget .badge.text-bg-warning").toHaveText("Blocked", {
message: "the warning label should read 'Blocked'",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveCount(1, {
message: "should have one default status label",
});
expect(".o_field_widget .badge.text-bg-secondary").toHaveText("Normal", {
message: "the default label should read 'Normal'",
});
expect(".o_field_widget .badge.text-bg-success").toHaveCount(1, {
message: "should have one success status label",
});
expect(".o_field_widget .badge.text-bg-success").toHaveText("Done", {
message: "the success label should read 'Done'",
});
});

View file

@ -0,0 +1,194 @@
import { expect, test } from "@odoo/hoot";
import { setInputFiles } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
MockServer,
mockService,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Turtle extends models.Model {
picture_ids = fields.Many2many({
string: "Pictures",
relation: "ir.attachment",
});
_records = [{ id: 1, picture_ids: [17] }];
}
class IrAttachment extends models.Model {
_name = "ir.attachment";
name = fields.Char();
mimetype = fields.Char();
_records = [{ id: 17, name: "Marley&Me.jpg", mimetype: "jpg" }];
}
defineModels([Turtle, IrAttachment]);
test("widget many2many_binary", async () => {
expect.assertions(17);
mockService("http", () => ({
post(route, { ufile }) {
expect(route).toBe("/web/binary/upload_attachment");
expect(ufile[0].name).toBe("fake_file.tiff", {
message: "file is correctly uploaded to the server",
});
const ids = MockServer.env["ir.attachment"].create(
ufile.map(({ name }) => ({ name, mimetype: "text/plain" }))
);
return JSON.stringify(MockServer.env["ir.attachment"].read(ids));
},
}));
IrAttachment._views.list = '<list string="Pictures"><field name="name"/></list>';
onRpc((args) => {
if (args.method !== "get_views") {
expect.step(args.route);
}
if (args.method === "web_read" && args.model === "turtle") {
expect(args.kwargs.specification).toEqual({
display_name: {},
picture_ids: {
fields: {
mimetype: {},
name: {},
},
},
});
}
if (args.method === "web_save" && args.model === "turtle") {
expect(args.kwargs.specification).toEqual({
display_name: {},
picture_ids: {
fields: {
mimetype: {},
name: {},
},
},
});
}
if (args.method === "web_read" && args.model === "ir.attachment") {
expect(args.kwargs.specification).toEqual({
mimetype: {},
name: {},
});
}
});
await mountView({
type: "form",
resModel: "turtle",
arch: `
<form>
<group>
<field name="picture_ids" widget="many2many_binary" options="{'accepted_file_extensions': 'image/*'}"/>
</group>
</form>`,
resId: 1,
});
expect("div.o_field_widget .oe_fileupload").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attachments").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attachment .o_attachment_delete").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attach").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attach").toHaveText("Pictures");
expect("input.o_input_file").toHaveAttribute("accept", "image/*");
expect.verifySteps(["/web/dataset/call_kw/turtle/web_read"]);
// Set and trigger the change of a file for the input
const file = new File(["fake_file"], "fake_file.tiff", { type: "text/plain" });
await contains(".o_file_input_trigger").click();
await setInputFiles([file]);
await animationFrame();
expect(".o_attachment:nth-child(2) .caption a:eq(0)").toHaveText("fake_file.tiff", {
message: 'value of attachment should be "fake_file.tiff"',
});
expect(".o_attachment:nth-child(2) .caption.small a").toHaveText("TIFF", {
message: "file extension should be correct",
});
expect(".o_attachment:nth-child(2) .o_image.o_hover").toHaveAttribute(
"data-mimetype",
"text/plain",
{ message: "preview displays the right mimetype" }
);
// delete the attachment
await contains("div.o_field_widget .oe_fileupload .o_attachment .o_attachment_delete").click();
await clickSave();
expect("div.o_field_widget .oe_fileupload .o_attachments").toHaveCount(1);
expect.verifySteps([
"/web/dataset/call_kw/ir.attachment/web_read",
"/web/dataset/call_kw/turtle/web_save",
]);
});
test("widget many2many_binary displays notification on error", async () => {
expect.assertions(12);
mockService("http", () => ({
post(route, { ufile }) {
expect(route).toBe("/web/binary/upload_attachment");
expect([ufile[0].name, ufile[1].name]).toEqual(["good_file.txt", "bad_file.txt"], {
message: "files are correctly sent to the server",
});
const ids = MockServer.env["ir.attachment"].create({
name: ufile[0].name,
mimetype: "text/plain",
});
return JSON.stringify([
...MockServer.env["ir.attachment"].read(ids),
{
name: ufile[1].name,
mimetype: "text/plain",
error: `Error on file: ${ufile[1].name}`,
},
]);
},
}));
IrAttachment._views.list = '<list string="Pictures"><field name="name"/></list>';
await mountView({
type: "form",
resModel: "turtle",
arch: `
<form>
<group>
<field name="picture_ids" widget="many2many_binary" options="{'accepted_file_extensions': 'image/*'}"/>
</group>
</form>`,
resId: 1,
});
expect("div.o_field_widget .oe_fileupload").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attachments").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attach").toHaveCount(1);
expect("div.o_field_widget .oe_fileupload .o_attachment .o_attachment_delete").toHaveCount(1);
// Set and trigger the import of 2 files in the input
const files = [
new File(["good_file"], "good_file.txt", { type: "text/plain" }),
new File(["bad_file"], "bad_file.txt", { type: "text/plain" }),
];
await contains(".o_file_input_trigger").click();
await setInputFiles(files);
await animationFrame();
expect(".o_attachment:nth-child(2) .caption a:eq(0)").toHaveText("good_file.txt", {
message: 'value of attachment should be "good_file.txt"',
});
expect("div.o_field_widget .oe_fileupload .o_attachments").toHaveCount(1);
expect(".o_notification").toHaveCount(1);
expect(".o_notification_title").toHaveText("Uploading error");
expect(".o_notification_content").toHaveText("Error on file: bad_file.txt");
expect(".o_notification_bar").toHaveClass("bg-danger");
});

View file

@ -0,0 +1,496 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { runAllTimers } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
int_field = fields.Integer({ sortable: true });
timmy = fields.Many2many({ string: "pokemon", relation: "partner.type" });
p = fields.One2many({
string: "one2many field",
relation: "partner",
relation_field: "trululu",
});
trululu = fields.Many2one({ relation: "partner" });
_records = [{ id: 1, int_field: 10, p: [1] }];
}
class PartnerType extends models.Model {
name = fields.Char();
_records = [
{ id: 12, name: "gold" },
{ id: 14, name: "silver" },
];
}
defineModels([Partner, PartnerType]);
test("Many2ManyCheckBoxesField", async () => {
Partner._records[0].timmy = [12];
const commands = [[[4, 14]], [[3, 12]]];
onRpc("web_save", (args) => {
expect.step("web_save");
expect(args.args[1].timmy).toEqual(commands.shift());
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
});
expect("div.o_field_widget div.form-check").toHaveCount(2);
expect("div.o_field_widget div.form-check input:eq(0)").toBeChecked();
expect("div.o_field_widget div.form-check input:eq(1)").not.toBeChecked();
expect("div.o_field_widget div.form-check input:disabled").toHaveCount(0);
// add a m2m value by clicking on input
await contains("div.o_field_widget div.form-check input:eq(1)").click();
await runAllTimers();
await clickSave();
expect("div.o_field_widget div.form-check input:checked").toHaveCount(2);
// remove a m2m value by clinking on label
await contains("div.o_field_widget div.form-check > label").click();
await runAllTimers();
await clickSave();
expect("div.o_field_widget div.form-check input:eq(0)").not.toBeChecked();
expect("div.o_field_widget div.form-check input:eq(1)").toBeChecked();
expect.verifySteps(["web_save", "web_save"]);
});
test("Many2ManyCheckBoxesField (readonly)", async () => {
Partner._records[0].timmy = [12];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" readonly="True" />
</group>
</form>`,
});
expect("div.o_field_widget div.form-check").toHaveCount(2, {
message: "should have fetched and displayed the 2 values of the many2many",
});
expect("div.o_field_widget div.form-check input:disabled").toHaveCount(2, {
message: "the checkboxes should be disabled",
});
await contains("div.o_field_widget div.form-check > label:eq(1)").click();
expect("div.o_field_widget div.form-check input:eq(0)").toBeChecked();
expect("div.o_field_widget div.form-check input:eq(1)").not.toBeChecked();
});
test("Many2ManyCheckBoxesField does not read added record", async () => {
Partner._records[0].timmy = [];
onRpc((args) => {
expect.step(args.method);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
});
expect("div.o_field_widget div.form-check").toHaveCount(2);
expect(queryAllTexts(".o_field_widget .form-check-label")).toEqual(["gold", "silver"]);
expect("div.o_field_widget div.form-check input:checked").toHaveCount(0);
await contains("div.o_field_widget div.form-check input").click();
await runAllTimers();
expect("div.o_field_widget div.form-check").toHaveCount(2);
expect(queryAllTexts(".o_field_widget .form-check-label")).toEqual(["gold", "silver"]);
expect("div.o_field_widget div.form-check input:checked").toHaveCount(1);
await clickSave();
expect("div.o_field_widget div.form-check").toHaveCount(2);
expect(queryAllTexts(".o_field_widget .form-check-label")).toEqual(["gold", "silver"]);
expect("div.o_field_widget div.form-check input:checked").toHaveCount(1);
expect.verifySteps(["get_views", "web_read", "name_search", "web_save"]);
});
test("Many2ManyCheckBoxesField: start non empty, then remove twice", async () => {
Partner._records[0].timmy = [12, 14];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
});
await contains("div.o_field_widget div.form-check input:eq(0)").click();
await contains("div.o_field_widget div.form-check input:eq(1)").click();
await runAllTimers();
await clickSave();
expect("div.o_field_widget div.form-check input:eq(0)").not.toBeChecked();
expect("div.o_field_widget div.form-check input:eq(1)").not.toBeChecked();
});
test("Many2ManyCheckBoxesField: many2many read, field context is properly sent", async () => {
onRpc((args) => {
expect.step(args.method);
if (args.method === "web_read") {
expect(args.kwargs.specification.timmy.context).toEqual({ hello: "world" });
} else if (args.method === "name_search") {
expect(args.kwargs.context.hello).toEqual("world");
}
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes" context="{ 'hello': 'world' }" />
</form>`,
});
expect.verifySteps(["get_views", "web_read", "name_search"]);
});
test("Many2ManyCheckBoxesField: values are updated when domain changes", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="int_field" />
<field name="timmy" widget="many2many_checkboxes" domain="[['id', '>', int_field]]" />
</form>`,
});
expect(".o_field_widget[name='int_field'] input").toHaveValue("10");
expect(".o_field_widget[name='timmy'] .form-check").toHaveCount(2);
expect(".o_field_widget[name='timmy']").toHaveText("gold\nsilver");
await contains(".o_field_widget[name='int_field'] input").edit(13);
expect(".o_field_widget[name='timmy'] .form-check").toHaveCount(1);
expect(".o_field_widget[name='timmy']").toHaveText("silver");
});
test("Many2ManyCheckBoxesField with 40+ values", async () => {
// 40 is the default limit for x2many fields. However, the many2many_checkboxes is a
// special field that fetches its data through the fetchSpecialData mechanism, and it
// uses the name_search server-side limit of 100. This test comes with a fix for a bug
// that occurred when the user (un)selected a checkbox that wasn't in the 40 first checkboxes,
// because the piece of data corresponding to that checkbox hadn't been processed by the
// BasicModel, whereas the code handling the change assumed it had.
expect.assertions(3);
const records = [];
for (let id = 1; id <= 90; id++) {
records.push({
id,
name: `type ${id}`,
});
}
PartnerType._records = records;
Partner._records[0].timmy = records.map((r) => r.id);
onRpc("web_save", ({ args }) => {
expect(args[1].timmy).toEqual([[3, records[records.length - 1].id]]);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes" />
</form>`,
});
expect(".o_field_widget[name='timmy'] input[type='checkbox']:checked").toHaveCount(90);
// toggle the last value
await contains(".o_field_widget[name='timmy'] input[type='checkbox']:last").click();
await runAllTimers();
await clickSave();
expect(".o_field_widget[name='timmy'] input[type='checkbox']:last").not.toBeChecked();
});
test("Many2ManyCheckBoxesField with 100+ values", async () => {
// The many2many_checkboxes widget limits the displayed values to 100 (this is the
// server-side name_search limit). This test encodes a scenario where there are more than
// 100 records in the co-model, and all values in the many2many relationship aren't
// displayed in the widget (due to the limit). If the user (un)selects a checkbox, we don't
// want to remove all values that aren't displayed from the relation.
expect.assertions(5);
const records = [];
for (let id = 1; id < 150; id++) {
records.push({
id,
name: `type ${id}`,
});
}
PartnerType._records = records;
Partner._records[0].timmy = records.map((r) => r.id);
onRpc("web_save", ({ args }) => {
expect(args[1].timmy).toEqual([[3, records[0].id]]);
expect.step("web_save");
});
onRpc("name_search", () => {
expect.step("name_search");
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes" />
</form>`,
});
expect(".o_field_widget[name='timmy'] input[type='checkbox']").toHaveCount(100);
expect(".o_field_widget[name='timmy'] input[type='checkbox']").toBeChecked();
// toggle the first value
await contains(".o_field_widget[name='timmy'] input[type='checkbox']").click();
await runAllTimers();
await clickSave();
expect(".o_field_widget[name='timmy'] input[type='checkbox']:first").not.toBeChecked();
expect.verifySteps(["name_search", "web_save"]);
});
test("Many2ManyCheckBoxesField in a one2many", async () => {
expect.assertions(3);
PartnerType._records.push({ id: 15, name: "bronze" });
Partner._records[0].timmy = [14, 15];
onRpc("web_save", ({ args }) => {
expect(args[1]).toEqual({
p: [
[
1,
1,
{
timmy: [
[4, 12],
[3, 14],
],
},
],
],
});
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="p">
<list><field name="id"/></list>
<form>
<field name="timmy" widget="many2many_checkboxes"/>
</form>
</field>
</form>`,
resId: 1,
});
await contains(".o_data_cell").click();
// edit the timmy field by (un)checking boxes on the widget
await contains(".modal .form-check-input:eq(0)").click();
expect(".modal .form-check-input:eq(0)").toBeChecked();
await contains(".modal .form-check-input:eq(1)").click();
expect(".modal .form-check-input:eq(1)").not.toBeChecked();
await contains(".modal .o_form_button_save").click();
await clickSave();
});
test("Many2ManyCheckBoxesField with default values", async () => {
expect.assertions(7);
Partner._fields.timmy = fields.Many2many({
string: "pokemon",
relation: "partner.type",
default: [[4, 3]],
});
PartnerType._records.push({ id: 3, name: "bronze" });
onRpc("web_save", ({ args }) => {
expect(args[1].timmy).toEqual([[4, 12]], {
message: "correct values should have been sent to create",
});
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes"/>
</form>`,
});
expect(".o_form_view .form-check input:eq(0)").not.toBeChecked();
expect(".o_form_view .form-check input:eq(1)").not.toBeChecked();
expect(".o_form_view .form-check input:eq(2)").toBeChecked();
await contains(".o_form_view .form-check input:checked").click();
await contains(".o_form_view .form-check input:eq(0)").click();
await contains(".o_form_view .form-check input:eq(0)").click();
await contains(".o_form_view .form-check input:eq(0)").click();
await runAllTimers();
expect(".o_form_view .form-check input:eq(0)").toBeChecked();
expect(".o_form_view .form-check input:eq(1)").not.toBeChecked();
expect(".o_form_view .form-check input:eq(2)").not.toBeChecked();
await clickSave();
});
test("Many2ManyCheckBoxesField batches successive changes", async () => {
Partner._fields.timmy = fields.Many2many({
string: "pokemon",
relation: "partner.type",
onChange: () => {},
});
Partner._records[0].timmy = [];
onRpc(({ method }) => {
expect.step(method);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
});
expect("div.o_field_widget div.form-check").toHaveCount(2);
expect(queryAllTexts(".o_field_widget .form-check-label")).toEqual(["gold", "silver"]);
expect("div.o_field_widget div.form-check input:checked").toHaveCount(0);
await contains("div.o_field_widget div.form-check input:eq(0)").click();
await contains("div.o_field_widget div.form-check input:eq(1)").click();
// checkboxes are updated directly
expect("div.o_field_widget div.form-check input:checked").toHaveCount(2);
// but no onchanges has been fired yet
expect.verifySteps(["get_views", "web_read", "name_search"]);
await runAllTimers();
expect.verifySteps(["onchange"]);
});
test("Many2ManyCheckBoxesField sends batched changes on save", async () => {
Partner._fields.timmy = fields.Many2many({
string: "pokemon",
relation: "partner.type",
onChange: () => {},
});
Partner._records[0].timmy = [];
onRpc(({ method }) => {
expect.step(method);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
});
expect("div.o_field_widget div.form-check").toHaveCount(2);
expect(queryAllTexts(".o_field_widget .form-check-label")).toEqual(["gold", "silver"]);
expect("div.o_field_widget div.form-check input:checked").toHaveCount(0);
await contains("div.o_field_widget div.form-check input:eq(0)").click();
await contains("div.o_field_widget div.form-check input:eq(1)").click();
// checkboxes are updated directly
expect("div.o_field_widget div.form-check input:checked").toHaveCount(2);
// but no onchanges has been fired yet
expect.verifySteps(["get_views", "web_read", "name_search"]);
await runAllTimers();
// save
await clickSave();
expect.verifySteps(["onchange", "web_save"]);
});
test("Many2ManyCheckBoxesField in a notebook tab", async () => {
Partner._records[0].timmy = [];
onRpc(({ method }) => {
expect.step(method);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<notebook>
<page string="Page 1">
<field name="timmy" widget="many2many_checkboxes" />
</page>
<page string="Page 2">
<field name="int_field" />
</page>
</notebook>
</form>`,
});
expect("div.o_field_widget[name=timmy]").toHaveCount(1);
expect("div.o_field_widget[name=timmy] div.form-check").toHaveCount(2);
expect(queryAllTexts(".o_field_widget .form-check-label")).toEqual(["gold", "silver"]);
expect("div.o_field_widget[name=timmy] div.form-check input:checked").toHaveCount(0);
await contains("div.o_field_widget div.form-check input:eq(0)").click();
await contains("div.o_field_widget div.form-check input:eq(1)").click();
// checkboxes are updated directly
expect("div.o_field_widget div.form-check input:checked").toHaveCount(2);
// go to the other tab
await contains(".o_notebook .nav-link:eq(1)").click();
expect("div.o_field_widget[name=timmy]").toHaveCount(0);
expect("div.o_field_widget[name=int_field]").toHaveCount(1);
// save
await clickSave();
expect.verifySteps(["get_views", "web_read", "name_search", "web_save"]);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,434 @@
import { describe, expect, test } from "@odoo/hoot";
import { press, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { getOrigin } from "@web/core/utils/urls";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
describe.current.tags("desktop");
class Partner extends models.Model {
name = fields.Char({ string: "Displayed name" });
_records = [
{ id: 1, name: "first record" },
{ id: 2, name: "second record" },
{ id: 4, name: "aaa" },
];
}
class Turtle extends models.Model {
name = fields.Char({ string: "Displayed name" });
partner_ids = fields.Many2many({ string: "Partner", relation: "partner" });
_records = [
{ id: 1, name: "leonardo", partner_ids: [] },
{ id: 2, name: "donatello", partner_ids: [2, 4] },
{ id: 3, name: "raphael" },
];
}
onRpc("has_group", () => {
return true;
});
defineModels([Partner, Turtle]);
test("widget many2many_tags_avatar", async () => {
await mountView({
type: "form",
resModel: "turtle",
arch: `
<form>
<sheet>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</sheet>
</form>`,
resId: 1,
});
expect(queryAllTexts("[name='partner_ids'] .o_tag")).toEqual([]);
expect("[name='partner_ids'] .o_input_dropdown input").toHaveValue("");
await contains("[name='partner_ids'] .o_input_dropdown input").fill("first record");
await runAllTimers();
expect(queryAllTexts("[name='partner_ids'] .o_tag")).toEqual(["first record"]);
expect("[name='partner_ids'] .o_input_dropdown input").toHaveValue("");
await contains("[name='partner_ids'] .o_input_dropdown input").fill("abc");
await runAllTimers();
expect(queryAllTexts("[name='partner_ids'] .o_tag")).toEqual(["first record", "abc"]);
expect("[name='partner_ids'] .o_input_dropdown input").toHaveValue("");
});
test("widget many2many_tags_avatar img src", async () => {
await mountView({
type: "form",
resModel: "turtle",
arch: `
<form>
<sheet>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</sheet>
</form>`,
resId: 2,
});
expect(".o_field_many2many_tags_avatar.o_field_widget .o_avatar img").toHaveCount(2);
expect(
`.o_field_many2many_tags_avatar.o_field_widget .o_avatar:nth-child(1) img[data-src='${getOrigin()}/web/image/partner/2/avatar_128']`
).toHaveCount(1);
});
test("widget many2many_tags_avatar in list view", async () => {
for (let id = 5; id <= 15; id++) {
Partner._records.push({
id,
name: `record ${id}`,
});
}
Turtle._records.push({
id: 4,
name: "crime master gogo",
partner_ids: [1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
});
Turtle._records[0].partner_ids = [1];
Turtle._records[1].partner_ids = [1, 2, 4, 5, 6, 7];
Turtle._records[2].partner_ids = [1, 2, 4, 5, 7];
await mountView({
type: "list",
resModel: "turtle",
arch: `
<list editable="bottom">
<field name="partner_ids" widget="many2many_tags_avatar"/>
</list>`,
});
expect(
`.o_data_row:nth-child(1) .o_field_many2many_tags_avatar .o_avatar img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/1/avatar_128']`
).toHaveCount(1);
expect(
".o_data_row .o_many2many_tags_avatar_cell .o_field_many2many_tags_avatar:eq(0)"
).toHaveText("first record");
expect(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_avatar:not(.o_m2m_avatar_empty) img"
).toHaveCount(4);
expect(
".o_data_row:nth-child(3) .o_field_many2many_tags_avatar .o_avatar:not(.o_m2m_avatar_empty) img"
).toHaveCount(5);
expect(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveCount(1);
expect(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveText("+2");
expect(
`.o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_avatar:nth-child(1) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/1/avatar_128']`
).toHaveCount(1);
expect(
`.o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_avatar:nth-child(2) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/2/avatar_128']`
).toHaveCount(1);
expect(
`.o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_avatar:nth-child(3) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/4/avatar_128']`
).toHaveCount(1);
expect(
`.o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_avatar:nth-child(4) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/5/avatar_128']`
).toHaveCount(1);
expect(
".o_data_row:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveCount(0);
expect(
".o_data_row:nth-child(4) .o_field_many2many_tags_avatar .o_avatar:not(.o_m2m_avatar_empty) img"
).toHaveCount(4);
expect(
".o_data_row:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveCount(1);
expect(
".o_data_row:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveText("+9");
// check data-tooltip attribute (used by the tooltip service)
const tag = queryOne(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
);
expect(tag).toHaveAttribute("data-tooltip-template", "web.TagsList.Tooltip");
const tooltipInfo = JSON.parse(tag.dataset["tooltipInfo"]);
expect(tooltipInfo.tags.map((tag) => tag.text).join(" ")).toBe("record 6 record 7", {
message: "shows a tooltip on hover",
});
await contains(".o_data_row .o_many2many_tags_avatar_cell:eq(0)").click();
await contains(
".o_data_row .o_many2many_tags_avatar_cell:eq(0) .o-autocomplete--input"
).click();
await contains(".o-autocomplete--dropdown-item:eq(1)").click();
await contains(".o_control_panel_main_buttons .o_list_button_save").click();
expect(".o_data_row:eq(0) .o_field_many2many_tags_avatar .o_avatar img").toHaveCount(2);
// Select the first row and enter edit mode on the x2many field.
await contains(".o_data_row:nth-child(1) .o_list_record_selector input").click();
await contains(".o_data_row:nth-child(1) .o_data_cell").click();
// Only the first row should have tags with delete buttons.
expect(".o_data_row:nth-child(1) .o_field_tags span .o_delete").toHaveCount(2);
expect(".o_data_row:nth-child(2) .o_field_tags span .o_delete").toHaveCount(0);
expect(".o_data_row:nth-child(3) .o_field_tags span .o_delete").toHaveCount(0);
expect(".o_data_row:nth-child(4) .o_field_tags span .o_delete").toHaveCount(0);
});
test("widget many2many_tags_avatar list view - don't crash on keyboard navigation", async () => {
await mountView({
type: "list",
resModel: "turtle",
arch: /*xml*/ `
<list editable="bottom">
<field name="partner_ids" widget="many2many_tags_avatar"/>
</list>
`,
});
// Select the 2nd row and enter edit mode on the x2many field.
await contains(".o_data_row:nth-child(2) .o_list_record_selector input").click();
await contains(".o_data_row:nth-child(2) .o_data_cell").click();
// Pressing left arrow should focus on the right-most (second) tag.
await press("arrowleft");
expect(".o_data_row:nth-child(2) .o_field_tags span:nth-child(2):first").toBeFocused();
// Pressing left arrow again should not crash and should focus on the first tag.
await press("arrowleft");
expect(".o_data_row:nth-child(2) .o_field_tags span:nth-child(1):first").toBeFocused();
});
test("widget many2many_tags_avatar in kanban view", async () => {
expect.assertions(21);
for (let id = 5; id <= 15; id++) {
Partner._records.push({
id,
name: `record ${id}`,
});
}
Turtle._records.push({
id: 4,
name: "crime master gogo",
partner_ids: [1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
});
Turtle._records[0].partner_ids = [1];
Turtle._records[1].partner_ids = [1, 2, 4];
Turtle._records[2].partner_ids = [1, 2, 4, 5];
Turtle._views = {
form: '<form><field name="name"/></form>',
};
Partner._views = {
list: '<list><field name="name"/></list>',
};
await mountView({
type: "kanban",
resModel: "turtle",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="name"/>
<footer>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
selectRecord(recordId) {
expect(recordId).toBe(1, {
message: "should call its selectRecord prop with the clicked record",
});
},
});
expect(".o_kanban_record:eq(0) .o_field_many2many_tags_avatar .o_quick_assign").toHaveCount(1);
expect(
".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_avatar img"
).toHaveCount(3);
expect(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_avatar img"
).toHaveCount(2);
expect(
`.o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_avatar:nth-child(1) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/5/avatar_128']`
).toHaveCount(1);
expect(
`.o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_avatar:nth-child(2) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/4/avatar_128']`
).toHaveCount(1);
expect(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveCount(1);
expect(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveText("+2");
expect(
".o_kanban_record:nth-child(4) .o_field_many2many_tags_avatar .o_avatar img"
).toHaveCount(2);
expect(
".o_kanban_record:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveCount(1);
expect(
".o_kanban_record:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
).toHaveText("9+");
expect(".o_field_many2many_tags_avatar .o_field_many2many_selection").toHaveCount(0);
await contains(".o_kanban_record:nth-child(3) .o_field_tags > .o_m2m_avatar_empty").click();
await animationFrame();
expect(".o-overlay-container input").toBeFocused();
expect(".o-overlay-container .o_tag").toHaveCount(4);
// delete inside the popover
await contains(".o-overlay-container .o_tag .o_delete:eq(0)", {
visible: false,
displayed: true,
}).click();
expect(".o-overlay-container .o_tag").toHaveCount(3);
expect(".o_kanban_record:nth-child(3) .o_tag").toHaveCount(3);
// select first non selected input
await contains(".o-overlay-container .o-autocomplete--dropdown-item:eq(4)").click();
expect(".o-overlay-container .o_tag").toHaveCount(4);
expect(".o_kanban_record:nth-child(3) .o_tag").toHaveCount(2);
// load more
await contains(".o-overlay-container .o_m2o_dropdown_option_search_more").click();
// first non already selected item
await contains(".o_dialog .o_list_table .o_data_row .o_data_cell:eq(3)").click();
expect(".o-overlay-container .o_tag").toHaveCount(5);
expect(".o_kanban_record:nth-child(3) .o_tag").toHaveCount(2);
expect(
`.o_kanban_record:nth-child(2) img.o_m2m_avatar[data-src='${getOrigin()}/web/image/partner/4/avatar_128']`
).toHaveCount(1);
await contains(".o_kanban_record .o_field_many2many_tags_avatar img.o_m2m_avatar").click();
});
test("widget many2many_tags_avatar add/remove tags in kanban view", async () => {
onRpc("web_save", ({ args }) => {
const command = args[1].partner_ids[0];
expect.step(`web_save: ${command[0]}-${command[1]}`);
});
await mountView({
type: "kanban",
resModel: "turtle",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="name"/>
<footer>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
});
await contains(".o_kanban_record:eq(0) .o_quick_assign", { visible: false }).click();
// add and directly remove an item
await contains(".o_popover .o-autocomplete--dropdown-item:eq(0)").click();
await contains(".o_popover .o_tag .o_delete", { visible: false }).click();
expect.verifySteps(["web_save: 4-1", "web_save: 3-1"]);
});
test("widget many2many_tags_avatar delete tag", async () => {
await mountView({
type: "form",
resModel: "turtle",
resId: 2,
arch: `
<form>
<sheet>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</sheet>
</form>`,
});
expect(".o_field_many2many_tags_avatar.o_field_widget .o_tag").toHaveCount(2);
await contains(".o_field_many2many_tags_avatar.o_field_widget .o_tag .o_delete", {
visible: false,
}).click();
expect(".o_field_many2many_tags_avatar.o_field_widget .o_tag").toHaveCount(1);
await clickSave();
expect(".o_field_many2many_tags_avatar.o_field_widget .o_tag").toHaveCount(1);
});
test("widget many2many_tags_avatar quick add tags and close in kanban view with keyboard navigation", async () => {
await mountView({
type: "kanban",
resModel: "turtle",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="name"/>
<footer>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
});
await contains(".o_kanban_record:eq(0) .o_quick_assign", { visible: false }).click();
// add and directly close the dropdown
await press("Tab");
await press("Enter");
await animationFrame();
expect(".o_kanban_record:eq(0) .o_field_many2many_tags_avatar .o_tag").toHaveCount(1);
expect(".o_kanban_record:eq(0) .o_field_many2many_tags_avatar .o_popover").toHaveCount(0);
});
test("widget many2many_tags_avatar in kanban view missing access rights", async () => {
expect.assertions(1);
await mountView({
type: "kanban",
resModel: "turtle",
arch: `
<kanban edit="0" create="0">
<templates>
<t t-name="card">
<field name="name"/>
<footer>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record:eq(0) .o_field_many2many_tags_avatar .o_quick_assign").toHaveCount(0);
});
test("Many2ManyTagsAvatarField: make sure that the arch context is passed to the form view call", async () => {
Partner._views = {
form: `<form><field name="name"/></form>`,
};
onRpc("onchange", (args) => {
if (args.model === "partner" && args.kwargs.context.append_coucou === "test_value") {
expect.step("onchange with context given");
}
});
await mountView({
type: "list",
resModel: "turtle",
arch: `<list editable="top">
<field name="partner_ids" widget="many2many_tags_avatar" context="{ 'append_coucou': 'test_value' }"/>
</list>`,
});
await contains("div[name=partner_ids]").click();
await contains(`div[name="partner_ids"] input`).edit("A new partner", { confirm: false });
await runAllTimers();
await contains(".o_m2o_dropdown_option_create_edit").click();
expect(".modal .o_form_view").toHaveCount(1);
expect.verifySteps(["onchange with context given"]);
});

View file

@ -0,0 +1,410 @@
import { expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts } from "@odoo/hoot-dom";
import { runAllTimers } from "@odoo/hoot-mock";
import {
clickFieldDropdown,
clickFieldDropdownItem,
clickSave,
contains,
defineModels,
fields,
models,
mountView,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
class Partner extends models.Model {
int_field = fields.Integer();
user_id = fields.Many2one({ string: "Users", relation: "res.users" });
_records = [
{ id: 1, user_id: 1 },
{ id: 2, user_id: 2 },
{ id: 3, user_id: 1 },
{ id: 4, user_id: false },
];
}
class Users extends models.Model {
_name = "res.users";
name = fields.Char();
partner_ids = fields.One2many({ relation: "partner", relation_field: "user_id" });
has_group() {
return true;
}
_records = [
{
id: 1,
name: "Aline",
},
{
id: 2,
name: "Christine",
},
];
}
defineModels([Partner, Users]);
test.tags("desktop");
test("basic form view flow", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="user_id" widget="many2one_avatar"/>
</form>`,
});
expect(".o_field_widget[name=user_id] input").toHaveValue("Aline");
expect('.o_m2o_avatar > img[data-src="/web/image/res.users/1/avatar_128"]').toHaveCount(1);
expect(".o_field_many2one_avatar > div").toHaveCount(1);
expect(".o_input_dropdown").toHaveCount(1);
expect(".o_input_dropdown input").toHaveValue("Aline");
expect(".o_external_button").toHaveCount(1);
expect('.o_m2o_avatar > img[data-src="/web/image/res.users/1/avatar_128"]').toHaveCount(1);
await clickFieldDropdown("user_id");
expect(".o_field_many2one_selection .o_avatar_many2x_autocomplete").toHaveCount(3);
await clickFieldDropdownItem("user_id", "Christine");
expect('.o_m2o_avatar > img[data-src="/web/image/res.users/2/avatar_128"]').toHaveCount(1);
await clickSave();
expect(".o_field_widget[name=user_id] input").toHaveValue("Christine");
expect('.o_m2o_avatar > img[data-src="/web/image/res.users/2/avatar_128"]').toHaveCount(1);
await contains('.o_field_widget[name="user_id"] input').clear({ confirm: "blur" });
expect(".o_m2o_avatar > img").toHaveCount(0);
expect(".o_m2o_avatar > .o_m2o_avatar_empty").toHaveCount(1);
await clickSave();
expect(".o_m2o_avatar > img").toHaveCount(0);
expect(".o_m2o_avatar > .o_m2o_avatar_empty").toHaveCount(1);
});
test("onchange in form view flow", async () => {
Partner._fields.int_field = fields.Integer({
onChange: (obj) => {
if (obj.int_field === 1) {
obj.user_id = [2, "Christine"];
} else if (obj.int_field === 2) {
obj.user_id = false;
} else {
obj.user_id = [1, "Aline"]; // default value
}
},
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="int_field"/>
<field name="user_id" widget="many2one_avatar" readonly="1"/>
</form>`,
});
expect(".o_field_widget[name=user_id]").toHaveText("Aline");
expect('.o_m2o_avatar > img[data-src="/web/image/res.users/1/avatar_128"]').toHaveCount(1);
await contains("div[name=int_field] input").edit(1);
expect(".o_field_widget[name=user_id]").toHaveText("Christine");
expect('.o_m2o_avatar > img[data-src="/web/image/res.users/2/avatar_128"]').toHaveCount(1);
await contains("div[name=int_field] input").edit(2);
expect(".o_field_widget[name=user_id]").toHaveText("");
expect(".o_m2o_avatar > img").toHaveCount(0);
});
test("basic list view flow", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: '<list><field name="user_id" widget="many2one_avatar"/></list>',
});
expect(queryAllTexts(".o_data_cell[name='user_id']")).toEqual([
"Aline",
"Christine",
"Aline",
"",
]);
const imgs = queryAll(".o_m2o_avatar > img");
expect(imgs[0]).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
expect(imgs[1]).toHaveAttribute("data-src", "/web/image/res.users/2/avatar_128");
expect(imgs[2]).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
});
test("basic flow in editable list view", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: '<list editable="top"><field name="user_id" widget="many2one_avatar"/></list>',
});
expect(queryAllTexts(".o_data_cell[name='user_id']")).toEqual([
"Aline",
"Christine",
"Aline",
"",
]);
const imgs = queryAll(".o_m2o_avatar > img");
expect(imgs[0]).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
expect(imgs[1]).toHaveAttribute("data-src", "/web/image/res.users/2/avatar_128");
expect(imgs[2]).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
await contains(".o_data_row .o_data_cell:eq(0)").click();
expect(".o_m2o_avatar > img:eq(0)").toHaveAttribute(
"data-src",
"/web/image/res.users/1/avatar_128"
);
});
test("Many2OneAvatar with placeholder", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="user_id" widget="many2one_avatar" placeholder="Placeholder"/></form>',
});
expect(".o_field_widget[name='user_id'] input").toHaveAttribute("placeholder", "Placeholder");
});
test.tags("desktop");
test("click on many2one_avatar in a list view (multi_edit='1')", async () => {
const listView = registry.category("views").get("list");
patchWithCleanup(listView.Controller.prototype, {
openRecord() {
expect.step("openRecord");
},
});
await mountView({
type: "list",
resModel: "partner",
arch: `
<list multi_edit="1">
<field name="user_id" widget="many2one_avatar"/>
</list>`,
});
await contains(".o_data_row:eq(0) .o_list_record_selector input").click();
await contains(".o_data_row .o_data_cell [name='user_id']").click();
expect(".o_data_row:eq(0)").toHaveClass("o_selected_row");
expect.verifySteps([]);
});
test("click on many2one_avatar in an editable list view", async () => {
const listView = registry.category("views").get("list");
patchWithCleanup(listView.Controller.prototype, {
openRecord() {
expect.step("openRecord");
},
});
await mountView({
type: "list",
resModel: "partner",
arch: `
<list>
<field name="user_id" widget="many2one_avatar"/>
</list>`,
});
await contains(".o_data_row .o_data_cell [name='user_id']").click();
expect(".o_selected_row").toHaveCount(0);
expect.verifySteps(["openRecord"]);
});
test.tags("desktop");
test("click on many2one_avatar in an editable list view (editable top)", async () => {
const listView = registry.category("views").get("list");
patchWithCleanup(listView.Controller.prototype, {
openRecord() {
expect.step("openRecord");
},
});
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="top">
<field name="user_id" widget="many2one_avatar"/>
</list>`,
});
await contains(".o_data_row:eq(0) .o_list_record_selector input").click();
await contains(".o_data_row .o_data_cell [name='user_id']").click();
expect(".o_data_row:eq(0)").toHaveClass("o_selected_row");
expect.verifySteps([]);
});
test("readonly many2one_avatar in form view should contain a link", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `<form><field name="user_id" widget="many2one_avatar" readonly="1"/></form>`,
});
expect("[name='user_id'] a").toHaveCount(1);
});
test("readonly many2one_avatar in list view should not contain a link", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: `<list><field name="user_id" widget="many2one_avatar"/></list>`,
});
expect("[name='user_id'] a").toHaveCount(0);
});
test.tags("desktop");
test("cancelling create dialog should clear value in the field", async () => {
Users._views = {
form: `
<form>
<field name="name" />
</form>`,
};
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="top">
<field name="user_id" widget="many2one_avatar"/>
</list>`,
});
await contains(".o_data_cell:eq(0)").click();
await contains(".o_field_widget[name=user_id] input").edit("yy", { confirm: false });
await runAllTimers();
await clickFieldDropdownItem("user_id", "Create and edit...");
await contains(".o_form_button_cancel").click();
expect(".o_field_widget[name=user_id] input").toHaveValue("");
expect(".o_field_widget[name=user_id] span.o_m2o_avatar_empty").toHaveCount(1);
});
test.tags("desktop");
test("widget many2one_avatar in kanban view (load more dialog)", async () => {
expect.assertions(1);
for (let id = 3; id <= 12; id++) {
Users._records.push({
id,
display_name: `record ${id}`,
});
}
Users._views = {
list: '<list><field name="display_name"/></list>',
};
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<footer>
<field name="user_id" widget="many2one_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
});
// open popover
await contains(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > a.o_quick_assign"
).click();
// load more
await contains(".o-overlay-container .o_m2o_dropdown_option_search_more").click();
await contains(".o_dialog .o_list_table .o_data_row .o_data_cell").click();
expect(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > img"
).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
});
test("widget many2one_avatar in kanban view", async () => {
expect.assertions(5);
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<footer>
<field name="user_id" widget="many2one_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
});
expect(
".o_kanban_record:nth-child(1) .o_field_many2one_avatar .o_m2o_avatar > img"
).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
expect(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > .o_quick_assign"
).toHaveCount(1);
// open popover
await contains(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > .o_quick_assign"
).click();
expect(".o-overlay-container input").toBeFocused();
// select first input
await contains(".o-overlay-container .o-autocomplete--dropdown-item").click();
expect(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > img"
).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
expect(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > .o_quick_assign"
).toHaveCount(0);
});
test("widget many2one_avatar in kanban view without access rights", async () => {
expect.assertions(2);
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban edit="0" create="0">
<templates>
<t t-name="card">
<footer>
<field name="user_id" widget="many2one_avatar"/>
</footer>
</t>
</templates>
</kanban>`,
});
expect(
".o_kanban_record:nth-child(1) .o_field_many2one_avatar .o_m2o_avatar > img"
).toHaveAttribute("data-src", "/web/image/res.users/1/avatar_128");
expect(
".o_kanban_record:nth-child(4) .o_field_many2one_avatar .o_m2o_avatar > .o_quick_assign"
).toHaveCount(0);
});

View file

@ -0,0 +1,158 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { mockUserAgent, mockVibrate, runAllTimers } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
getKwArgs,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import * as BarcodeScanner from "@web/core/barcode/barcode_dialog";
class Product extends models.Model {
_name = "product.product";
name = fields.Char();
barcode = fields.Char();
_records = [
{
id: 111,
name: "product_cable_management_box",
barcode: "601647855631",
},
{
id: 112,
name: "product_n95_mask",
barcode: "601647855632",
},
{
id: 113,
name: "product_surgical_mask",
barcode: "601647855633",
},
];
// to allow the search in barcode too
name_search() {
const result = super.name_search(...arguments);
const kwargs = getKwArgs(arguments, "name", "domain");
for (const record of this) {
if (record.barcode === kwargs.name) {
result.push([record.id, record.name]);
}
}
return result;
}
}
class SaleOrderLine extends models.Model {
id = fields.Integer();
product_id = fields.Many2one({
relation: "product.product",
});
}
defineModels([Product, SaleOrderLine]);
beforeEach(() => {
mockUserAgent("android");
mockVibrate((pattern) => expect.step(`vibrate:${pattern}`));
});
test("Many2OneBarcode component should display the barcode icon", async () => {
await mountView({
type: "form",
resModel: "sale.order.line",
arch: `
<form>
<field name="product_id" widget="many2one_barcode"/>
</form>
`,
});
expect(".o_barcode").toHaveCount(1);
});
test("barcode button with single results", async () => {
expect.assertions(3);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = Product._records[0];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => selectedRecordTest.barcode,
});
onRpc("sale.order.line", "web_save", (args) => {
const selectedId = args.args[1]["product_id"];
expect(selectedId).toBe(selectedRecordTest.id, {
message: `product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`,
});
return args.parent();
});
await mountView({
type: "form",
resModel: "sale.order.line",
arch: `
<form>
<field name="product_id" options="{'can_scan_barcode': True}"/>
</form>
`,
});
expect(".o_barcode").toHaveCount(1);
await contains(".o_barcode").click();
await clickSave();
expect.verifySteps(["vibrate:100"]);
});
test.tags("desktop");
test("barcode button with multiple results", async () => {
expect.assertions(5);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = Product._records[1];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => "mask",
});
onRpc("sale.order.line", "web_save", (args) => {
const selectedId = args.args[1]["product_id"];
expect(selectedId).toBe(selectedRecordTest.id, {
message: `product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`,
});
return args.parent();
});
await mountView({
type: "form",
resModel: "sale.order.line",
arch: `
<form>
<field name="product_id" options="{'can_scan_barcode': True}"/>
</form>`,
});
expect(".o_barcode").toHaveCount(1);
await contains(".o_barcode").click();
await runAllTimers();
expect(".o-autocomplete--dropdown-menu").toHaveCount(1);
expect(
".o-autocomplete--dropdown-menu .o-autocomplete--dropdown-item.ui-menu-item:not(.o_m2o_dropdown_option)"
).toHaveCount(2);
await contains(
".o-autocomplete--dropdown-menu .o-autocomplete--dropdown-item:nth-child(1)"
).click();
await clickSave();
expect.verifySteps(["vibrate:100"]);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,232 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { runAllTimers } from "@odoo/hoot-mock";
import {
clickFieldDropdownItem,
clickSave,
contains,
defineModels,
fields,
mockService,
models,
mountView,
onRpc,
selectFieldDropdownItem,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
model = fields.Char({
string: "Resource Model",
});
res_id = fields.Many2oneReference({
string: "Resource Id",
model_field: "model",
relation: "partner.type",
});
_records = [
{ id: 1, model: "partner.type", res_id: 10 },
{ id: 2, res_id: false },
];
}
class PartnerType extends models.Model {
id = fields.Integer();
name = fields.Char();
_records = [
{ id: 10, name: "gold" },
{ id: 14, name: "silver" },
];
}
defineModels([Partner, PartnerType]);
onRpc("has_group", () => true);
test("Many2OneReferenceField in form view", async () => {
mockService("action", {
doAction() {
expect.step("doAction");
},
});
onRpc("get_formview_action", ({ model, args }) => {
expect.step(`opening ${model} ${args[0][0]}`);
return false;
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="model" invisible="1"/>
<field name="res_id"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("gold");
expect(".o_field_widget[name=res_id] .o_external_button").toHaveCount(1);
await contains(".o_field_widget[name=res_id] .o_external_button", { visible: false }).click();
expect.verifySteps(["opening partner.type 10", "doAction"]);
});
test("Many2OneReferenceField in list view", async () => {
await mountView({
type: "list",
resModel: "partner",
resId: 1,
arch: `
<list>
<field name="model" column_invisible="1"/>
<field name="res_id"/>
</list>`,
});
expect(queryAllTexts(".o_data_cell")).toEqual(["gold", ""]);
});
test("Many2OneReferenceField with no_open option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="model" invisible="1"/>
<field name="res_id" options="{'no_open': 1}"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("gold");
expect(".o_field_widget[name=res_id] .o_external_button").toHaveCount(0);
});
test.tags("desktop");
test("Many2OneReferenceField edition: unset", async () => {
expect.assertions(4);
onRpc("web_save", ({ args }) => {
expect(args).toEqual([[2], { model: "partner.type", res_id: 14 }]);
});
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: `
<form>
<field name="model"/>
<field name="res_id"/>
</form>`,
});
expect(".o_field_widget[name=res_id] input").toHaveCount(0);
await contains(".o_field_widget[name=model] input").edit("partner.type");
expect(".o_field_widget[name=res_id] input").toHaveCount(1);
await selectFieldDropdownItem("res_id", "silver");
expect(".o_field_widget[name=res_id] input").toHaveValue("silver");
await clickSave();
});
test.tags("desktop");
test("Many2OneReferenceField set value with search more", async () => {
PartnerType._views = {
list: `<list><field name="name"/></list>`,
};
PartnerType._records = [
{ id: 1, name: "type 1" },
{ id: 2, name: "type 2" },
{ id: 3, name: "type 3" },
{ id: 4, name: "type 4" },
{ id: 5, name: "type 5" },
{ id: 6, name: "type 6" },
{ id: 7, name: "type 7" },
{ id: 8, name: "type 8" },
{ id: 9, name: "type 9" },
];
Partner._records[0].res_id = 1;
onRpc(({ method }) => {
expect.step(method);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="model" invisible="1"/>
<field name="res_id"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("type 1");
await selectFieldDropdownItem("res_id", "Search More...");
expect(".o_dialog .o_list_view").toHaveCount(1);
await contains(".o_data_row .o_data_cell:eq(6)").click();
expect(".o_dialog .o_list_view").toHaveCount(0);
expect(".o_field_widget input").toHaveValue("type 7");
expect.verifySteps([
"get_views", // form view
"web_read", // partner id 1
"name_search", // many2one
"get_views", // Search More...
"web_search_read", // SelectCreateDialog
"has_group",
"web_read", // read selected value
]);
});
test.tags("desktop");
test("Many2OneReferenceField: quick create a value", async () => {
onRpc(({ method }) => {
expect.step(method);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="model" invisible="1"/>
<field name="res_id"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("gold");
await contains(".o_field_widget[name='res_id'] input").edit("new value", { confirm: false });
await runAllTimers();
expect(
".o_field_widget[name='res_id'] .dropdown-menu .o_m2o_dropdown_option_create"
).toHaveCount(1);
await clickFieldDropdownItem("res_id", `Create "new value"`);
expect(".o_field_widget input").toHaveValue("new value");
expect.verifySteps(["get_views", "web_read", "name_search", "name_create"]);
});
test("Many2OneReferenceField with no_create option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="model" invisible="1"/>
<field name="res_id" options="{'no_create': 1}"/>
</form>`,
});
await contains(".o_field_widget[name='res_id'] input").edit("new value", { confirm: false });
await runAllTimers();
expect(
".o_field_widget[name='res_id'] .dropdown-menu .o_m2o_dropdown_option_create"
).toHaveCount(0);
});

View file

@ -0,0 +1,66 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
model = fields.Char({
string: "Resource Model",
});
res_id = fields.Many2oneReference({
string: "Resource Id",
model_field: "model",
relation: "partner.type",
});
_records = [
{ id: 1, model: "partner.type", res_id: 10 },
{ id: 2, res_id: false },
];
}
class PartnerType extends models.Model {
name = fields.Char();
_records = [
{ id: 10, name: "gold" },
{ id: 14, name: "silver" },
];
}
defineModels([Partner, PartnerType]);
onRpc("has_group", () => true);
test("Many2OneReferenceIntegerField in form view", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: '<form><field name="res_id" widget="many2one_reference_integer"/></form>',
});
expect(".o_field_widget input").toHaveValue("10");
});
test("Many2OneReferenceIntegerField in list view", async () => {
await mountView({
type: "list",
resModel: "partner",
resId: 1,
arch: '<list><field name="res_id" widget="many2one_reference_integer"/></list>',
});
expect(queryAllTexts(".o_data_cell")).toEqual(["10", ""]);
});
test("Many2OneReferenceIntegerField: unset value in form view", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: '<form><field name="res_id" widget="many2one_reference_integer"/></form>',
});
expect(".o_field_widget input").toHaveValue("");
});

View file

@ -0,0 +1,798 @@
import { expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts, queryFirst } from "@odoo/hoot-dom";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
serverState,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
name = fields.Char();
int_field = fields.Integer();
float_field = fields.Float({
digits: [16, 1],
});
p = fields.One2many({ relation: "partner" });
currency_id = fields.Many2one({ relation: "res.currency" });
monetary_field = fields.Monetary({ currency_field: "currency_id" });
_records = [
{ id: 1, int_field: 10, float_field: 0.44444 },
{ id: 2, int_field: 0, float_field: 0, currency_id: 2 },
{ id: 3, int_field: 80, float_field: -3.89859 },
{ id: 4, int_field: 0, float_field: 0 },
{ id: 5, int_field: -4, float_field: 9.1, monetary_field: 9.1, currency_id: 1 },
{ id: 6, float_field: 3.9, monetary_field: 4.2, currency_id: 1 },
];
}
class Currency extends models.Model {
_name = "res.currency";
name = fields.Char();
symbol = fields.Char({ string: "Currency Sumbol" });
position = fields.Selection({
selection: [
["after", "A"],
["before", "B"],
],
});
_records = [
{ id: 1, name: "USD", symbol: "$", position: "before" },
{ id: 2, name: "EUR", symbol: "€", position: "after" },
{
id: 3,
name: "VEF",
symbol: "Bs.F",
position: "after",
},
];
}
defineModels([Partner, Currency]);
onRpc("has_group", () => true);
test("basic flow in form view - float field", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `
<form>
<field name="float_field" widget="monetary"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
expect(".o_field_monetary > div.text-nowrap").toHaveCount(1);
expect(".o_field_widget input").toHaveValue("9.10", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
await contains(".o_field_monetary input").edit("108.2458938598598");
expect(".o_field_widget input").toHaveValue("108.25", {
message: "The new value should be rounded properly after the blur",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("108.25", {
message: "The new value should be rounded properly.",
});
});
test("basic flow in form view - monetary field", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `
<form>
<field name="monetary_field"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("9.10", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
await contains(".o_field_monetary input").edit("108.2458938598598");
expect(".o_field_widget input").toHaveValue("108.25", {
message: "The new value should be rounded properly after the blur",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("108.25", {
message: "The new value should be rounded properly.",
});
});
test("rounding using formula in form view - float field", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `
<form>
<field name="float_field" widget="monetary"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
// Test computation and rounding
await contains(".o_field_monetary input").edit("=100/3");
await clickSave();
expect(".o_field_widget input").toHaveValue("33.33", {
message: "The new value should be calculated and rounded properly.",
});
});
test("rounding using formula in form view - monetary field", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `
<form>
<field name="monetary_field"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
// Test computation and rounding
await contains(".o_field_monetary input").edit("=100/3");
await clickSave();
expect(".o_field_widget input").toHaveValue("33.33", {
message: "The new value should be calculated and rounded properly.",
});
});
test("with currency digits != 2 - float field", async () => {
serverState.currencies = [
{ id: 1, name: "USD", symbol: "$", position: "before" },
{ id: 2, name: "EUR", symbol: "€", position: "after" },
{
id: 3,
name: "VEF",
symbol: "Bs.F",
position: "after",
digits: [0, 4],
},
];
Partner._records = [
{
id: 1,
float_field: 99.1234,
currency_id: 3,
},
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="float_field" widget="monetary"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("99.1234", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(1)").toHaveText("Bs.F", {
message: "The input should be superposed with a span containing the currency symbol.",
});
await contains(".o_field_widget input").edit("99.111111111");
expect(".o_field_widget input").toHaveValue("99.1111", {
message: "The value should should be formatted on blur.",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("99.1111", {
message: "The new value should be rounded properly.",
});
});
test("with currency digits != 2 - monetary field", async () => {
serverState.currencies = [
{ id: 1, name: "USD", symbol: "$", position: "before" },
{ id: 2, name: "EUR", symbol: "€", position: "after" },
{
id: 3,
name: "VEF",
symbol: "Bs.F",
position: "after",
digits: [0, 4],
},
];
Partner._records = [
{
id: 1,
float_field: 99.1234,
monetary_field: 99.1234,
currency_id: 3,
},
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="monetary_field"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("99.1234", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(1)").toHaveText("Bs.F", {
message: "The input should be superposed with a span containing the currency symbol.",
});
await contains(".o_field_widget input").edit("99.111111111");
expect(".o_field_widget input").toHaveValue("99.1111", {
message: "The value should should be formatted on blur.",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("99.1111", {
message: "The new value should be rounded properly.",
});
});
test("basic flow in editable list view - float field", async () => {
Partner._records = [
{
id: 1,
float_field: 9.1,
monetary_field: 9.1,
currency_id: 1,
},
{
id: 2,
float_field: 15.3,
monetary_field: 15.3,
currency_id: 2,
},
{
id: 3,
float_field: 0,
monetary_field: 0,
currency_id: 1,
},
{
id: 4,
float_field: 5.0,
monetary_field: 5.0,
},
];
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="bottom">
<field name="float_field" widget="monetary"/>
<field name="currency_id" column_invisible="1"/>
</list>`,
});
const dollarValues = queryAll("td:contains($)");
expect(dollarValues).toHaveLength(2, { message: "Only 2 line has dollar as a currency." });
const euroValues = queryAll("td:contains(€)");
expect(euroValues).toHaveLength(1, { message: "Only 1 line has euro as a currency." });
const noCurrencyValues = queryAll("td.o_data_cell").filter(
(x) => !(x.textContent.includes("€") || x.textContent.includes("$"))
);
expect(noCurrencyValues).toHaveLength(1, { message: "Only 1 line has no currency." });
// switch to edit mode
const dollarCell = queryFirst("td.o_field_cell");
await contains(dollarCell).click();
expect(dollarCell.children).toHaveLength(1, {
message: "The cell td should only contain the special div of monetary widget.",
});
expect(".o_field_widget input").toHaveCount(1, {
message: "The view should have 1 input for editable monetary float.",
});
expect(".o_field_widget input").toHaveValue("9.10", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
await contains(".o_field_widget input").edit("108.2458938598598", { confirm: "blur" });
expect(dollarCell).toHaveText("$ 108.25", { message: "The new value should be correct" });
});
test("basic flow in editable list view - monetary field", async () => {
Partner._records = [
{
id: 1,
float_field: 9.1,
monetary_field: 9.1,
currency_id: 1,
},
{
id: 2,
float_field: 15.3,
monetary_field: 15.3,
currency_id: 2,
},
{
id: 3,
float_field: 0,
monetary_field: 0,
currency_id: 1,
},
{
id: 4,
float_field: 5.0,
monetary_field: 5.0,
},
];
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="bottom">
<field name="monetary_field"/>
<field name="currency_id" column_invisible="1"/>
</list>`,
});
const dollarValues = queryAll("td:contains($)");
expect(dollarValues).toHaveLength(2, { message: "Only 2 line has dollar as a currency." });
const euroValues = queryAll("td:contains(€)");
expect(euroValues).toHaveLength(1, { message: "Only 1 line has euro as a currency." });
const noCurrencyValues = queryAll("td.o_data_cell").filter(
(x) => !(x.textContent.includes("€") || x.textContent.includes("$"))
);
expect(noCurrencyValues).toHaveLength(1, { message: "Only 1 line has no currency." });
// switch to edit mode
const dollarCell = queryFirst("td.o_field_cell");
await contains(dollarCell).click();
expect(dollarCell.children).toHaveLength(1, {
message: "The cell td should only contain the special div of monetary widget.",
});
expect(".o_field_widget input").toHaveCount(1, {
message: "The view should have 1 input for editable monetary float.",
});
expect(".o_field_widget input").toHaveValue("9.10", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
await contains(".o_field_widget input").edit("108.2458938598598", { confirm: "blur" });
expect(dollarCell).toHaveText("$ 108.25", { message: "The new value should be correct" });
});
test.tags("desktop");
test("changing currency updates the field - float field", async () => {
Partner._records = [
{
id: 1,
float_field: 4.2,
monetary_field: 4.2,
currency_id: 1,
},
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="float_field" widget="monetary"/>
<field name="currency_id"/>
</form>`,
});
await contains(".o_field_many2one_selection input").click();
await contains(".o-autocomplete--dropdown-item:contains(EUR)").click();
expect(".o_field_widget .o_input span:eq(1)").toHaveText("€", {
message:
"The input should be preceded by a span containing the currency symbol added on blur.",
});
expect(".o_field_monetary input").toHaveValue("4.20");
await clickSave();
expect(".o_field_monetary input").toHaveValue("4.20", {
message: "The new value should still be correct.",
});
});
test.tags("desktop");
test("changing currency updates the field - monetary field", async () => {
Partner._records = [
{
id: 1,
float_field: 4.2,
monetary_field: 4.2,
currency_id: 1,
},
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="monetary_field"/>
<field name="currency_id"/>
</form>`,
});
await contains(".o_field_many2one_selection input").click();
await contains(".o-autocomplete--dropdown-item:contains(EUR)").click();
expect(".o_field_widget .o_input span:eq(1)").toHaveText("€", {
message:
"The input should be preceded by a span containing the currency symbol added on blur.",
});
expect(".o_field_monetary input").toHaveValue("4.20");
await clickSave();
expect(".o_field_monetary input").toHaveValue("4.20", {
message: "The new value should still be correct.",
});
});
test("MonetaryField with monetary field given in options", async () => {
Partner._fields.company_currency_id = fields.Many2one({
string: "Company Currency",
relation: "res.currency",
});
Partner._records[4].company_currency_id = 2;
await mountView({
type: "form",
resModel: "partner",
arch: `
<form edit="0">
<sheet>
<field name="monetary_field" options="{'currency_field': 'company_currency_id'}"/>
<field name="company_currency_id"/>
</sheet>
</form>`,
resId: 5,
});
expect(".o_field_monetary").toHaveText("9.10 €", {
message: "field monetary should be formatted with correct currency",
});
});
test("should keep the focus when being edited in x2many lists", async () => {
Partner._fields.currency_id.default = 1;
Partner._fields.m2m = fields.Many2many({
relation: "partner",
default: [[4, 2]],
});
Partner._views = {
list: `
<list editable="bottom">
<field name="float_field" widget="monetary"/>
<field name="currency_id" invisible="1"/>
</list>`,
};
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<field name="p"/>
<field name="m2m"/>
</sheet>
</form>`,
});
// test the monetary field inside the one2many
await contains(".o_field_x2many_list_row_add a").click();
await contains(".o_field_widget[name=float_field] input").edit("22", { confirm: "blur" });
expect(".o_field_widget[name=p] .o_field_widget[name=float_field] span").toHaveInnerHTML(
"$&nbsp;22.00",
{ type: "html" }
);
// test the monetary field inside the many2many
await contains(".o_field_widget[name=m2m] .o_data_cell").click();
await contains(".o_field_widget[name=float_field] input").edit("22", { confirm: "blur" });
expect(".o_field_widget[name=m2m] .o_field_widget[name=float_field] span").toHaveInnerHTML(
"22.00&nbsp;€",
{ type: "html" }
);
});
test("MonetaryField with currency set by an onchange", async () => {
// this test ensures that the monetary field can be re-rendered with and
// without currency (which can happen as the currency can be set by an
// onchange)
Partner._onChanges = {
int_field: function (obj) {
obj.currency_id = obj.int_field ? 2 : null;
},
};
await mountView({
type: "list",
resModel: "partner",
arch: `
<list editable="top">
<field name="int_field"/>
<field name="float_field" widget="monetary"/>
<field name="currency_id" invisible="1"/>
</list>`,
});
await contains(".o_control_panel_main_buttons .o_list_button_add").click();
expect(".o_selected_row .o_field_widget[name=float_field] input").toHaveCount(1, {
message: "monetary field should have been rendered correctly (without currency)",
});
expect(".o_selected_row .o_field_widget[name=float_field] span").toHaveCount(2, {
message: "monetary field should have been rendered correctly (without currency)",
});
// set a value for int_field -> should set the currency and re-render float_field
await contains(".o_field_widget[name=int_field] input").edit("7", { confirm: "blur" });
await contains(".o_field_cell[name=int_field]").click();
expect(".o_selected_row .o_field_widget[name=float_field] input").toHaveCount(1, {
message: "monetary field should have been re-rendered correctly (with currency)",
});
expect(
queryAllTexts(".o_selected_row .o_field_widget[name=float_field] .o_input span")
).toEqual(["0.00", "€"], {
message: "monetary field should have been re-rendered correctly (with currency)",
});
await contains(".o_field_widget[name=float_field] input").click();
expect(".o_field_widget[name=float_field] input").toBeFocused({
message: "focus should be on the float_field field's input",
});
// unset the value of int_field -> should unset the currency and re-render float_field
await contains(".o_field_widget[name=int_field]").click();
await contains(".o_field_widget[name=int_field] input").edit("0", { confirm: "blur" });
await contains(".o_field_cell[name=int_field]").click();
expect(".o_selected_row .o_field_widget[name=float_field] input").toHaveCount(1, {
message: "monetary field should have been re-rendered correctly (without currency)",
});
expect(".o_selected_row .o_field_widget[name=float_field] span").toHaveCount(2, {
message: "monetary field should have been re-rendered correctly (without currency)",
});
await contains(".o_field_widget[name=float_field] input").click();
expect(".o_field_widget[name=float_field] input").toBeFocused({
message: "focus should be on the float_field field's input",
});
});
test("float widget on monetary field", async () => {
Partner._fields.monetary = fields.Monetary({ currency_field: "currency_id" });
Partner._records[0].monetary = 9.99;
Partner._records[0].currency_id = 1;
await mountView({
type: "form",
resModel: "partner",
arch: `
<form edit="0">
<sheet>
<field name="monetary" widget="float"/>
<field name="currency_id" invisible="1"/>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_widget[name=monetary]").toHaveText("9.99", {
message: "value should be correctly formatted (with the float formatter)",
});
});
test("float field with monetary widget and decimal precision", async () => {
Partner._records = [
{
id: 1,
float_field: -8.89859,
currency_id: 1,
},
];
serverState.currencies = [
{ id: 1, name: "USD", symbol: "$", position: "before", digits: [0, 4] },
{ id: 2, name: "EUR", symbol: "€", position: "after" },
];
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<field name="float_field" widget="monetary" options="{'field_digits': True}"/>
<field name="currency_id" invisible="1"/>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_widget[name=float_field] input").toHaveValue("-8.9", {
message: "The input should be rendered without the currency symbol.",
});
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
await contains(".o_field_monetary input").edit("109.2458938598598");
expect(".o_field_widget[name=float_field] input").toHaveValue("109.2", {
message: "The value should should be formatted on blur.",
});
await clickSave();
expect(".o_field_widget input").toHaveValue("109.2", {
message: "The new value should be rounded properly.",
});
});
test("MonetaryField without currency symbol", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `
<form>
<sheet>
<field name="float_field" widget="monetary" options="{'no_symbol': True}" />
<field name="currency_id" invisible="1" />
</sheet>
</form>`,
});
// Non-breaking space between the currency and the amount
expect(".o_field_widget[name=float_field] input").toHaveValue("9.10", {
message: "The currency symbol is not displayed",
});
});
test("monetary field with placeholder", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" widget="monetary" placeholder="Placeholder"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
await contains(".o_field_widget[name='float_field'] input").clear();
expect(".o_field_widget[name='float_field'] input").toHaveAttribute(
"placeholder",
"Placeholder"
);
});
test("required monetary field with zero value", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="monetary_field" required="1"/>
</form>`,
});
expect(".o_form_editable").toHaveCount(1);
expect("[name=monetary_field] input").toHaveValue("0.00");
});
test("uses 'currency_id' as currency field by default", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="monetary_field"/>
<field name="currency_id" invisible="1"/>
</form>`,
resId: 6,
});
expect(".o_form_editable").toHaveCount(1);
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
});
test("automatically uses currency_field if defined", async () => {
Partner._fields.custom_currency_id = fields.Many2one({
string: "Currency",
relation: "res.currency",
});
Partner._fields.monetary_field.currency_field = "custom_currency_id";
Partner._records[5].custom_currency_id = 1;
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="monetary_field"/>
<field name="custom_currency_id" invisible="1"/>
</form>`,
resId: 6,
});
expect(".o_form_editable").toHaveCount(1);
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$", {
message: "The input should be preceded by a span containing the currency symbol.",
});
});
test("monetary field with pending onchange", async () => {
const def = new Deferred();
Partner._onChanges = {
async name(record) {
record.float_field = 132;
},
};
onRpc("onchange", async () => {
await def;
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" widget="monetary"/>
<field name="name"/>
<field name="currency_id" invisible="1"/>
</form>`,
resId: 1,
});
await contains(".o_field_widget[name='name'] input").edit("test", { confirm: "blur" });
await contains(".o_field_widget[name='float_field'] input").edit("1", { confirm: false });
def.resolve();
await animationFrame();
expect(".o_field_monetary .o_monetary_ghost_value").toHaveText("1");
});

View file

@ -0,0 +1,293 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { click, keyDown, pointerDown, queryAll, queryFirst } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
defineModels,
defineParams,
fields,
models,
mountView,
mountWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Component, useState, xml } from "@odoo/owl";
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
class Partner extends models.Model {
int_field = fields.Integer();
qux = fields.Float({ digits: [16, 1] });
currency_id = fields.Many2one({ relation: "currency" });
float_factor_field = fields.Float();
percentage = fields.Float();
monetary = fields.Monetary({ currency_field: "" });
progressbar = fields.Integer();
_records = [
{
id: 1,
int_field: 10,
qux: 0.44444,
float_factor_field: 9.99,
percentage: 0.99,
monetary: 9.99,
currency_id: 1,
progressbar: 69,
},
];
}
class Currency extends models.Model {
digits = fields.Float();
symbol = fields.Char();
position = fields.Char();
_records = [{ id: 1, display_name: "$", symbol: "$", position: "before" }];
}
defineModels([Partner, Currency]);
beforeEach(() => {
defineParams({ lang_parameters: { decimal_point: ",", thousands_sep: "." } });
});
test("Numeric fields: fields with keydown on numpad decimal key", async () => {
defineParams({ lang_parameters: { decimal_point: "🇧🇪" } });
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="float_factor_field" options="{'factor': 0.5}" widget="float_factor"/>
<field name="qux"/>
<field name="int_field"/>
<field name="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="percentage" widget="percentage"/>
<field name="progressbar" widget="progressbar" options="{'editable': true, 'max_value': 'qux', 'edit_max_value': true}"/>
</form>
`,
resId: 1,
});
// Dispatch numpad "dot" and numpad "comma" keydown events to all inputs and check
// Numpad "comma" is specific to some countries (Brazil...)
await click(".o_field_float_factor input");
await keyDown("ArrowRight", { code: "ArrowRight" });
await keyDown(".", { code: "NumpadDecimal" });
await keyDown(",", { code: "NumpadDecimal" });
await animationFrame();
expect(".o_field_float_factor input").toHaveValue("5🇧🇪00🇧🇪🇧🇪");
await click(".o_field_float input");
await keyDown("ArrowRight", { code: "ArrowRight" });
await keyDown(".", { code: "NumpadDecimal" });
await keyDown(",", { code: "NumpadDecimal" });
await animationFrame();
expect(".o_field_float input").toHaveValue("0🇧🇪4🇧🇪🇧🇪");
await click(".o_field_integer input");
await keyDown("ArrowRight", { code: "ArrowRight" });
await keyDown(".", { code: "NumpadDecimal" });
await keyDown(",", { code: "NumpadDecimal" });
await animationFrame();
expect(".o_field_integer input").toHaveValue("10🇧🇪🇧🇪");
await click(".o_field_monetary input");
await keyDown("ArrowRight", { code: "ArrowRight" });
await keyDown(".", { code: "NumpadDecimal" });
await keyDown(",", { code: "NumpadDecimal" });
await animationFrame();
expect(".o_field_monetary input").toHaveValue("9🇧🇪99🇧🇪🇧🇪");
await click(".o_field_percentage input");
await keyDown("ArrowRight", { code: "ArrowRight" });
await keyDown(".", { code: "NumpadDecimal" });
await keyDown(",", { code: "NumpadDecimal" });
await animationFrame();
expect(".o_field_percentage input").toHaveValue("99🇧🇪🇧🇪");
await click(".o_field_progressbar input");
await animationFrame();
await keyDown("ArrowRight", { code: "ArrowRight" });
await keyDown(".", { code: "NumpadDecimal" });
await keyDown(",", { code: "NumpadDecimal" });
await animationFrame();
expect(".o_field_progressbar input").toHaveValue("0🇧🇪44🇧🇪🇧🇪");
});
test("Numeric fields: NumpadDecimal key is different from the decimalPoint", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="float_factor_field" options="{'factor': 0.5}" widget="float_factor"/>
<field name="qux"/>
<field name="int_field"/>
<field name="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="percentage" widget="percentage"/>
<field name="progressbar" widget="progressbar" options="{'editable': true, 'max_value': 'qux', 'edit_max_value': true}"/>
</form>
`,
resId: 1,
});
// Get all inputs
const floatFactorField = queryFirst(".o_field_float_factor input");
const floatInput = queryFirst(".o_field_float input");
const integerInput = queryFirst(".o_field_integer input");
const monetaryInput = queryFirst(".o_field_monetary input");
const percentageInput = queryFirst(".o_field_percentage input");
const progressbarInput = queryFirst(".o_field_progressbar input");
/**
* Common assertion steps are extracted in this procedure.
*
* @param {object} params
* @param {HTMLInputElement} params.el
* @param {[number, number]} params.selectionRange
* @param {string} params.expectedValue
* @param {string} params.msg
*/
async function testInputElementOnNumpadDecimal(params) {
const { el, selectionRange, expectedValue, msg } = params;
await pointerDown(el);
await animationFrame();
el.setSelectionRange(...selectionRange);
const [event] = await keyDown(".", { code: "NumpadDecimal" });
if (event.defaultPrevented) {
expect.step("preventDefault");
}
await animationFrame();
// dispatch an extra keydown event and expect that it's not default prevented
const [extraEvent] = await keyDown("1", { code: "Digit1" });
if (extraEvent.defaultPrevented) {
throw new Error("should not be default prevented");
}
await animationFrame();
// Selection range should be at +2 from the specified selection start (separator + character).
expect(el.selectionStart).toBe(selectionRange[0] + 2);
expect(el.selectionEnd).toBe(selectionRange[0] + 2);
await animationFrame();
// NumpadDecimal event should be default prevented
expect.verifySteps(["preventDefault"]);
expect(el).toHaveValue(expectedValue, { message: msg });
}
await testInputElementOnNumpadDecimal({
el: floatFactorField,
selectionRange: [1, 3],
expectedValue: "5,10",
msg: "Float factor field from 5,00 to 5,10",
});
await testInputElementOnNumpadDecimal({
el: floatInput,
selectionRange: [0, 2],
expectedValue: ",14",
msg: "Float field from 0,4 to ,14",
});
await testInputElementOnNumpadDecimal({
el: integerInput,
selectionRange: [1, 2],
expectedValue: "1,1",
msg: "Integer field from 10 to 1,1",
});
await testInputElementOnNumpadDecimal({
el: monetaryInput,
selectionRange: [0, 3],
expectedValue: ",19",
msg: "Monetary field from 9,99 to ,19",
});
await testInputElementOnNumpadDecimal({
el: percentageInput,
selectionRange: [1, 1],
expectedValue: "9,19",
msg: "Percentage field from 99 to 9,19",
});
await testInputElementOnNumpadDecimal({
el: progressbarInput,
selectionRange: [1, 3],
expectedValue: "0,14",
msg: "Progressbar field 2 from 0,44 to 0,14",
});
});
test("useNumpadDecimal should synchronize handlers on input elements", async () => {
/**
* Takes an array of input elements and asserts that each has the correct event listener.
* @param {HTMLInputElement[]} inputEls
*/
async function testInputElements(inputEls) {
for (const inputEl of inputEls) {
await pointerDown(inputEl);
await animationFrame();
const [event] = await keyDown(".", { code: "NumpadDecimal" });
if (event.defaultPrevented) {
expect.step("preventDefault");
}
await animationFrame();
// dispatch an extra keydown event and expect that it's not default prevented
const [extraEvent] = await keyDown("1", { code: "Digit1" });
if (extraEvent.defaultPrevented) {
throw new Error("should not be default prevented");
}
await animationFrame();
expect.verifySteps(["preventDefault"]);
}
}
class MyComponent extends Component {
static template = xml`
<main t-ref="numpadDecimal">
<input type="text" placeholder="input 1" />
<input t-if="state.showOtherInput" type="text" placeholder="input 2" />
</main>
`;
static props = ["*"];
setup() {
useNumpadDecimal();
this.state = useState({ showOtherInput: false });
}
}
const comp = await mountWithCleanup(MyComponent);
await animationFrame();
// Initially, only one input should be rendered.
expect("main > input").toHaveCount(1);
await testInputElements(queryAll("main > input"));
// We show the second input by manually updating the state.
comp.state.showOtherInput = true;
await animationFrame();
// The second input should also be able to handle numpad decimal.
expect("main > input").toHaveCount(2);
await testInputElements(queryAll("main > input"));
});
test("select all content on focus", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="monetary"/></form>`,
});
const input = queryFirst(".o_field_widget[name='monetary'] input");
await pointerDown(input);
await animationFrame();
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(4);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,177 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { localization } from "@web/core/l10n/localization";
import { nbsp } from "@web/core/utils/strings";
import {
parseFloat,
parseFloatTime,
parseInteger,
parseMonetary,
parsePercentage,
} from "@web/views/fields/parsers";
beforeEach(makeMockEnv);
test("parseFloat", () => {
expect(parseFloat("")).toBe(0);
expect(parseFloat("0")).toBe(0);
expect(parseFloat("100.00")).toBe(100);
expect(parseFloat("-100.00")).toBe(-100);
expect(parseFloat("1,000.00")).toBe(1000);
expect(parseFloat("1,000,000.00")).toBe(1000000);
expect(parseFloat("1,234.567")).toBe(1234.567);
expect(() => parseFloat("1.000.000")).toThrow();
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." });
expect(parseFloat("1.234,567")).toBe(1234.567);
// Can evaluate expression from locale with decimal point different from ".".
expect(parseFloat("=1.000,1 + 2.000,2")).toBe(3000.3);
expect(parseFloat("=1.000,00 + 11.121,00")).toBe(12121);
expect(parseFloat("=1000,00 + 11122,00")).toBe(12122);
expect(parseFloat("=1000 + 11123")).toBe(12123);
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: false });
expect(parseFloat("1234,567")).toBe(1234.567);
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: nbsp });
expect(parseFloat("9 876,543")).toBe(9876.543);
expect(parseFloat("1 234 567,89")).toBe(1234567.89);
expect(parseFloat(`98${nbsp}765 432,1`)).toBe(98765432.1);
});
test("parseFloatTime", () => {
expect(parseFloatTime("0")).toBe(0);
expect(parseFloatTime("100")).toBe(100);
expect(parseFloatTime("100.00")).toBe(100);
expect(parseFloatTime("7:15")).toBe(7.25);
expect(parseFloatTime("-4:30")).toBe(-4.5);
expect(parseFloatTime(":")).toBe(0);
expect(parseFloatTime("1:")).toBe(1);
expect(parseFloatTime(":12")).toBe(0.2);
expect(() => parseFloatTime("a:1")).toThrow();
expect(() => parseFloatTime("1:a")).toThrow();
expect(() => parseFloatTime("1:1:")).toThrow();
expect(() => parseFloatTime(":1:1")).toThrow();
});
test("parseInteger", () => {
expect(parseInteger("")).toBe(0);
expect(parseInteger("0")).toBe(0);
expect(parseInteger("100")).toBe(100);
expect(parseInteger("-100")).toBe(-100);
expect(parseInteger("1,000")).toBe(1000);
expect(parseInteger("1,000,000")).toBe(1000000);
expect(parseInteger("-2,147,483,648")).toBe(-2147483648);
expect(parseInteger("2,147,483,647")).toBe(2147483647);
expect(() => parseInteger("1.000.000")).toThrow();
expect(() => parseInteger("1,234.567")).toThrow();
expect(() => parseInteger("-2,147,483,649")).toThrow();
expect(() => parseInteger("2,147,483,648")).toThrow();
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." });
expect(parseInteger("1.000.000")).toBe(1000000);
expect(() => parseInteger("1.234,567")).toThrow();
// fallback to en localization
expect(parseInteger("1,000,000")).toBe(1000000);
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: false });
expect(parseInteger("1000000")).toBe(1000000);
});
test("parsePercentage", () => {
expect(parsePercentage("")).toBe(0);
expect(parsePercentage("0")).toBe(0);
expect(parsePercentage("0.5")).toBe(0.005);
expect(parsePercentage("1")).toBe(0.01);
expect(parsePercentage("100")).toBe(1);
expect(parsePercentage("50%")).toBe(0.5);
expect(() => parsePercentage("50%40")).toThrow();
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." });
expect(parsePercentage("1.234,56")).toBe(12.3456);
expect(parsePercentage("6,02")).toBe(0.0602);
});
test("parsers fallback on english localisation", () => {
patchWithCleanup(localization, {
decimalPoint: ",",
thousandsSep: ".",
});
expect(parseInteger("1,000,000")).toBe(1000000);
expect(parseFloat("1,000,000.50")).toBe(1000000.5);
});
test("parseMonetary", () => {
expect(parseMonetary("")).toBe(0);
expect(parseMonetary("0")).toBe(0);
expect(parseMonetary("100.00\u00a0€")).toBe(100);
expect(parseMonetary("-100.00")).toBe(-100);
expect(parseMonetary("1,000.00")).toBe(1000);
expect(parseMonetary(".1")).toBe(0.1);
expect(parseMonetary("1,000,000.00")).toBe(1000000);
expect(parseMonetary("$\u00a0125.00")).toBe(125);
expect(parseMonetary("1,000.00\u00a0€")).toBe(1000);
expect(parseMonetary("\u00a0")).toBe(0);
expect(parseMonetary("1\u00a0")).toBe(1);
expect(parseMonetary("\u00a01")).toBe(1);
expect(parseMonetary("12.00 €")).toBe(12);
expect(parseMonetary("$ 12.00")).toBe(12);
expect(parseMonetary("1\u00a0$")).toBe(1);
expect(parseMonetary("$\u00a01")).toBe(1);
expect(() => parseMonetary("1$\u00a01")).toThrow();
expect(() => parseMonetary("$\u00a012.00\u00a034")).toThrow();
// nbsp as thousands separator
patchWithCleanup(localization, { thousandsSep: "\u00a0", decimalPoint: "," });
expect(parseMonetary("1\u00a0000,06\u00a0€")).toBe(1000.06);
expect(parseMonetary("$\u00a01\u00a0000,07")).toBe(1000.07);
expect(parseMonetary("1000000,08")).toBe(1000000.08);
expect(parseMonetary("$ -1\u00a0000,09")).toBe(-1000.09);
// symbol not separated from the value
expect(parseMonetary("1\u00a0000,08€")).toBe(1000.08);
expect(parseMonetary("€1\u00a0000,09")).toBe(1000.09);
expect(parseMonetary("$1\u00a0000,10")).toBe(1000.1);
expect(parseMonetary("$-1\u00a0000,11")).toBe(-1000.11);
// any symbol
expect(parseMonetary("1\u00a0000,11EUROS")).toBe(1000.11);
expect(parseMonetary("EUR1\u00a0000,12")).toBe(1000.12);
expect(parseMonetary("DOL1\u00a0000,13")).toBe(1000.13);
expect(parseMonetary("1\u00a0000,14DOLLARS")).toBe(1000.14);
expect(parseMonetary("DOLLARS+1\u00a0000,15")).toBe(1000.15);
expect(parseMonetary("EURO-1\u00a0000,16DOGE")).toBe(-1000.16);
// comma as decimal point and dot as thousands separator
patchWithCleanup(localization, { thousandsSep: ".", decimalPoint: "," });
expect(parseMonetary("10,08")).toBe(10.08);
expect(parseMonetary("")).toBe(0);
expect(parseMonetary("0")).toBe(0);
expect(parseMonetary("100,12\u00a0€")).toBe(100.12);
expect(parseMonetary("-100,12")).toBe(-100.12);
expect(parseMonetary("1.000,12")).toBe(1000.12);
expect(parseMonetary(",1")).toBe(0.1);
expect(parseMonetary("1.000.000,12")).toBe(1000000.12);
expect(parseMonetary("$\u00a0125,12")).toBe(125.12);
expect(parseMonetary("1.000,00\u00a0€")).toBe(1000);
expect(parseMonetary(",")).toBe(0);
expect(parseMonetary("1\u00a0")).toBe(1);
expect(parseMonetary("\u00a01")).toBe(1);
expect(parseMonetary("12,34 €")).toBe(12.34);
expect(parseMonetary("$ 12,34")).toBe(12.34);
// Can evaluate expression
expect(parseMonetary("=1.000,1 + 2.000,2")).toBe(3000.3);
expect(parseMonetary("=1.000,00 + 11.121,00")).toBe(12121);
expect(parseMonetary("=1000,00 + 11122,00")).toBe(12122);
expect(parseMonetary("=1000 + 11123")).toBe(12123);
});

View file

@ -0,0 +1,78 @@
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { test, expect } from "@odoo/hoot";
import { click, setInputFiles, queryOne, waitFor } from "@odoo/hoot-dom";
const getIframeSrc = () => queryOne(".o_field_widget iframe.o_pdfview_iframe").dataset.src;
const getIframeProtocol = () => getIframeSrc().match(/\?file=(\w+)%3A/)[1];
const getIframeViewerParams = () =>
decodeURIComponent(getIframeSrc().match(/%2Fweb%2Fcontent%3F(.*)#page/)[1]);
class Partner extends models.Model {
document = fields.Binary({ string: "Binary" });
_records = [
{
document: "coucou==\n",
},
];
}
defineModels([Partner]);
test("PdfViewerField without data", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
});
expect(".o_field_widget").toHaveClass("o_field_pdf_viewer");
expect(".o_select_file_button:not(.o_hidden)").toHaveCount(1);
expect(".o_pdfview_iframe").toHaveCount(0);
expect(`input[type="file"]`).toHaveCount(1);
});
test("PdfViewerField: basic rendering", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
});
expect(".o_field_widget").toHaveClass("o_field_pdf_viewer");
expect(".o_select_file_button").toHaveCount(1);
expect(".o_field_widget iframe.o_pdfview_iframe").toHaveCount(1);
expect(getIframeProtocol()).toBe("https");
expect(getIframeViewerParams()).toBe("model=partner&field=document&id=1");
});
test("PdfViewerField: upload rendering", async () => {
expect.assertions(4);
onRpc("web_save", ({ args }) => {
expect(args[1]).toEqual({ document: btoa("test") });
});
await mountView({
type: "form",
resModel: "partner",
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
});
expect("iframe.o_pdfview_iframe").toHaveCount(0);
const file = new File(["test"], "test.pdf", { type: "application/pdf" });
await click(".o_field_pdf_viewer input[type=file]");
await setInputFiles(file);
await waitFor("iframe.o_pdfview_iframe");
expect(getIframeProtocol()).toBe("blob");
await clickSave();
expect(getIframeProtocol()).toBe("blob");
});

View file

@ -0,0 +1,151 @@
import { defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
import { test, expect } from "@odoo/hoot";
import { queryOne } from "@odoo/hoot-dom";
class Partner extends models.Model {
foo = fields.Char({
string: "Foo",
default: "My little Foo Value",
trim: true,
});
int_field = fields.Integer();
float_field = fields.Float();
_records = [
{ id: 1, foo: "yop", int_field: 10 },
{ id: 2, foo: "gnap", int_field: 80 },
{ id: 3, foo: "blip", float_field: 33.3333 },
];
}
defineModels([Partner]);
test("PercentPieField in form view with value < 50%", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="int_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_percent_pie.o_field_widget .o_pie").toHaveCount(1);
expect(".o_field_percent_pie.o_field_widget .o_pie_info .o_pie_value").toHaveText("10%", {
message: "should have 10% as pie value since int_field=10",
});
expect(
queryOne(".o_field_percent_pie.o_field_widget .o_pie").style.background.replaceAll(
/\s+/g,
" "
)
).toBe(
"conic-gradient( var(--PercentPieField-color-active) 0% 10%, var(--PercentPieField-color-static) 0% 100% )",
{ message: "pie should have a background computed for its value of 10%" }
);
});
test("PercentPieField in form view with value > 50%", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="int_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 2,
});
expect(".o_field_percent_pie.o_field_widget .o_pie").toHaveCount(1);
expect(".o_field_percent_pie.o_field_widget .o_pie_info .o_pie_value").toHaveText("80%", {
message: "should have 80% as pie value since int_field=80",
});
expect(
queryOne(".o_field_percent_pie.o_field_widget .o_pie").style.background.replaceAll(
/\s+/g,
" "
)
).toBe(
"conic-gradient( var(--PercentPieField-color-active) 0% 80%, var(--PercentPieField-color-static) 0% 100% )",
{ message: "pie should have a background computed for its value of 80%" }
);
});
test("PercentPieField in form view with float value", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="float_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 3,
});
expect(".o_field_percent_pie.o_field_widget .o_pie").toHaveCount(1);
expect(".o_field_percent_pie.o_field_widget .o_pie_info .o_pie_value").toHaveText("33.33%", {
message:
"should have 33.33% as pie value since float_field=33.3333 and its value is rounded to 2 decimals",
});
expect(
queryOne(".o_field_percent_pie.o_field_widget .o_pie").style.background.replaceAll(
/\s+/g,
" "
)
).toBe(
"conic-gradient( var(--PercentPieField-color-active) 0% 33.3333%, var(--PercentPieField-color-static) 0% 100% )",
{ message: "pie should have a background computed for its value of 33.3333%" }
);
});
test("hide the string when the PercentPieField widget is used in the view", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="int_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_percent_pie.o_field_widget .o_pie").toHaveCount(1);
expect(".o_field_percent_pie.o_field_widget .o_pie_info .o_pie_text").not.toBeVisible();
});
test.tags("desktop");
test("show the string when the PercentPieField widget is used in a button with the class oe_stat_button", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<div name="button_box" class="oe_button_box">
<button type="object" class="oe_stat_button">
<field name="int_field" widget="percentpie"/>
</button>
</div>
</form>`,
resId: 1,
});
expect(".o_field_percent_pie.o_field_widget .o_pie").toHaveCount(1);
expect(".o_field_percent_pie.o_field_widget .o_pie_info .o_pie_text").toBeVisible();
});

View file

@ -0,0 +1,81 @@
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { expect, test } from "@odoo/hoot";
import { clear, click, edit } from "@odoo/hoot-dom";
class Partner extends models.Model {
float_field = fields.Float({
string: "Float_field",
digits: [0, 1],
});
_records = [{ float_field: 0.44444 }];
}
defineModels([Partner]);
test("PercentageField in form view", async () => {
expect.assertions(5);
onRpc("web_save", ({ args }) => {
expect(args[1].float_field).toBe(0.24);
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="float_field" widget="percentage"/></form>`,
resId: 1,
});
expect(".o_field_widget[name=float_field] input").toHaveValue("44.4");
expect(".o_field_widget[name=float_field] span").toHaveText("%", {
message: "The input should be followed by a span containing the percentage symbol.",
});
await click("[name='float_field'] input");
await edit("24");
expect("[name='float_field'] input").toHaveValue("24");
await clickSave();
expect(".o_field_widget input").toHaveValue("24");
});
test("percentage field with placeholder", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="float_field" widget="percentage" placeholder="Placeholder"/></form>`,
});
await click(".o_field_widget[name='float_field'] input");
await clear();
expect(".o_field_widget[name='float_field'] input").toHaveProperty(
"placeholder",
"Placeholder"
);
expect(".o_field_widget[name='float_field'] input").toHaveAttribute(
"placeholder",
"Placeholder"
);
});
test("PercentageField in form view without rounding error", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="float_field" widget="percentage"/></form>`,
});
await click("[name='float_field'] input");
await edit("28");
expect("[name='float_field'] input").toHaveValue("28");
});

View file

@ -0,0 +1,206 @@
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { expect, test } from "@odoo/hoot";
import { click, edit, pointerDown, queryFirst, queryOne } from "@odoo/hoot-dom";
import { getNextTabableElement } from "@web/core/utils/ui";
import { animationFrame } from "@odoo/hoot-mock";
class Partner extends models.Model {
foo = fields.Char({ default: "My little Foo Value", trim: true });
name = fields.Char();
_records = [{ foo: "yop" }, { foo: "blip" }];
}
defineModels([Partner]);
test("PhoneField in form view on normal screens (readonly)", async () => {
await mountView({
type: "form",
resModel: "partner",
mode: "readonly",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="foo" widget="phone"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_phone a").toHaveCount(1);
expect(".o_field_phone a").toHaveText("yop");
expect(".o_field_phone a").toHaveAttribute("href", "tel:yop");
});
test("PhoneField in form view on normal screens (edit)", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="foo" widget="phone"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(`input[type="tel"]`).toHaveCount(1);
expect(`input[type="tel"]`).toHaveValue("yop");
expect(".o_field_phone a").toHaveCount(1);
expect(".o_field_phone a").toHaveText("Call");
expect(".o_field_phone a").toHaveAttribute("href", "tel:yop");
// change value in edit mode
await click(`input[type="tel"]`);
await edit("new");
await animationFrame();
// save
await clickSave();
expect(`input[type="tel"]`).toHaveValue("new");
});
test("PhoneField in editable list view on normal screens", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: '<list editable="bottom"><field name="foo" widget="phone"/></list>',
});
expect("tbody td:not(.o_list_record_selector).o_data_cell").toHaveCount(2);
expect("tbody td:not(.o_list_record_selector) a:first").toHaveText("yop");
expect(".o_field_widget a.o_form_uri").toHaveCount(2);
// Edit a line and check the result
const cell = queryFirst("tbody td:not(.o_list_record_selector)");
await click(cell);
await animationFrame();
expect(cell.parentElement).toHaveClass("o_selected_row");
expect(`tbody td:not(.o_list_record_selector) input`).toHaveValue("yop");
await click(`tbody td:not(.o_list_record_selector) input`);
await edit("new");
await animationFrame();
await click(".o_control_panel_main_buttons .o_list_button_save");
await animationFrame();
expect(".o_selected_row").toHaveCount(0);
expect("tbody td:not(.o_list_record_selector) a:first").toHaveText("new");
expect(".o_field_widget a.o_form_uri").toHaveCount(2);
});
test("use TAB to navigate to a PhoneField", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="name"/>
<field name="foo" widget="phone"/>
</group>
</sheet>
</form>`,
});
await pointerDown(".o_field_widget[name=name] input");
expect(".o_field_widget[name=name] input").toBeFocused();
expect(queryOne`[name="foo"] input:only`).toBe(getNextTabableElement());
});
test("phone field with placeholder", async () => {
Partner._fields.foo.default = false;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="foo" widget="phone" placeholder="Placeholder"/>
</group>
</sheet>
</form>`,
});
expect(".o_field_widget[name='foo'] input").toHaveProperty("placeholder", "Placeholder");
});
test("unset and readonly PhoneField", async () => {
Partner._fields.foo.default = false;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="foo" widget="phone" readonly="1" placeholder="Placeholder"/>
</group>
</sheet>
</form>`,
});
expect(".o_field_widget[name='foo'] a").toHaveCount(0);
});
test("href is correctly formatted", async () => {
Partner._records[0].foo = "+12 345 67 89 00";
await mountView({
type: "form",
resModel: "partner",
mode: "readonly",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="foo" widget="phone"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_phone a").toHaveText("+12 345 67 89 00");
expect(".o_field_phone a").toHaveAttribute("href", "tel:+12345678900");
});
test("New record, fill in phone field, then click on call icon and save", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="name" required="1"/>
<field name="foo" widget="phone"/>
</group>
</sheet>
</form>`,
});
await contains(".o_field_widget[name=name] input").edit("TEST");
await contains(".o_field_widget[name=foo] input").edit("+12345678900");
await click(`input[type="tel"]`);
expect(`.o_form_status_indicator_buttons`).not.toHaveClass("invisible");
await clickSave();
expect(".o_field_widget[name=name] input").toHaveValue("TEST");
expect(".o_field_widget[name=foo] input").toHaveValue("+12345678900");
expect(`.o_form_status_indicator_buttons`).toHaveClass("invisible");
});

View file

@ -0,0 +1,453 @@
import { expect, test } from "@odoo/hoot";
import { click, hover, leave, press, queryAll, queryAllTexts } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
foo = fields.Char({ string: "Foo" });
id = fields.Integer({ string: "Sequence" });
selection = fields.Selection({
string: "Selection",
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
});
_records = [
{
id: 1,
foo: "yop",
selection: "blocked",
},
{
id: 2,
foo: "blip",
selection: "normal",
},
{
id: 4,
foo: "abc",
selection: "done",
},
{ id: 3, foo: "gnap" },
{ id: 5, foo: "blop" },
];
}
defineModels([Partner]);
test("PriorityField when not set", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="priority" />
</group>
</sheet>
</form>`,
});
expect(".o_field_widget .o_priority:not(.o_field_empty)").toHaveCount(1, {
message: "widget should be considered set, even though there is no value for this field",
});
expect(".o_field_widget .o_priority a.o_priority_star").toHaveCount(2, {
message:
"should have two stars for representing each possible value: no star, one star and two stars",
});
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(0, {
message: "should have no full star since there is no value",
});
expect(".o_field_widget .o_priority a.o_priority_star.fa-star-o").toHaveCount(2, {
message: "should have two empty stars since there is no value",
});
});
test("PriorityField tooltip", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="priority"/>
</group>
</sheet>
</form>`,
resId: 1,
});
// check data-tooltip attribute (used by the tooltip service)
const stars = queryAll(".o_field_widget .o_priority a.o_priority_star");
expect(stars[0]).toHaveAttribute("data-tooltip", "Selection: Blocked");
expect(stars[1]).toHaveAttribute("data-tooltip", "Selection: Done");
});
test("PriorityField in form view", async () => {
expect.assertions(8);
onRpc("web_save", ({ args }) => {
expect(args).toEqual([[1], { selection: "done" }]);
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="priority" />
</group>
</sheet>
</form>`,
});
expect(".o_field_widget .o_priority:not(.o_field_empty)").toHaveCount(1);
expect(".o_field_widget .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(1);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star-o").toHaveCount(1);
// click on the second star in edit mode
await click(".o_field_widget .o_priority a.o_priority_star.fa-star-o:last");
await animationFrame();
expect(".o_field_widget .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(2);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star-o").toHaveCount(0);
});
test.tags("desktop");
test("PriorityField hover a star in form view", async () => {
expect.assertions(10);
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="priority" />
</group>
</sheet>
</form>`,
});
expect(".o_field_widget .o_priority:not(.o_field_empty)").toHaveCount(1);
expect(".o_field_widget .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(1);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star-o").toHaveCount(1);
// hover last star
const star = ".o_field_widget .o_priority a.o_priority_star.fa-star-o:last";
await hover(star);
await animationFrame();
expect(".o_field_widget .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(2, {
message: "should temporary have two full stars since we are hovering the third value",
});
expect(".o_field_widget .o_priority a.o_priority_star.fa-star-o").toHaveCount(0, {
message: "should temporary have no empty star since we are hovering the third value",
});
await leave(star);
await animationFrame();
expect(".o_field_widget .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(1);
expect(".o_field_widget .o_priority a.o_priority_star.fa-star-o").toHaveCount(1);
});
test("PriorityField can write after adding a record -- kanban", async () => {
Partner._fields.selection = fields.Selection({
string: "Selection",
selection: [
["0", 0],
["1", 1],
],
});
Partner._records[0].selection = "0";
Partner._views[["form", "myquickview"]] = /* xml */ `<form/>`;
onRpc("web_save", ({ args }) => expect.step(`web_save ${JSON.stringify(args)}`));
await mountView({
type: "kanban",
resModel: "partner",
domain: [["id", "=", 1]],
groupBy: ["foo"],
arch: /* xml */ `
<kanban on_create="quick_create" quick_create_view="myquickview">
<templates>
<t t-name="card">
<field name="selection" widget="priority"/>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record .fa-star").toHaveCount(0);
await click(".o_priority a.o_priority_star.fa-star-o");
// wait for web_save
await animationFrame();
expect.verifySteps(['web_save [[1],{"selection":"1"}]']);
expect(".o_kanban_record .fa-star").toHaveCount(1);
await click(".o_control_panel_main_buttons .o-kanban-button-new");
await animationFrame();
await animationFrame();
await click(".o_kanban_quick_create .o_kanban_add");
await animationFrame();
expect.verifySteps(["web_save [[],{}]"]);
await click(".o_priority a.o_priority_star.fa-star-o");
await animationFrame();
expect.verifySteps([`web_save [[6],{"selection":"1"}]`]);
expect(".o_kanban_record .fa-star").toHaveCount(2);
});
test("PriorityField in editable list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list editable="bottom"><field name="selection" widget="priority" /></list>`,
});
expect(".o_data_row:first-child .o_priority:not(.o_field_empty)").toHaveCount(1);
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2, {
message:
"should have two stars for representing each possible value: no star, one star and two stars",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(1, {
message: "should have one full star since the value is the second value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(1, {
message: "should have one empty star since the value is the second value",
});
// switch to edit mode and check the result
await click("tbody td:not(.o_list_record_selector)");
await animationFrame();
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2, {
message:
"should have two stars for representing each possible value: no star, one star and two stars",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(1, {
message: "should have one full star since the value is the second value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(1, {
message: "should have one empty star since the value is the second value",
});
// save
await click(".o_control_panel_main_buttons .o_list_button_save");
await animationFrame();
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2, {
message:
"should have two stars for representing each possible value: no star, one star and two stars",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(1, {
message: "should have one full star since the value is the second value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(1, {
message: "should have one empty star since the value is the second value",
});
// click on the first star in readonly mode
await click(".o_priority a.o_priority_star.fa-star");
await animationFrame();
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2, {
message: "should still have two stars",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(0, {
message: "should now have no full star since the value is the first value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(2, {
message: "should now have two empty stars since the value is the first value",
});
// re-enter edit mode to force re-rendering the widget to check if the value was correctly saved
await click("tbody td:not(.o_list_record_selector)");
await animationFrame();
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2, {
message: "should still have two stars",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(0, {
message: "should now have no full star since the value is the first value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(2, {
message: "should now have two empty stars since the value is the first value",
});
// Click on second star in edit mode
await click(".o_priority a.o_priority_star.fa-star-o:last");
await animationFrame();
expect(".o_data_row:last-child .o_priority a.o_priority_star").toHaveCount(2, {
message: "should still have two stars",
});
expect(".o_data_row:last-child .o_priority a.o_priority_star.fa-star").toHaveCount(2, {
message: "should now have two full stars since the value is the third value",
});
expect(".o_data_row:last-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(0, {
message: "should now have no empty star since the value is the third value",
});
// save
await click(".o_control_panel_main_buttons .o_list_button_save");
await animationFrame();
expect(".o_data_row:last-child .o_priority a.o_priority_star").toHaveCount(2, {
message: "should still have two stars",
});
expect(".o_data_row:last-child .o_priority a.o_priority_star.fa-star").toHaveCount(2, {
message: "should now have two full stars since the value is the third value",
});
expect(".o_data_row:last-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(0, {
message: "should now have no empty star since the value is the third value",
});
});
test.tags("desktop");
test("PriorityField hover in editable list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list editable="bottom"><field name="selection" widget="priority" /></list>`,
});
expect(".o_data_row:first-child .o_priority:not(.o_field_empty)").toHaveCount(1);
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2, {
message:
"should have two stars for representing each possible value: no star, one star and two stars",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(1, {
message: "should have one full star since the value is the second value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(1, {
message: "should have one empty star since the value is the second value",
});
// hover last star
const star = ".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o:last";
await hover(star);
await animationFrame();
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(2, {
message: "should temporary have two full stars since we are hovering the third value",
});
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(0, {
message: "should temporary have no empty star since we are hovering the third value",
});
await leave(star);
await animationFrame();
expect(".o_data_row:first-child .o_priority a.o_priority_star").toHaveCount(2);
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star").toHaveCount(1);
expect(".o_data_row:first-child .o_priority a.o_priority_star.fa-star-o").toHaveCount(1);
});
test("PriorityField with readonly attribute", async () => {
onRpc("write", () => {
expect.step("write");
throw new Error("should not save");
});
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: '<form><field name="selection" widget="priority" readonly="1"/></form>',
});
expect("span.o_priority_star.fa.fa-star-o").toHaveCount(2, {
message: "stars of priority widget should rendered with span tag if readonly",
});
await hover(".o_priority_star.fa-star-o:last");
await animationFrame();
expect.step("hover");
expect(".o_field_widget .o_priority a.o_priority_star.fa-star").toHaveCount(0, {
message: "should have no full stars on hover since the field is readonly",
});
await click(".o_priority_star.fa-star-o:last");
await animationFrame();
expect.step("click");
expect("span.o_priority_star.fa.fa-star-o").toHaveCount(2, {
message: "should still have two stars",
});
expect.verifySteps(["hover", "click"]);
});
test('PriorityField edited by the smart action "Set priority..."', async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="selection" widget="priority"/></form>`,
resId: 1,
});
expect("a.fa-star").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
const idx = queryAllTexts(".o_command").indexOf("Set priority...\nALT + R");
expect(idx).toBeGreaterThan(-1);
await click(queryAll(".o_command")[idx]);
await animationFrame();
expect(queryAllTexts(".o_command")).toEqual(["Normal", "Blocked", "Done"]);
await click("#o_command_2");
await animationFrame();
expect("a.fa-star").toHaveCount(2);
});
test("PriorityField - auto save record when field toggled", async () => {
onRpc("web_save", () => expect.step("web_save"));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="priority" />
</group>
</sheet>
</form>`,
});
await click(".o_field_widget .o_priority a.o_priority_star.fa-star-o:last");
await animationFrame();
expect.verifySteps(["web_save"]);
});
test("PriorityField - prevent auto save with autosave option", async () => {
onRpc("write", () => expect.step("write"));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="priority" options="{'autosave': False}"/>
</group>
</sheet>
</form>`,
});
await click(".o_field_widget .o_priority a.o_priority_star.fa-star-o:last");
await animationFrame();
expect.verifySteps([]);
});

View file

@ -0,0 +1,401 @@
import { expect, test } from "@odoo/hoot";
import { click, edit, queryOne, queryText, queryValue } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
defineParams,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
name = fields.Char({ string: "Display Name" });
int_field = fields.Integer({
string: "int_field",
});
int_field2 = fields.Integer({
string: "int_field",
});
int_field3 = fields.Integer({
string: "int_field",
});
float_field = fields.Float({
string: "Float_field",
digits: [16, 1],
});
_records = [
{
int_field: 10,
float_field: 0.44444,
},
];
}
defineModels([Partner]);
test("ProgressBarField: max_value should update", async () => {
expect.assertions(3);
Partner._records[0].float_field = 2;
Partner._onChanges.name = (record) => {
record.int_field = 999;
record.float_field = 5;
};
onRpc("web_save", ({ args }) => {
expect(args[1]).toEqual(
{ int_field: 999, float_field: 5, name: "new name" },
{ message: "New value of progress bar saved" }
);
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="name" />
<field name="float_field" invisible="1" />
<field name="int_field" widget="progressbar" options="{'current_value': 'int_field', 'max_value': 'float_field'}" />
</form>`,
resId: 1,
});
expect(".o_progressbar").toHaveText("10\n/\n2");
await click(".o_field_widget[name=name] input");
await edit("new name", { confirm: "enter" });
await clickSave();
await animationFrame();
expect(".o_progressbar").toHaveText("999\n/\n5");
});
test("ProgressBarField: value should update in edit mode when typing in input", async () => {
expect.assertions(4);
Partner._records[0].int_field = 99;
onRpc("web_save", ({ args }) => {
expect(args[1].int_field).toBe(69);
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="int_field" widget="progressbar" options="{'editable': true}"/>
</form>`,
resId: 1,
});
expect(queryValue(".o_progressbar_value .o_input") + queryText(".o_progressbar")).toBe("99%", {
message: "Initial value should be correct",
});
await click(".o_progressbar_value .o_input");
// wait for apply dom change
await animationFrame();
await edit("69", { confirm: "enter" });
expect(".o_progressbar_value .o_input").toHaveValue("69", {
message: "New value should be different after focusing out of the field",
});
// wait for apply dom change
await animationFrame();
await clickSave();
// wait for rpc
await animationFrame();
expect(".o_progressbar_value .o_input").toHaveValue("69", {
message: "New value is still displayed after save",
});
});
test("ProgressBarField: value should update in edit mode when typing in input with field max value", async () => {
expect.assertions(4);
Partner._records[0].int_field = 99;
onRpc("web_save", ({ args }) => {
expect(args[1].int_field).toBe(69);
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="float_field" invisible="1" />
<field name="int_field" widget="progressbar" options="{'editable': true, 'max_value': 'float_field'}" />
</form>`,
resId: 1,
});
expect(".o_form_view .o_form_editable").toHaveCount(1, { message: "Form in edit mode" });
expect(queryValue(".o_progressbar_value .o_input") + queryText(".o_progressbar")).toBe(
"99/\n0",
{ message: "Initial value should be correct" }
);
await click(".o_progressbar_value .o_input");
await animationFrame();
await edit("69", { confirm: "enter" });
await animationFrame();
await clickSave();
await animationFrame();
expect(queryValue(".o_progressbar_value .o_input") + queryText(".o_progressbar")).toBe(
"69/\n0",
{ message: "New value should be different than initial after click" }
);
});
test("ProgressBarField: max value should update in edit mode when typing in input with field max value", async () => {
expect.assertions(5);
Partner._records[0].int_field = 99;
onRpc("web_save", ({ args }) => {
expect(args[1].float_field).toBe(69);
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="float_field" invisible="1" />
<field name="int_field" widget="progressbar" options="{'editable': true, 'max_value': 'float_field', 'edit_max_value': true}" />
</form>`,
resId: 1,
});
expect(queryText(".o_progressbar") + queryValue(".o_progressbar_value .o_input")).toBe(
"99\n/0",
{ message: "Initial value should be correct" }
);
expect(".o_form_view .o_form_editable").toHaveCount(1, { message: "Form in edit mode" });
queryOne(".o_progressbar input").focus();
await animationFrame();
expect(queryText(".o_progressbar") + queryValue(".o_progressbar_value .o_input")).toBe(
"99\n/0.44",
{ message: "Initial value is not formatted when focused" }
);
await click(".o_progressbar_value .o_input");
await edit("69", { confirm: "enter" });
await clickSave();
expect(queryText(".o_progressbar") + queryValue(".o_progressbar_value .o_input")).toBe(
"99\n/69",
{ message: "New value should be different than initial after click" }
);
});
test("ProgressBarField: Standard readonly mode is readonly", async () => {
Partner._records[0].int_field = 99;
onRpc(({ method }) => expect.step(method));
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form edit="0">
<field name="float_field" invisible="1"/>
<field name="int_field" widget="progressbar" options="{'editable': true, 'max_value': 'float_field', 'edit_max_value': true}"/>
</form>`,
resId: 1,
});
expect(".o_progressbar").toHaveText("99\n/\n0", {
message: "Initial value should be correct",
});
await click(".o_progress");
await animationFrame();
expect(".o_progressbar_value .o_input").toHaveCount(0, {
message: "no input in readonly mode",
});
expect.verifySteps(["get_views", "web_read"]);
});
test("ProgressBarField: field is editable in kanban", async () => {
expect.assertions(7);
Partner._records[0].int_field = 99;
onRpc("web_save", ({ args }) => {
expect(args[1].int_field).toBe(69);
});
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="int_field" title="ProgressBarTitle" widget="progressbar" options="{'editable': true, 'max_value': 'float_field'}" />
</t>
</templates>
</kanban>`,
resId: 1,
});
expect(".o_progressbar_value .o_input").toHaveValue("99", {
message: "Initial input value should be correct",
});
expect(".o_progressbar_value span").toHaveText("100", {
message: "Initial max value should be correct",
});
expect(".o_progressbar_title").toHaveText("ProgressBarTitle");
await click(".o_progressbar_value .o_input");
await edit("69", { confirm: "enter" });
await animationFrame();
expect(".o_progressbar_value .o_input").toHaveValue("69");
expect(".o_progressbar_value span").toHaveText("100", {
message: "Max value is still the same be correct",
});
expect(".o_progressbar_title").toHaveText("ProgressBarTitle");
});
test("force readonly in kanban", async (assert) => {
expect.assertions(2);
Partner._records[0].int_field = 99;
onRpc("web_save", () => {
throw new Error("Not supposed to write");
});
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="int_field" widget="progressbar" options="{'editable': true, 'max_value': 'float_field', 'readonly': True}" />
</t>
</templates>
</kanban>`,
resId: 1,
});
expect(".o_progressbar").toHaveText("99\n/\n100");
expect(".o_progressbar_value .o_input").toHaveCount(0);
});
test("ProgressBarField: readonly and editable attrs/options in kanban", async () => {
expect.assertions(4);
Partner._records[0].int_field = 29;
Partner._records[0].int_field2 = 59;
Partner._records[0].int_field3 = 99;
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="int_field" readonly="1" widget="progressbar" options="{'max_value': 'float_field'}" />
<field name="int_field2" widget="progressbar" options="{'max_value': 'float_field'}" />
<field name="int_field3" widget="progressbar" options="{'editable': true, 'max_value': 'float_field'}" />
</t>
</templates>
</kanban>`,
resId: 1,
});
expect("[name='int_field'] .o_progressbar_value .o_input").toHaveCount(0, {
message: "the field is still in readonly since there is readonly attribute",
});
expect("[name='int_field2'] .o_progressbar_value .o_input").toHaveCount(0, {
message: "the field is still in readonly since there is readonly attribute",
});
expect("[name='int_field3'] .o_progressbar_value .o_input").toHaveCount(1, {
message: "the field is still in readonly since there is readonly attribute",
});
await click(".o_field_progressbar[name='int_field3'] .o_progressbar_value .o_input");
await edit("69", { confirm: "enter" });
await animationFrame();
expect(".o_field_progressbar[name='int_field3'] .o_progressbar_value .o_input").toHaveValue(
"69",
{ message: "New value should be different than initial after click" }
);
});
test("ProgressBarField: write float instead of int works, in locale", async () => {
expect.assertions(4);
Partner._records[0].int_field = 99;
defineParams({
lang_parameters: {
decimal_point: ":",
thousands_sep: "#",
},
});
onRpc("web_save", ({ args }) => {
expect(args[1].int_field).toBe(1037);
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="int_field" widget="progressbar" options="{'editable': true}"/>
</form>`,
resId: 1,
});
expect(queryValue(".o_progressbar_value .o_input") + queryText(".o_progressbar")).toBe("99%", {
message: "Initial value should be correct",
});
expect(".o_form_view .o_form_editable").toHaveCount(1, { message: "Form in edit mode" });
await click(".o_field_widget input");
await animationFrame();
await edit("1#037:9", { confirm: "enter" });
await animationFrame();
await clickSave();
await animationFrame();
expect(".o_progressbar_value .o_input").toHaveValue("1k", {
message: "New value should be different than initial after click",
});
});
test("ProgressBarField: write gibberish instead of int throws warning", async () => {
Partner._records[0].int_field = 99;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="int_field" widget="progressbar" options="{'editable': true}"/>
</form>`,
resId: 1,
});
expect(".o_progressbar_value .o_input").toHaveValue("99", {
message: "Initial value in input is correct",
});
await click(".o_progressbar_value .o_input");
await animationFrame();
await edit("trente sept virgule neuf", { confirm: "enter" });
await animationFrame();
await click(".o_form_button_save");
await animationFrame();
expect(".o_form_status_indicator span.text-danger").toHaveCount(1, {
message: "The form has not been saved",
});
expect(".o_form_button_save").toHaveProperty("disabled", true, {
message: "save button is disabled",
});
});
test("ProgressBarField: color is correctly set when value > max value", async () => {
Partner._records[0].float_field = 101;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="float_field" widget="progressbar" options="{'overflow_class': 'bg-warning'}"/>
</form>`,
resId: 1,
});
expect(".o_progressbar .bg-warning").toHaveCount(1, {
message: "As the value has excedded the max value, the color should be set to bg-warning",
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,253 @@
import { expect, test } from "@odoo/hoot";
import { check, click, queryRect } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
bar = fields.Boolean({ default: true });
int_field = fields.Integer();
trululu = fields.Many2one({ relation: "partner" });
product_id = fields.Many2one({ relation: "product" });
color = fields.Selection({
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
});
_records = [
{
id: 1,
display_name: "first record",
bar: true,
int_field: 10,
},
{
id: 2,
display_name: "second record",
},
{
id: 3,
display_name: "third record",
},
];
}
class Product extends models.Model {
display_name = fields.Char();
_records = [
{
id: 37,
display_name: "xphone",
},
{
id: 41,
display_name: "xpad",
},
];
}
defineModels([Partner, Product]);
test("radio field on a many2one in a new record", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="product_id" widget="radio"/></form>`,
});
expect("div.o_radio_item").toHaveCount(2);
expect("input.o_radio_input").toHaveCount(2);
expect(".o_field_radio:first").toHaveText("xphone\nxpad");
expect("input.o_radio_input:checked").toHaveCount(0);
});
test("required radio field on a many2one", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="product_id" widget="radio" required="1"/></form>`,
});
expect(".o_field_radio input:checked").toHaveCount(0);
await clickSave();
expect(".o_notification_title:first").toHaveText("Invalid fields:");
expect(".o_notification_content:first").toHaveProperty(
"innerHTML",
"<ul><li>Product</li></ul>"
);
expect(".o_notification_bar:first").toHaveClass("bg-danger");
});
test("radio field change value by onchange", async () => {
Partner._fields.bar = fields.Boolean({
default: true,
onChange: (obj) => {
obj.product_id = obj.bar ? [41] : [37];
obj.color = obj.bar ? "red" : "black";
},
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar" />
<field name="product_id" widget="radio" />
<field name="color" widget="radio" />
</form>
`,
});
await click(".o_field_boolean input[type='checkbox']");
await animationFrame();
expect("input.o_radio_input[data-value='37']").toBeChecked();
expect("input.o_radio_input[data-value='black']").toBeChecked();
await click(".o_field_boolean input[type='checkbox']");
await animationFrame();
expect("input.o_radio_input[data-value='41']").toBeChecked();
expect("input.o_radio_input[data-value='red']").toBeChecked();
});
test("radio field on a selection in a new record", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="color" widget="radio"/></form>`,
});
expect("div.o_radio_item").toHaveCount(2);
expect("input.o_radio_input").toHaveCount(2, { message: "should have 2 possible choices" });
expect(".o_field_radio").toHaveText("Red\nBlack");
// click on 2nd option
await click("input.o_radio_input:eq(1)");
await animationFrame();
await clickSave();
expect("input.o_radio_input[data-value=black]").toBeChecked({
message: "should have saved record with correct value",
});
});
test("two radio field with same selection", async () => {
Partner._fields.color_2 = { ...Partner._fields.color };
Partner._records[0].color = "black";
Partner._records[0].color_2 = "black";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<group>
<field name="color" widget="radio"/>
</group>
<group>
<field name="color_2" widget="radio"/>
</group>
</form>
`,
});
expect("[name='color'] input.o_radio_input[data-value=black]").toBeChecked();
expect("[name='color_2'] input.o_radio_input[data-value=black]").toBeChecked();
// click on Red
await click("[name='color_2'] label");
await animationFrame();
expect("[name='color'] input.o_radio_input[data-value=black]").toBeChecked();
expect("[name='color_2'] input.o_radio_input[data-value=red]").toBeChecked();
});
test("radio field has o_horizontal or o_vertical class", async () => {
Partner._fields.color2 = Partner._fields.color;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<group>
<field name="color" widget="radio" />
<field name="color2" widget="radio" options="{'horizontal': True}" />
</group>
</form>
`,
});
expect(".o_field_radio > div.o_vertical").toHaveCount(1, {
message: "should have o_vertical class",
});
const verticalRadio = ".o_field_radio > div.o_vertical:first";
expect(`${verticalRadio} .o_radio_item:first`).toHaveRect({
right: queryRect(`${verticalRadio} .o_radio_item:last`).right,
});
expect(".o_field_radio > div.o_horizontal").toHaveCount(1, {
message: "should have o_horizontal class",
});
const horizontalRadio = ".o_field_radio > div.o_horizontal:first";
expect(`${horizontalRadio} .o_radio_item:first`).toHaveRect({
top: queryRect(`${horizontalRadio} .o_radio_item:last`).top,
});
});
test("radio field with numerical keys encoded as strings", async () => {
Partner._fields.selection = fields.Selection({
selection: [
["0", "Red"],
["1", "Black"],
],
});
onRpc("partner", "web_save", ({ args }) => expect.step(args[1].selection));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="selection" widget="radio"/></form>`,
});
expect(".o_field_widget").toHaveText("Red\nBlack");
expect(".o_radio_input:checked").toHaveCount(0);
await check("input.o_radio_input:last");
await animationFrame();
await clickSave();
expect(".o_field_widget").toHaveText("Red\nBlack");
expect(".o_radio_input[data-value='1']").toBeChecked();
expect.verifySteps(["1"]);
});
test("radio field is empty", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: /* xml */ `
<form edit="0">
<field name="trululu" widget="radio" />
</form>
`,
});
expect(".o_field_widget[name=trululu]").toHaveClass("o_field_empty");
expect(".o_radio_input").toHaveCount(3);
expect(".o_radio_input:disabled").toHaveCount(3);
expect(".o_radio_input:checked").toHaveCount(0);
});

View file

@ -0,0 +1,989 @@
import { describe, expect, test } from "@odoo/hoot";
import { click, edit, press, queryAllValues, queryFirst, select } from "@odoo/hoot-dom";
import { animationFrame, Deferred, runAllTimers } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
fields,
mockService,
models,
mountView,
mountViewInDialog,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
name = fields.Char();
foo = fields.Char({ default: "My little Foo Value" });
bar = fields.Boolean({ default: true });
int_field = fields.Integer();
p = fields.One2many({
relation: "partner",
relation_field: "trululu",
});
turtles = fields.One2many({
relation: "turtle",
relation_field: "turtle_trululu",
});
trululu = fields.Many2one({ relation: "partner" });
color = fields.Selection({
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
});
reference = fields.Reference({
selection: [
["product", "Product"],
["partner.type", "Partner Type"],
["partner", "Partner"],
],
});
reference_char = fields.Char();
model_id = fields.Many2one({ relation: "ir.model" });
_records = [
{
id: 1,
name: "first record",
bar: true,
foo: "yop",
int_field: 10,
p: [],
turtles: [2],
trululu: 4,
reference: "product,37",
},
{
id: 2,
name: "second record",
bar: true,
foo: "blip",
int_field: 9,
p: [],
trululu: 1,
},
{
id: 4,
name: "aaa",
bar: false,
},
];
}
class Product extends models.Model {
name = fields.Char();
_records = [
{ id: 37, name: "xphone" },
{ id: 41, name: "xpad" },
];
}
class PartnerType extends models.Model {
name = fields.Char();
_records = [
{ id: 12, name: "gold" },
{ id: 14, name: "silver" },
];
}
class Turtle extends models.Model {
name = fields.Char();
turtle_trululu = fields.Many2one({ relation: "partner" });
turtle_ref = fields.Reference({
selection: [
["product", "Product"],
["partner", "Partner"],
],
});
partner_ids = fields.Many2many({ relation: "partner" });
_records = [
{ id: 1, name: "leonardo", partner_ids: [] },
{ id: 2, name: "donatello", partner_ids: [2, 4] },
{ id: 3, name: "raphael", partner_ids: [], turtle_ref: "product,37" },
];
}
class IrModel extends models.Model {
_name = "ir.model";
name = fields.Char();
model = fields.Char();
_records = [
{ id: 17, name: "Partner", model: "partner" },
{ id: 20, name: "Product", model: "product" },
{ id: 21, name: "Partner Type", model: "partner.type" },
];
}
defineModels([Partner, Product, PartnerType, Turtle, IrModel]);
describe.current.tags("desktop");
test("ReferenceField can quick create models", async () => {
onRpc(({ method }) => expect.step(method));
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="reference" /></form>`,
});
await click("select");
await select("partner");
await animationFrame();
await click(".o_field_widget[name='reference'] input");
await edit("new partner");
await runAllTimers();
await click(".o_field_widget[name='reference'] .o_m2o_dropdown_option_create");
await animationFrame();
await clickSave();
// The name_create method should have been called
expect.verifySteps([
"get_views",
"onchange",
"name_search", // for the select
"name_search", // for the spawned many2one
"name_create",
"web_save",
]);
});
test("ReferenceField respects no_quick_create", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="reference" options="{'no_quick_create': 1}" /></form>`,
});
await click("select");
await select("partner");
await animationFrame();
await click(".o_field_widget[name='reference'] input");
await edit("new partner");
await runAllTimers();
expect(".ui-autocomplete .o_m2o_dropdown_option").toHaveCount(1, {
message: "Dropdown should be opened and have only one item",
});
expect(".ui-autocomplete .o_m2o_dropdown_option").toHaveClass(
"o_m2o_dropdown_option_create_edit"
);
});
test("ReferenceField in modal readonly mode", async () => {
Partner._records[0].p = [2];
Partner._records[1].trululu = 1;
Partner._records[1].reference = "product,41";
Partner._views[["form", false]] = /* xml */ `
<form>
<field name="display_name" />
<field name="reference" />
</form>
`;
Partner._views[["list", false]] = /* xml */ `
<list>
<field name="display_name"/>
<field name="reference" />
</list>
`;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form edit="0">
<field name="reference" />
<field name="p" />
</form>
`,
});
// Current Form
expect(".o_field_widget[name=reference] .o_form_uri").toHaveText("xphone", {
message: "the field reference of the form should have the right value",
});
expect(queryFirst(".o_data_cell")).toHaveText("second record", {
message: "the list should have one record",
});
await click(".o_data_cell");
await animationFrame();
// In modal
expect(".modal-lg").toHaveCount(1);
expect(".modal-lg .o_field_widget[name=reference] .o_form_uri").toHaveText("xpad", {
message: "The field reference in the modal should have the right value",
});
});
test("ReferenceField in modal write mode", async () => {
Partner._records[0].p = [2];
Partner._records[1].trululu = 1;
Partner._records[1].reference = "product,41";
Partner._views[["form", false]] = /* xml */ `
<form>
<field name="display_name" />
<field name="reference" />
</form>
`;
Partner._views[["list", false]] = /* xml */ `
<list>
<field name="display_name"/>
<field name="reference" />
</list>
`;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="reference" />
<field name="p" />
</form>
`,
});
// Current Form
expect(".o_field_widget[name=reference] option:checked").toHaveText("Product", {
message: "The reference field's model should be Product",
});
expect(".o_field_widget[name=reference] .o-autocomplete--input").toHaveValue("xphone", {
message: "The reference field's record should be xphone",
});
await click(".o_data_cell");
await animationFrame();
// In modal
expect(".modal-lg").toHaveCount(1, { message: "there should be one modal opened" });
expect(".modal-lg .o_field_widget[name=reference] option:checked").toHaveText("Product", {
message: "The reference field's model should be Product",
});
expect(".modal-lg .o_field_widget[name=reference] .o-autocomplete--input").toHaveValue("xpad", {
message: "The reference field's record should be xpad",
});
});
test("reference in form view", async () => {
expect.assertions(11);
Product._views[["form", false]] = /* xml */ `
<form>
<field name="display_name" />
</form>
`;
onRpc(({ args, method, model }) => {
if (method === "get_formview_action") {
expect(args[0]).toEqual([37], {
message: "should call get_formview_action with correct id",
});
return {
res_id: 17,
type: "ir.actions.act_window",
target: "current",
res_model: "res.partner",
};
}
if (method === "get_formview_id") {
expect(args[0]).toEqual([37], {
message: "should call get_formview_id with correct id",
});
return false;
}
if (method === "name_search") {
expect(model).toBe("partner.type", {
message: "the name_search should be done on the newly set model",
});
}
if (method === "web_save") {
expect(model).toBe("partner", { message: "should write on the current model" });
expect(args).toEqual([[1], { reference: "partner.type,12" }], {
message: "should write the correct value",
});
}
});
mockService("action", {
doAction(action) {
expect(action.res_id).toBe(17, {
message: "should do a do_action with correct parameters",
});
},
});
await mountViewInDialog({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="reference" string="custom label"/>
</group>
</sheet>
</form>
`,
});
expect(".o_field_many2one_selection").toHaveCount(1, {
message: "should contain one many2one",
});
expect(".o_field_widget select").toHaveValue("product", {
message: "widget should contain one select with the model",
});
expect(".o_field_widget input").toHaveValue("xphone", {
message: "widget should contain one input with the record",
});
expect(queryAllValues(".o_field_widget select > option")).toEqual(
["", "product", "partner.type", "partner"],
{
message: "the options should be correctly set",
}
);
await click(".o_external_button");
await animationFrame();
expect(".o_dialog:not(.o_inactive_modal) .modal-title").toHaveText("Open: custom label", {
message: "dialog title should display the custom string label",
});
await click(".o_dialog:not(.o_inactive_modal) .o_form_button_cancel");
await animationFrame();
await select("partner.type", { target: ".o_field_widget select" });
await animationFrame();
expect(".o_field_widget input").toHaveValue("", {
message: "many2one value should be reset after model change",
});
await click(".o_field_widget[name=reference] input");
await animationFrame();
await click(".o_field_widget[name=reference] .ui-menu-item");
await clickSave();
expect(".o_field_widget[name=reference] input").toHaveValue("gold", {
message: "should contain a link with the new value",
});
});
test("Many2One 'Search more...' updates on resModel change", async () => {
onRpc("has_group", () => true);
Product._views[["list", false]] = /* xml */ `<list><field name="display_name"/></list>`;
Product._views[["search", false]] = /* xml */ `<search/>`;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="reference"/></form>`,
});
// Selecting a relation
await click("div.o_field_reference select.o_input");
await select("partner.type");
// Selecting another relation
await click("div.o_field_reference select.o_input");
await select("product");
await animationFrame();
// Opening the Search More... option
await click("div.o_field_reference input.o_input");
await animationFrame();
await click("div.o_field_reference .o_m2o_dropdown_option_search_more");
await animationFrame();
expect(queryFirst("div.modal td.o_data_cell")).toHaveText("xphone", {
message: "The search more should lead to the values of product.",
});
});
test("computed reference field changed by onchange to 'False,0' value", async () => {
expect.assertions(1);
Partner._onChanges.bar = (obj) => {
if (!obj.bar) {
obj.reference_char = "False,0";
}
};
onRpc("web_save", ({ args }) => {
expect(args[1]).toEqual({
bar: false,
reference_char: "False,0",
});
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<field name="reference_char" widget="reference"/>
</form>
`,
});
// trigger the onchange to set a value for the reference field
await click(".o_field_boolean input");
await animationFrame();
await clickSave();
});
test("interact with reference field changed by onchange", async () => {
expect.assertions(2);
Partner._onChanges.bar = (obj) => {
if (!obj.bar) {
obj.reference = "partner,1";
}
};
onRpc("web_save", ({ args }) => {
expect(args[1]).toEqual({
bar: false,
reference: "partner,4",
});
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<field name="reference"/>
</form>
`,
});
// trigger the onchange to set a value for the reference field
await click(".o_field_boolean input");
await animationFrame();
expect(".o_field_widget[name=reference] select").toHaveValue("partner");
// manually update reference field
queryFirst(".o_field_widget[name=reference] input").tabIndex = 0;
await click(".o_field_widget[name=reference] input");
await edit("aaa");
await runAllTimers();
await click(".ui-autocomplete .ui-menu-item");
// save
await clickSave();
});
test("default_get and onchange with a reference field", async () => {
Partner._fields.reference = fields.Reference({
selection: [
["product", "Product"],
["partner.type", "Partner Type"],
["partner", "Partner"],
],
default: "product,37",
});
Partner._onChanges.int_field = (obj) => {
if (obj.int_field) {
obj.reference = "partner.type," + obj.int_field;
}
};
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="int_field" />
<field name="reference" />
</group>
</sheet>
</form>
`,
});
expect(".o_field_widget[name='reference'] select").toHaveValue("product", {
message: "reference field model should be correctly set",
});
expect(".o_field_widget[name='reference'] input").toHaveValue("xphone", {
message: "reference field value should be correctly set",
});
// trigger onchange
await click(".o_field_widget[name=int_field] input");
await edit(12, { confirm: "enter" });
await animationFrame();
expect(".o_field_widget[name='reference'] select").toHaveValue("partner.type", {
message: "reference field model should be correctly set",
});
expect(".o_field_widget[name='reference'] input").toHaveValue("gold", {
message: "reference field value should be correctly set",
});
});
test("default_get a reference field in a x2m", async () => {
Partner._fields.turtles = fields.One2many({
relation: "turtle",
relation_field: "turtle_trululu",
default: [[0, 0, { turtle_ref: "product,37" }]],
});
Turtle._views[["form", false]] = /* xml */ `
<form>
<field name="display_name" />
<field name="turtle_ref" />
</form>
`;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<field name="turtles">
<list>
<field name="turtle_ref" />
</list>
</field>
</sheet>
</form>
`,
});
expect('.o_field_widget[name="turtles"] .o_data_row').toHaveText("xphone", {
message: "the default value should be correctly handled",
});
});
test("ReferenceField on char field, reset by onchange", async () => {
Partner._records[0].foo = "product,37";
Partner._onChanges.int_field = (obj) => (obj.foo = "product," + obj.int_field);
let nbNameGet = 0;
onRpc("product", "read", ({ args }) => {
if (args[1].length === 1 && args[1][0] === "display_name") {
nbNameGet++;
}
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="int_field" />
<field name="foo" widget="reference" readonly="1" />
</group>
</sheet>
</form>
`,
});
expect(nbNameGet).toBe(1, { message: "the first name_get should have been done" });
expect(".o_field_widget[name=foo]").toHaveText("xphone", {
message: "foo field should be correctly set",
});
// trigger onchange
await click(".o_field_widget[name=int_field] input");
await edit(41, { confirm: "enter" });
await runAllTimers();
await animationFrame();
expect(nbNameGet).toBe(2, { message: "the second name_get should have been done" });
expect(".o_field_widget[name=foo]").toHaveText("xpad", {
message: "foo field should have been updated",
});
});
test("reference and list navigation", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list editable="bottom">
<field name="reference" />
</list>
`,
});
// edit first row
await click(".o_data_row .o_data_cell");
await animationFrame();
expect(".o_data_row [name='reference'] input").toBeFocused();
await press("Tab");
await animationFrame();
expect(".o_data_row:nth-child(2) [name='reference'] select").toBeFocused();
});
test("ReferenceField with model_field option", async () => {
Partner._records[0].reference = false;
Partner._records[0].model_id = 20;
Partner._records[1].name = "John Smith";
Product._records[0].name = "Product 1";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="model_id" />
<field name="reference" options="{'model_field': 'model_id'}" />
</form>
`,
});
expect("select").toHaveCount(0, {
message: "the selection list of the reference field should not exist.",
});
expect(".o_field_widget[name='reference'] input").toHaveValue("", {
message: "no record should be selected in the reference field",
});
await click(".o_field_widget[name='reference'] input");
await edit("Product 1");
await runAllTimers();
await click(".ui-autocomplete .ui-menu-item:first-child");
await animationFrame();
expect(".o_field_widget[name='reference'] input").toHaveValue("Product 1", {
message: "the Product 1 record should be selected in the reference field",
});
await click(".o_field_widget[name='model_id'] input");
await edit("Partner");
await runAllTimers();
await click(".ui-autocomplete .ui-menu-item:first-child");
await runAllTimers();
await animationFrame();
expect(".o_field_widget[name='reference'] input").toHaveValue("", {
message: "no record should be selected in the reference field",
});
await click(".o_field_widget[name='reference'] input");
await edit("John");
await runAllTimers();
await click(".ui-autocomplete .ui-menu-item:first-child");
await animationFrame();
expect(".o_field_widget[name='reference'] input").toHaveValue("John Smith", {
message: "the John Smith record should be selected in the reference field",
});
});
test("ReferenceField with model_field option (model_field not synchronized with reference)", async () => {
// Checks that the data is not modified even though it is not synchronized.
// Not synchronized = model_id contains a different model than the one used in reference.
Partner._records[0].reference = "partner,1";
Partner._records[0].model_id = 20;
Partner._records[0].name = "John Smith";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="model_id" />
<field name="reference" options="{'model_field': 'model_id'}" />
</form>
`,
});
expect("select").toHaveCount(0, {
message: "the selection list of the reference field should not exist.",
});
expect(".o_field_widget[name='model_id'] input").toHaveValue("Product", {
message: "the Product model should be selected in the model_id field",
});
expect(".o_field_widget[name='reference'] input").toHaveValue("John Smith", {
message: "the John Smith record should be selected in the reference field",
});
});
test("Reference field with default value in list view", async () => {
expect.assertions(1);
onRpc("has_group", () => true);
onRpc(({ method, args }) => {
if (method === "onchange") {
return {
value: {
reference: {
id: { id: 2, model: "partner" },
name: "second record",
},
},
};
} else if (method === "web_save") {
expect(args[1].reference).toBe("partner,2");
}
});
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list string="Test" editable="top">
<field name="reference"/>
<field name="name"/>
</list>
`,
});
await click(".o_control_panel_main_buttons .o_list_button_add");
await animationFrame();
await click('.o_list_char[name="name"] input');
await edit("Blabla");
await runAllTimers();
await click(".o_control_panel_main_buttons .o_list_button_save");
await animationFrame();
});
test("ReferenceField with model_field option (tree list in form view)", async () => {
Turtle._records[0].partner_ids = [1];
Partner._records[0].reference = "product,41";
Partner._records[0].model_id = 20;
await mountView({
type: "form",
resModel: "turtle",
resId: 1,
arch: /* xml */ `
<form>
<field name="partner_ids">
<list editable="bottom">
<field name="name" />
<field name="model_id" />
<field name="reference" options="{'model_field': 'model_id'}" class="reference_field" />
</list>
</field>
</form>
`,
});
expect(".reference_field").toHaveText("xpad");
// Select the second product without changing the model
await click(".o_list_table .reference_field");
await animationFrame();
await click(".o_list_table .reference_field input");
await animationFrame();
// Enter to select it
await press("Enter");
await animationFrame();
expect(".reference_field input").toHaveValue("xphone", {
message: "should have selected the first product",
});
});
test("edit a record containing a ReferenceField with model_field option (list in form view)", async () => {
Turtle._records[0].partner_ids = [1];
Partner._records[0].reference = "product,41";
Partner._records[0].model_id = 20;
await mountView({
type: "form",
resModel: "turtle",
resId: 1,
arch: /* xml */ `
<form>
<field name="partner_ids">
<list editable="bottom">
<field name="name" />
<field name="model_id" />
<field name="reference" options='{"model_field": "model_id"}'/>
</list>
</field>
</form>
`,
});
expect(".o_list_table [name='name']").toHaveText("first record");
expect(".o_list_table [name='reference']").toHaveText("xpad");
await click(".o_list_table .o_data_cell");
await animationFrame();
await click(".o_list_table [name='name'] input");
await edit("plop");
await animationFrame();
await click(".o_form_view");
await animationFrame();
expect(".o_list_table [name='name']").toHaveText("plop");
expect(".o_list_table [name='reference']").toHaveText("xpad");
});
test("Change model field of a ReferenceField then select an invalid value (tree list in form view)", async () => {
Turtle._records[0].partner_ids = [1];
Partner._records[0].reference = "product,41";
Partner._records[0].model_id = 20;
await mountView({
type: "form",
resModel: "turtle",
resId: 1,
arch: /* xml */ `
<form>
<field name="partner_ids">
<list editable="bottom">
<field name="name" />
<field name="model_id"/>
<field name="reference" required="true" options="{'model_field': 'model_id'}" class="reference_field" />
</list>
</field>
</form>
`,
});
expect(".reference_field").toHaveText("xpad");
expect(".o_list_many2one").toHaveText("Product");
await click(".o_list_table td.o_list_many2one");
await animationFrame();
await click(".o_list_table .o_list_many2one input");
await animationFrame();
//Select the "Partner" option, different from original "Product"
await click(
".o_list_table .o_list_many2one .o_input_dropdown .dropdown-item:contains(Partner)"
);
await runAllTimers();
await animationFrame();
expect(".reference_field input").toHaveValue("");
expect(".o_list_many2one input").toHaveValue("Partner");
//Void the associated, required, "reference" field and make sure the form marks the field as required
await click(".o_list_table .reference_field input");
const textInput = queryFirst(".o_list_table .reference_field input");
textInput.setSelectionRange(0, textInput.value.length);
await click(".o_list_table .reference_field input");
await press("Backspace");
await click(".o_form_view_container");
await animationFrame();
expect(".o_list_table .reference_field.o_field_invalid").toHaveCount(1);
});
test("model selector is displayed only when it should be", async () => {
//The model selector should be only displayed if
//there is no hide_model=True options AND no model_field specified
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<group>
<field name="reference" options="{'model_field': 'model_id'}" />
</group>
<group>
<field name="reference" options="{'model_field': 'model_id', 'hide_model': True}" />
</group>
<group>
<field name="reference" options="{'hide_model': True}" />
</group>
<group>
<field name="reference" />
</group>
</form>
`,
});
expect(".o_inner_group:eq(0) select").toHaveCount(0, {
message:
"the selection list of the reference field should not exist when model_field is specified.",
});
expect(".o_inner_group:eq(1) select").toHaveCount(0, {
message:
"the selection list of the reference field should not exist when model_field is specified and hide_model=True.",
});
expect(".o_inner_group:eq(2) select").toHaveCount(0, {
message: "the selection list of the reference field should not exist when hide_model=True.",
});
expect(".o_inner_group:eq(3) select").toHaveCount(1, {
message:
"the selection list of the reference field should exist when hide_model=False and no model_field specified.",
});
});
test("reference field should await fetch model before render", async () => {
Partner._records[0].model_id = 20;
const def = new Deferred();
onRpc(async (args) => {
if (args.method === "read" && args.model === "ir.model") {
await def;
}
});
mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="model_id" invisible="1"/>
<field name="reference" options="{'model_field': 'model_id'}" />
</form>
`,
});
await animationFrame();
expect(".o_form_view").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_form_view").toHaveCount(1);
});
test("do not ask for display_name if field is invisible", async () => {
expect.assertions(1);
onRpc("web_read", ({ kwargs }) => {
expect(kwargs.specification).toEqual({
display_name: {},
reference: {
fields: {},
},
});
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `<form><field name="reference" invisible="1"/></form>`,
});
});
test("reference char with list view pager navigation", async () => {
Partner._records[0].reference_char = "product,37";
Partner._records[1].reference_char = "product,41";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
resIds: [1, 2],
arch: `<form edit="0"><field name="reference_char" widget="reference" string="Record"/></form>`,
});
expect(".o_field_reference").toHaveText("xphone");
await click(".o_pager_next");
await animationFrame();
expect(".o_field_reference").toHaveText("xpad");
});

View file

@ -0,0 +1,429 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { click, edit, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame, mockDate } from "@odoo/hoot-mock";
import { getPickerCell } from "@web/../tests/core/datetime/datetime_test_helpers";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
date = fields.Date({ string: "A date", searchable: true });
datetime = fields.Datetime({ string: "A datetime", searchable: true });
}
beforeEach(() => {
onRpc("has_group", () => true);
});
defineModels([Partner]);
test("RemainingDaysField on a date field in list view", async () => {
mockDate("2017-10-08 15:35:11");
Partner._records = [
{ id: 1, date: "2017-10-08" }, // today
{ id: 2, date: "2017-10-09" }, // tomorrow
{ id: 3, date: "2017-10-07" }, // yesterday
{ id: 4, date: "2017-10-10" }, // + 2 days
{ id: 5, date: "2017-10-05" }, // - 3 days
{ id: 6, date: "2018-02-08" }, // + 4 months (diff >= 100 days)
{ id: 7, date: "2017-06-08" }, // - 4 months (diff >= 100 days)
{ id: 8, date: false },
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list><field name="date" widget="remaining_days" /></list>`,
});
const cells = queryAll(".o_data_cell");
expect(cells[0]).toHaveText("Today");
expect(cells[1]).toHaveText("Tomorrow");
expect(cells[2]).toHaveText("Yesterday");
expect(cells[3]).toHaveText("In 2 days");
expect(cells[4]).toHaveText("3 days ago");
expect(cells[5]).toHaveText("02/08/2018");
expect(cells[6]).toHaveText("06/08/2017");
expect(cells[7]).toHaveText("");
expect(queryOne(".o_field_widget > div", { root: cells[0] })).toHaveAttribute(
"title",
"10/08/2017"
);
expect(queryOne(".o_field_widget > div", { root: cells[0] })).toHaveClass([
"fw-bold",
"text-warning",
]);
expect(queryOne(".o_field_widget > div", { root: cells[1] })).not.toHaveClass([
"fw-bold",
"text-warning",
"text-danger",
]);
expect(queryOne(".o_field_widget > div", { root: cells[2] })).toHaveClass([
"fw-bold",
"text-danger",
]);
expect(queryOne(".o_field_widget > div", { root: cells[3] })).not.toHaveClass([
"fw-bold",
"text-warning",
"text-danger",
]);
expect(queryOne(".o_field_widget > div", { root: cells[4] })).toHaveClass([
"fw-bold",
"text-danger",
]);
expect(queryOne(".o_field_widget > div", { root: cells[5] })).not.toHaveClass([
"fw-bold",
"text-warning",
"text-danger",
]);
expect(queryOne(".o_field_widget > div", { root: cells[6] })).toHaveClass([
"fw-bold",
"text-danger",
]);
});
test.tags("desktop");
test("RemainingDaysField on a date field in multi edit list view", async () => {
mockDate("2017-10-08 15:35:11"); // October 8 2017, 15:35:11
Partner._records = [
{ id: 1, date: "2017-10-08" }, // today
{ id: 2, date: "2017-10-09" }, // tomorrow
{ id: 8, date: false },
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list multi_edit="1"><field name="date" widget="remaining_days" /></list>`,
});
expect(queryAllTexts(".o_data_cell").slice(0, 2)).toEqual(["Today", "Tomorrow"]);
// select two records and edit them
await click(".o_data_row:eq(0) .o_list_record_selector input:first");
await animationFrame();
await click(".o_data_row:eq(1) .o_list_record_selector input:first");
await animationFrame();
await click(".o_data_row:eq(0) .o_data_cell:first");
await animationFrame();
expect(".o_field_remaining_days input").toHaveCount(1);
await click(".o_field_remaining_days input");
await edit("10/10/2017", { confirm: "enter" });
await animationFrame();
expect(".modal").toHaveCount(1);
expect(".modal .o_field_widget").toHaveText("In 2 days", {
message: "should have 'In 2 days' value to change",
});
await click(".modal .modal-footer .btn-primary");
await animationFrame();
expect(".o_data_row:eq(0) .o_data_cell:first").toHaveText("In 2 days", {
message: "should have 'In 2 days' as date field value",
});
expect(".o_data_row:eq(1) .o_data_cell:first").toHaveText("In 2 days", {
message: "should have 'In 2 days' as date field value",
});
});
test.tags("desktop");
test("RemainingDaysField, enter wrong value manually in multi edit list view", async () => {
mockDate("2017-10-08 15:35:11"); // October 8 2017, 15:35:11
Partner._records = [
{ id: 1, date: "2017-10-08" }, // today
{ id: 2, date: "2017-10-09" }, // tomorrow
{ id: 8, date: false },
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list multi_edit="1"><field name="date" widget="remaining_days" /></list>`,
});
const cells = queryAll(".o_data_cell");
const rows = queryAll(".o_data_row");
expect(cells[0]).toHaveText("Today");
expect(cells[1]).toHaveText("Tomorrow");
// select two records and edit them
await click(".o_list_record_selector input", { root: rows[0] });
await animationFrame();
await click(".o_list_record_selector input", { root: rows[1] });
await animationFrame();
await click(".o_data_cell", { root: rows[0] });
await animationFrame();
expect(".o_field_remaining_days input").toHaveCount(1);
await click(".o_field_remaining_days input");
await edit("blabla", { confirm: "enter" });
await animationFrame();
expect(".modal").toHaveCount(0);
expect(cells[0]).toHaveText("Today");
expect(cells[1]).toHaveText("Tomorrow");
});
test("RemainingDaysField on a date field in form view", async () => {
mockDate("2017-10-08 15:35:11"); // October 8 2017, 15:35:11
Partner._records = [
{ id: 1, date: "2017-10-08" }, // today
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="date" widget="remaining_days" /></form>`,
});
expect(".o_field_widget input").toHaveValue("10/08/2017");
expect(".o_form_editable").toHaveCount(1);
expect("div.o_field_widget[name='date'] input").toHaveCount(1);
await click(".o_field_remaining_days input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1, { message: "datepicker should be opened" });
await click(getPickerCell("9"));
await animationFrame();
await click(".o_form_button_save");
await animationFrame();
expect(".o_field_widget input").toHaveValue("10/09/2017");
});
test("RemainingDaysField on a date field on a new record in form", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="date" widget="remaining_days" />
</form>`,
});
expect(".o_form_editable .o_field_widget[name='date'] input").toHaveCount(1);
await click(".o_field_widget[name='date'] input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
});
test("RemainingDaysField in form view (readonly)", async () => {
mockDate("2017-10-08 15:35:11"); // October 8 2017, 15:35:11
Partner._records = [
{ id: 1, date: "2017-10-08", datetime: "2017-10-08 10:00:00" }, // today
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="date" widget="remaining_days" readonly="1" />
<field name="datetime" widget="remaining_days" readonly="1" />
</form>`,
});
expect(".o_field_widget[name='date']").toHaveText("Today");
expect(".o_field_widget[name='date'] > div ").toHaveClass(["fw-bold", "text-warning"]);
expect(".o_field_widget[name='datetime']").toHaveText("Today");
expect(".o_field_widget[name='datetime'] > div ").toHaveClass(["fw-bold", "text-warning"]);
});
test("RemainingDaysField on a datetime field in form view", async () => {
mockDate("2017-10-08 15:35:11"); // October 8 2017, 15:35:11
Partner._records = [
{ id: 1, datetime: "2017-10-08 10:00:00" }, // today
];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="datetime" widget="remaining_days" /></form>`,
});
expect(".o_field_widget input").toHaveValue("10/08/2017 11:00:00");
expect("div.o_field_widget[name='datetime'] input").toHaveCount(1);
await click(".o_field_widget input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1, { message: "datepicker should be opened" });
await click(getPickerCell("9"));
await animationFrame();
await click(".o_form_button_save");
await animationFrame();
expect(".o_field_widget input").toHaveValue("10/09/2017 11:00:00");
});
test("RemainingDaysField on a datetime field in list view in UTC", async () => {
mockDate("2017-10-08 15:35:11", 0); // October 8 2017, 15:35:11
Partner._records = [
{ id: 1, datetime: "2017-10-08 20:00:00" }, // today
{ id: 2, datetime: "2017-10-09 08:00:00" }, // tomorrow
{ id: 3, datetime: "2017-10-07 18:00:00" }, // yesterday
{ id: 4, datetime: "2017-10-10 22:00:00" }, // + 2 days
{ id: 5, datetime: "2017-10-05 04:00:00" }, // - 3 days
{ id: 6, datetime: "2018-02-08 04:00:00" }, // + 4 months (diff >= 100 days)
{ id: 7, datetime: "2017-06-08 04:00:00" }, // - 4 months (diff >= 100 days)
{ id: 8, datetime: false },
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list><field name="datetime" widget="remaining_days" /></list>`,
});
expect(queryAllTexts(".o_data_cell")).toEqual([
"Today",
"Tomorrow",
"Yesterday",
"In 2 days",
"3 days ago",
"02/08/2018",
"06/08/2017",
"",
]);
expect(".o_data_cell .o_field_widget div:first").toHaveAttribute("title", "10/08/2017");
const cells = queryAll(".o_data_cell div div");
expect(cells[0]).toHaveClass(["fw-bold", "text-warning"]);
expect(cells[1]).not.toHaveClass(["fw-bold", "text-warning", "text-danger"]);
expect(cells[2]).toHaveClass(["fw-bold", "text-danger"]);
expect(cells[3]).not.toHaveClass(["fw-bold", "text-warning", "text-danger"]);
expect(cells[4]).toHaveClass(["fw-bold", "text-danger"]);
expect(cells[5]).not.toHaveClass(["fw-bold", "text-warning", "text-danger"]);
expect(cells[6]).toHaveClass(["fw-bold", "text-danger"]);
});
test("RemainingDaysField on a datetime field in list view in UTC+6", async () => {
mockDate("2017-10-08 15:35:11", +6); // October 8 2017, 15:35:11, UTC+6
Partner._records = [
{ id: 1, datetime: "2017-10-08 20:00:00" }, // tomorrow
{ id: 2, datetime: "2017-10-09 08:00:00" }, // tomorrow
{ id: 3, datetime: "2017-10-07 18:30:00" }, // today
{ id: 4, datetime: "2017-10-07 12:00:00" }, // yesterday
{ id: 5, datetime: "2017-10-09 20:00:00" }, // + 2 days
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list><field name="datetime" widget="remaining_days" /></list>`,
});
expect(queryAllTexts(".o_data_cell")).toEqual([
"Tomorrow",
"Tomorrow",
"Today",
"Yesterday",
"In 2 days",
]);
expect(".o_data_cell .o_field_widget div:first").toHaveAttribute("title", "10/09/2017");
});
test("RemainingDaysField on a date field in list view in UTC-6", async () => {
mockDate("2017-10-08 15:35:11", -6); // October 8 2017, 15:35:11, UTC-6
Partner._records = [
{ id: 1, date: "2017-10-08" }, // today
{ id: 2, date: "2017-10-09" }, // tomorrow
{ id: 3, date: "2017-10-07" }, // yesterday
{ id: 4, date: "2017-10-10" }, // + 2 days
{ id: 5, date: "2017-10-05" }, // - 3 days
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list><field name="date" widget="remaining_days" /></list>`,
});
expect(queryAllTexts(".o_data_cell")).toEqual([
"Today",
"Tomorrow",
"Yesterday",
"In 2 days",
"3 days ago",
]);
expect(".o_data_cell .o_field_widget div:first").toHaveAttribute("title", "10/08/2017");
});
test("RemainingDaysField on a datetime field in list view in UTC-8", async () => {
mockDate("2017-10-08 15:35:11", -8); // October 8 2017, 15:35:11, UTC-8
Partner._records = [
{ id: 1, datetime: "2017-10-08 20:00:00" }, // today
{ id: 2, datetime: "2017-10-09 07:00:00" }, // today
{ id: 3, datetime: "2017-10-09 10:00:00" }, // tomorrow
{ id: 4, datetime: "2017-10-08 06:00:00" }, // yesterday
{ id: 5, datetime: "2017-10-07 02:00:00" }, // - 2 days
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list><field name="datetime" widget="remaining_days" /></list>`,
});
expect(queryAllTexts(".o_data_cell")).toEqual([
"Today",
"Today",
"Tomorrow",
"Yesterday",
"2 days ago",
]);
});
test("RemainingDaysField with custom decoration classes", async () => {
mockDate("2017-10-08 15:35:11");
Partner._records = [
{ id: 1, date: "2017-10-08" }, // today
{ id: 2, date: "2017-10-09" }, // tomorrow
{ id: 3, date: "2017-10-07" }, // yesterday
{ id: 4, date: "2017-10-10" }, // + 2 days
{ id: 5, date: "2017-10-05" }, // - 3 days
{ id: 6, date: "2018-02-08" }, // + 4 months (diff >= 100 days)
{ id: 7, date: "2017-06-08" }, // - 4 months (diff >= 100 days)
{ id: 8, date: false },
];
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list>
<field
name="date"
widget="remaining_days"
options="{
'classes': {
'muted': 'days &lt; -30',
'danger': 'days &lt; 0',
'success': 'days == 0',
'warning': 'days &gt; 30',
'info': 'days &gt;= 2'
}
}"
/>
</list>`,
});
const cells = queryAll(".o_data_cell div div");
expect(cells[0]).toHaveClass("text-success");
expect(cells[1]).not.toHaveAttribute("class");
expect(cells[2]).toHaveClass("text-danger");
expect(cells[3]).toHaveClass("text-info");
expect(cells[4]).toHaveClass("text-danger");
expect(cells[5]).toHaveClass("text-warning");
expect(cells[6]).toHaveClass("text-muted");
expect(cells[7]).not.toHaveAttribute("class");
});

View file

@ -0,0 +1,467 @@
import { expect, test } from "@odoo/hoot";
import {
click,
pointerDown,
queryAll,
queryAllValues,
queryFirst,
queryOne,
select,
} from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
display_name = fields.Char({ string: "Displayed name" });
int_field = fields.Integer({ string: "int_field" });
trululu = fields.Many2one({ string: "Trululu", relation: "partner" });
product_id = fields.Many2one({ string: "Product", relation: "product" });
color = fields.Selection({
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
string: "Color",
});
_records = [
{
id: 1,
display_name: "first record",
int_field: 10,
trululu: 4,
},
{
id: 2,
display_name: "second record",
int_field: 9,
trululu: 1,
product_id: 37,
},
{
id: 4,
display_name: "aaa",
},
];
}
class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
_records = [
{
id: 37,
display_name: "xphone",
},
{
id: 41,
display_name: "xpad",
},
];
}
defineModels([Partner, Product]);
test("SelectionField in a list view", async () => {
Partner._records.forEach((r) => (r.color = "red"));
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: '<list string="Colors" editable="top"><field name="color"/></list>',
});
expect(".o_data_row").toHaveCount(3);
await click(".o_data_cell");
await animationFrame();
const td = queryFirst("tbody tr.o_selected_row td:not(.o_list_record_selector)");
expect(queryOne("select", { root: td })).toHaveCount(1, {
message: "td should have a child 'select'",
});
expect(td.children).toHaveCount(1, { message: "select tag should be only child of td" });
});
test("SelectionField, edition and on many2one field", async () => {
Partner._onChanges.product_id = () => {};
Partner._records[0].product_id = 37;
Partner._records[0].trululu = false;
onRpc(({ method }) => expect.step(method));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="product_id" widget="selection" />
<field name="trululu" widget="selection" />
<field name="color" widget="selection" />
</form>`,
});
expect("select").toHaveCount(3);
expect(".o_field_widget[name='product_id'] select option[value='37']").toHaveCount(1, {
message: "should have fetched xphone option",
});
expect(".o_field_widget[name='product_id'] select option[value='41']").toHaveCount(1, {
message: "should have fetched xpad option",
});
expect(".o_field_widget[name='product_id'] select").toHaveValue("37", {
message: "should have correct product_id value",
});
expect(".o_field_widget[name='trululu'] select").toHaveValue("false", {
message: "should not have any value in trululu field",
});
await click(".o_field_widget[name='product_id'] select");
await select("41");
await animationFrame();
expect(".o_field_widget[name='product_id'] select").toHaveValue("41", {
message: "should have a value of xphone",
});
expect(".o_field_widget[name='color'] select").toHaveValue('"red"', {
message: "should have correct value in color field",
});
expect.verifySteps(["get_views", "web_read", "name_search", "name_search", "onchange"]);
});
test("unset selection field with 0 as key", async () => {
// The server doesn't make a distinction between false value (the field
// is unset), and selection 0, as in that case the value it returns is
// false. So the client must convert false to value 0 if it exists.
Partner._fields.selection = fields.Selection({
selection: [
[0, "Value O"],
[1, "Value 1"],
],
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form edit="0"><field name="selection" /></form>',
});
expect(".o_field_widget").toHaveText("Value O", {
message: "the displayed value should be 'Value O'",
});
expect(".o_field_widget").not.toHaveClass("o_field_empty", {
message: "should not have class o_field_empty",
});
});
test("unset selection field with string keys", async () => {
// The server doesn't make a distinction between false value (the field
// is unset), and selection 0, as in that case the value it returns is
// false. So the client must convert false to value 0 if it exists. In
// this test, it doesn't exist as keys are strings.
Partner._fields.selection = fields.Selection({
selection: [
["0", "Value O"],
["1", "Value 1"],
],
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form edit="0"><field name="selection" /></form>',
});
expect(".o_field_widget").toHaveText("", { message: "there should be no displayed value" });
expect(".o_field_widget").toHaveClass("o_field_empty", {
message: "should have class o_field_empty",
});
});
test("unset selection on a many2one field", async () => {
expect.assertions(1);
onRpc("web_save", ({ args }) => {
expect(args[1].trululu).toBe(false, {
message: "should send 'false' as trululu value",
});
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form><field name="trululu" widget="selection" /></form>',
});
await click(".o_form_view select");
await select("false");
await animationFrame();
await clickSave();
await animationFrame();
});
test("field selection with many2ones and special characters", async () => {
// edit the partner with id=4
Partner._records[2].display_name = "<span>hey</span>";
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form><field name="trululu" widget="selection" /></form>',
});
expect("select option[value='4']").toHaveText("<span>hey</span>");
});
test("required selection widget should not have blank option", async () => {
Partner._fields.feedback_value = fields.Selection({
required: true,
selection: [
["good", "Good"],
["bad", "Bad"],
],
default: "good",
string: "Good",
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="feedback_value" />
<field name="color" required="feedback_value == 'bad'" />
</form>`,
});
expect(queryAll(".o_field_widget[name='color'] option").map((n) => n.style.display)).toEqual([
"",
"",
"",
]);
expect(
queryAll(".o_field_widget[name='feedback_value'] option").map((n) => n.style.display)
).toEqual(["none", "", ""]);
// change value to update widget modifier values
await click(".o_field_widget[name='feedback_value'] select");
await select('"bad"');
await animationFrame();
expect(queryAll(".o_field_widget[name='color'] option").map((n) => n.style.display)).toEqual([
"none",
"",
"",
]);
});
test("required selection widget should have only one blank option", async () => {
Partner._fields.feedback_value = fields.Selection({
required: true,
selection: [
["good", "Good"],
["bad", "Bad"],
],
default: "good",
string: "Good",
});
Partner._fields.color = fields.Selection({
selection: [
[false, ""],
["red", "Red"],
["black", "Black"],
],
default: "red",
string: "Color",
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="feedback_value" />
<field name="color" required="feedback_value == 'bad'" />
</form>`,
});
expect(".o_field_widget[name='color'] option").toHaveCount(3, {
message: "Three options in non required field (one blank option)",
});
// change value to update widget modifier values
await click(".o_field_widget[name='feedback_value'] select");
await select('"bad"');
await animationFrame();
expect(queryAll(".o_field_widget[name='color'] option").map((n) => n.style.display)).toEqual([
"none",
"",
"",
]);
});
test("selection field with placeholder", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="trululu" widget="selection" placeholder="Placeholder"/></form>`,
});
expect(".o_field_widget[name='trululu'] select option:first").toHaveText("Placeholder");
expect(".o_field_widget[name='trululu'] select option:first").toHaveValue("false");
});
test("SelectionField in kanban view", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="color" widget="selection" />
</t>
</templates>
</kanban>`,
domain: [["id", "=", 1]],
});
expect(".o_field_widget[name='color'] select").toHaveCount(1, {
message: "SelectionKanbanField widget applied to selection field",
});
expect(".o_field_widget[name='color'] option").toHaveCount(3, {
message: "Three options are displayed (one blank option)",
});
expect(queryAllValues(".o_field_widget[name='color'] option")).toEqual([
"false",
'"red"',
'"black"',
]);
});
test("SelectionField - auto save record in kanban view", async () => {
onRpc("web_save", ({ method }) => expect.step(method));
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="color" widget="selection" />
</t>
</templates>
</kanban>`,
domain: [["id", "=", 1]],
});
await click(".o_field_widget[name='color'] select");
await select('"black"');
await animationFrame();
expect.verifySteps(["web_save"]);
});
test("SelectionField don't open form view on click in kanban view", async function (assert) {
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="color" widget="selection" />
</t>
</templates>
</kanban>`,
domain: [["id", "=", 1]],
selectRecord: () => {
expect.step("selectRecord");
},
});
await click(".o_field_widget[name='color'] select");
await animationFrame();
expect.verifySteps([]);
});
test("SelectionField is disabled if field readonly", async () => {
Partner._fields.color = fields.Selection({
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
string: "Color",
readonly: true,
});
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="color" widget="selection" />
</t>
</templates>
</kanban>
`,
domain: [["id", "=", 1]],
});
expect(".o_field_widget[name='color'] span").toHaveCount(1, {
message: "field should be readonly",
});
});
test("SelectionField is disabled with a readonly attribute", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="color" widget="selection" readonly="1" />
</t>
</templates>
</kanban>
`,
domain: [["id", "=", 1]],
});
expect(".o_field_widget[name='color'] span").toHaveCount(1, {
message: "field should be readonly",
});
});
test("SelectionField in kanban view with handle widget", async () => {
// When records are draggable, most pointerdown events are default prevented. This test
// comes with a fix that blacklists "select" elements, i.e. pointerdown events on such
// elements aren't default prevented, because if they were, the select element can't be
// opened. The test is a bit artificial but there's no other way to test the scenario, as
// using editSelect simply triggers a "change" event, which obviously always works.
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="color" widget="selection"/>
</t>
</templates>
</kanban>`,
});
const events = await pointerDown(".o_kanban_record .o_field_widget[name=color] select");
await animationFrame();
expect(events.get("pointerdown").defaultPrevented).toBe(false);
});

View file

@ -0,0 +1,344 @@
import { NameAndSignature } from "@web/core/signature/name_and_signature";
import { expect, test } from "@odoo/hoot";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { click, drag, edit, queryFirst, waitFor } from "@odoo/hoot-dom";
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
function getUnique(target) {
const src = target.dataset.src;
return new URL(src).searchParams.get("unique");
}
class Partner extends models.Model {
name = fields.Char();
product_id = fields.Many2one({
string: "Product Name",
relation: "product",
});
sign = fields.Binary({ string: "Signature" });
_records = [
{
id: 1,
name: "Pop's Chock'lit",
product_id: 7,
},
];
}
class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
_records = [
{
id: 7,
name: "Veggie Burger",
},
];
}
defineModels([Partner, Product]);
test("signature can be drawn", async () => {
onRpc("/web/sign/get_fonts/", () => ({}));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><field name="sign" widget="signature" /></form>`,
});
expect("div[name=sign] img.o_signature").toHaveCount(0);
expect("div[name=sign] div.o_signature svg").toHaveCount(1, {
message: "should have a valid signature widget",
});
// Click on the widget to open signature modal
await click("div[name=sign] div.o_signature");
await waitFor(".modal .modal-body");
expect(".modal .modal-body .o_web_sign_name_and_signature").toHaveCount(1);
expect(".modal .btn.btn-primary:not([disabled])").toHaveCount(0);
// Use a drag&drop simulation to draw a signature
const { drop } = await drag(".modal .o_web_sign_signature", {
position: {
x: 1,
y: 1,
},
relative: true,
});
await drop(".modal .o_web_sign_signature", {
position: {
x: 10, // Arbitrary value
y: 10, // Arbitrary value
},
relative: true,
});
await animationFrame(); // await owl rendering
expect(".modal .btn.btn-primary:not([disabled])").toHaveCount(1);
// Click on "Adopt and Sign" button
await click(".modal .btn.btn-primary:not([disabled])");
await animationFrame();
expect(".modal").toHaveCount(0);
// The signature widget should now display the signature img
expect("div[name=sign] div.o_signature svg").toHaveCount(0);
expect("div[name=sign] img.o_signature").toHaveCount(1);
const signImgSrc = queryFirst("div[name=sign] img.o_signature").dataset.src;
expect(signImgSrc).not.toMatch("placeholder");
expect(signImgSrc).toMatch(/^data:image\/png;base64,/);
});
test("Set simple field in 'full_name' node option", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup(...arguments);
expect.step(this.props.signature.name);
},
});
onRpc("/web/sign/get_fonts/", () => ({}));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="name"/>
<field name="sign" widget="signature" options="{'full_name': 'name'}" />
</form>`,
});
expect("div[name=sign] div.o_signature svg").toHaveCount(1, {
message: "should have a valid signature widget",
});
// Click on the widget to open signature modal
await click("div[name=sign] div.o_signature");
await animationFrame();
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(1, {
message: 'should open a modal with "Auto" button',
});
expect(".o_web_sign_auto_button").toHaveClass("active", {
message: "'Auto' panel is visible by default",
});
expect.verifySteps(["Pop's Chock'lit"]);
});
test("Set m2o field in 'full_name' node option", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup(...arguments);
expect.step(this.props.signature.name);
},
});
onRpc("/web/sign/get_fonts/", () => ({}));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="product_id"/>
<field name="sign" widget="signature" options="{'full_name': 'product_id'}" />
</form>`,
});
expect("div[name=sign] div.o_signature svg").toHaveCount(1, {
message: "should have a valid signature widget",
});
// Click on the widget to open signature modal
await click("div[name=sign] div.o_signature");
await waitFor(".modal .modal-body");
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(1, {
message: 'should open a modal with "Auto" button',
});
expect.verifySteps(["Veggie Burger"]);
});
test("Set size (width and height) in node option", async () => {
Partner._fields.sign2 = fields.Binary({ string: "Signature" });
Partner._fields.sign3 = fields.Binary({ string: "Signature" });
onRpc("/web/sign/get_fonts/", () => ({}));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="sign" widget="signature" options="{'size': [150,'']}" />
<field name="sign2" widget="signature" options="{'size': ['',100]}" />
<field name="sign3" widget="signature" options="{'size': [120,130]}" />
</form>`,
});
expect(".o_signature").toHaveCount(3);
expect("[name='sign'] .o_signature").toHaveStyle({
width: "150px",
height: "50px",
});
expect("[name='sign2'] .o_signature").toHaveStyle({
width: "300px",
height: "100px",
});
expect("[name='sign3'] .o_signature").toHaveStyle({
width: "120px",
height: "40px",
});
});
test("clicking save manually after changing signature should change the unique of the image src", async () => {
Partner._fields.foo = fields.Char({
onChange() {},
});
const rec = Partner._records.find((rec) => rec.id === 1);
rec.sign = "3 kb";
rec.write_date = "2022-08-05 08:37:00"; // 1659688620000
const fillSignatureField = async (lineToX, lineToY) => {
await click(".o_field_signature img", { visible: false });
await waitFor(".modal .modal-body");
expect(".modal canvas").toHaveCount(1);
const { drop } = await drag(".modal .o_web_sign_signature", {
position: {
x: 1,
y: 1,
},
relative: true,
});
await drop(".modal .o_web_sign_signature", {
position: {
x: lineToX,
y: lineToY,
},
relative: true,
});
await animationFrame();
await click(".modal-footer .btn-primary");
await animationFrame();
};
// 1659692220000, 1659695820000
const lastUpdates = ["2022-08-05 09:37:00", "2022-08-05 10:37:00"];
let index = 0;
onRpc("/web/sign/get_fonts/", () => ({}));
onRpc("web_save", ({ args }) => {
expect.step("web_save");
args[1].write_date = lastUpdates[index];
args[1].sign = "4 kb";
index++;
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="foo" />
<field name="sign" widget="signature" />
</form>`,
});
expect(getUnique(queryFirst(".o_field_signature img"))).toBe("1659688620000");
await fillSignatureField(0, 2);
await click(".o_field_widget[name='foo'] input");
await edit("grrr", { confirm: "Enter" });
await runAllTimers();
await animationFrame();
await clickSave();
expect.verifySteps(["web_save"]);
expect(getUnique(queryFirst(".o_field_signature img"))).toBe("1659692220000");
await fillSignatureField(2, 0);
await clickSave();
expect.verifySteps(["web_save"]);
expect(getUnique(queryFirst(".o_field_signature img"))).toBe("1659695820000");
});
test("save record with signature field modified by onchange", async () => {
const MYB64 = `iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAABRJREFUGFdjZGD438DAwNjACGMAACQlBAMW7JulAAAAAElFTkSuQmCC`;
Partner._fields.foo = fields.Char({
onChange(data) {
data.sign = MYB64;
},
});
const rec = Partner._records.find((rec) => rec.id === 1);
rec.sign = "3 kb";
rec.write_date = "2022-08-05 08:37:00"; // 1659688620000
// 1659692220000, 1659695820000
const lastUpdates = ["2022-08-05 09:37:00", "2022-08-05 10:37:00"];
let index = 0;
onRpc("web_save", ({ args }) => {
expect.step("web_save");
args[1].write_date = lastUpdates[index];
args[1].sign = "4 kb";
index++;
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="foo" />
<field name="sign" widget="signature" />
</form>`,
});
expect(getUnique(queryFirst(".o_field_signature img"))).toBe("1659688620000");
await click("[name='foo'] input");
await edit("grrr", { confirm: "Enter" });
await runAllTimers();
await animationFrame();
expect(queryFirst("div[name=sign] img").dataset.src).toBe(`data:image/png;base64,${MYB64}`);
await clickSave();
expect(getUnique(queryFirst(".o_field_signature img"))).toBe("1659692220000");
expect.verifySteps(["web_save"]);
});
test("signature field should render initials", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup(...arguments);
expect.step(this.getCleanedName());
},
});
onRpc("/web/sign/get_fonts/", () => ({}));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="product_id"/>
<field name="sign" widget="signature" options="{'full_name': 'product_id', 'type': 'initial'}" />
</form>`,
});
expect("div[name=sign] div.o_signature svg").toHaveCount(1, {
message: "should have a valid signature widget",
});
// Click on the widget to open signature modal
await click("div[name=sign] div.o_signature");
await animationFrame();
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(1, {
message: 'should open a modal with "Auto" button',
});
expect.verifySteps(["V.B."]);
});

View file

@ -0,0 +1,225 @@
import { expect, test } from "@odoo/hoot";
import { contains, defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
foo = fields.Char({ default: "My little Foo Value" });
int_field = fields.Integer({ string: "int_field" });
qux = fields.Float({ digits: [16, 1] });
monetary = fields.Monetary({ currency_field: "" });
_records = [{ id: 1, foo: "yop", int_field: 10, qux: 0.44444, monetary: 9.999999 }];
}
defineModels([Partner]);
test("StatInfoField formats decimal precision", async () => {
// sometimes the round method can return numbers such as 14.000001
// when asked to round a number to 2 decimals, as such is the behaviour of floats.
// we check that even in that eventuality, only two decimals are displayed
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<button class="oe_stat_button" name="items" icon="fa-gear">
<field name="qux" widget="statinfo" />
</button>
<button class="oe_stat_button" name="money" icon="fa-money">
<field name="monetary" widget="statinfo" />
</button>
</form>
`,
});
// formatFloat renders according to this.field.digits
expect("button.oe_stat_button .o_field_widget .o_stat_value:eq(0)").toHaveText("0.4", {
message: "Default precision should be [16,1]",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value:eq(1)").toHaveText("10.00", {
message: "Currency decimal precision should be 2",
});
});
test.tags("desktop");
test("StatInfoField in form view on desktop", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="items" type="object" icon="fa-gear">
<field name="int_field" widget="statinfo" />
</button>
</div>
</form>
`,
});
expect("button.oe_stat_button .o_field_widget .o_stat_info").toHaveCount(1, {
message: "should have one stat button",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value").toHaveText("10", {
message: "should have 10 as value",
});
expect("button.oe_stat_button .o_field_widget .o_stat_text").toHaveText("int_field", {
message: "should have 'int_field' as text",
});
});
test.tags("mobile");
test("StatInfoField in form view on mobile", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="items" type="object" icon="fa-gear">
<field name="int_field" widget="statinfo" />
</button>
</div>
</form>
`,
});
await contains(".o-form-buttonbox .o_button_more").click();
expect("button.oe_stat_button .o_field_widget .o_stat_info").toHaveCount(1, {
message: "should have one stat button",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value").toHaveText("10", {
message: "should have 10 as value",
});
expect("button.oe_stat_button .o_field_widget .o_stat_text").toHaveText("int_field", {
message: "should have 'int_field' as text",
});
});
test.tags("desktop");
test("StatInfoField in form view with specific label_field on desktop", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="items" type="object" icon="fa-gear">
<field string="Useful stat button" name="int_field" widget="statinfo" options="{'label_field': 'foo'}" />
</button>
</div>
<group>
<field name="foo" invisible="1" />
</group>
</sheet>
</form>
`,
});
expect("button.oe_stat_button .o_field_widget .o_stat_info").toHaveCount(1, {
message: "should have one stat button",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value").toHaveText("10", {
message: "should have 10 as value",
});
expect("button.oe_stat_button .o_field_widget .o_stat_text").toHaveText("yop", {
message: "should have 'yop' as text, since it is the value of field foo",
});
});
test.tags("mobile");
test("StatInfoField in form view with specific label_field on mobile", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="items" type="object" icon="fa-gear">
<field string="Useful stat button" name="int_field" widget="statinfo" options="{'label_field': 'foo'}" />
</button>
</div>
<group>
<field name="foo" invisible="1" />
</group>
</sheet>
</form>
`,
});
await contains(".o-form-buttonbox .o_button_more").click();
expect("button.oe_stat_button .o_field_widget .o_stat_info").toHaveCount(1, {
message: "should have one stat button",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value").toHaveText("10", {
message: "should have 10 as value",
});
expect("button.oe_stat_button .o_field_widget .o_stat_text").toHaveText("yop", {
message: "should have 'yop' as text, since it is the value of field foo",
});
});
test.tags("desktop");
test("StatInfoField in form view with no label on desktop", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="items" type="object" icon="fa-gear">
<field string="Useful stat button" name="int_field" widget="statinfo" nolabel="1" />
</button>
</div>
</sheet>
</form>
`,
});
expect("button.oe_stat_button .o_field_widget .o_stat_info").toHaveCount(1, {
message: "should have one stat button",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value").toHaveText("10", {
message: "should have 10 as value",
});
expect("button.oe_stat_button .o_field_widget .o_stat_text").toHaveCount(0, {
message: "should not have any label",
});
});
test.tags("mobile");
test("StatInfoField in form view with no label on mobile", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" name="items" type="object" icon="fa-gear">
<field string="Useful stat button" name="int_field" widget="statinfo" nolabel="1" />
</button>
</div>
</sheet>
</form>
`,
});
await contains(".o-form-buttonbox .o_button_more").click();
expect("button.oe_stat_button .o_field_widget .o_stat_info").toHaveCount(1, {
message: "should have one stat button",
});
expect("button.oe_stat_button .o_field_widget .o_stat_value").toHaveText("10", {
message: "should have 10 as value",
});
expect("button.oe_stat_button .o_field_widget .o_stat_text").toHaveCount(0, {
message: "should not have any label",
});
});

View file

@ -0,0 +1,528 @@
import { expect, test } from "@odoo/hoot";
import { click, press, queryAll, queryAllTexts, queryFirst } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
foo = fields.Char({ string: "Foo" });
sequence = fields.Integer({ string: "Sequence", searchable: true });
selection = fields.Selection({
string: "Selection",
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
});
_records = [
{ id: 1, foo: "yop", selection: "blocked" },
{ id: 2, foo: "blip", selection: "normal" },
{ id: 4, foo: "abc", selection: "done" },
{ id: 3, foo: "gnap" },
{ id: 5, foo: "blop" },
];
}
defineModels([Partner]);
test("StateSelectionField in form view", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="state_selection"/>
</group>
</sheet>
</form>
`,
resId: 1,
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_red").toHaveCount(1, {
message: "should have one red status since selection is the second, blocked state",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_green").toHaveCount(0, {
message: "should not have one green status since selection is the second, blocked state",
});
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should not be a dropdown" });
// Click on the status button to make the dropdown appear
await click(".o_field_widget.o_field_state_selection .o_status");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(1, { message: "there should be a dropdown" });
expect(".o-dropdown--menu .dropdown-item").toHaveCount(3, {
message: "there should be three options in the dropdown",
});
expect(".o-dropdown--menu .dropdown-item:nth-child(2)").toHaveClass("active", {
message: "current value has a checkmark",
});
// Click on the first option, "Normal"
await click(".o-dropdown--menu .dropdown-item");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(0, {
message: "there should not be a dropdown anymore",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_red").toHaveCount(0, {
message: "should not have one red status since selection is the first, normal state",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_green").toHaveCount(0, {
message: "should not have one green status since selection is the first, normal state",
});
expect(".o_field_widget.o_field_state_selection span.o_status").toHaveCount(1, {
message: "should have one grey status since selection is the first, normal state",
});
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should still not be a dropdown" });
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_red").toHaveCount(0, {
message: "should still not have one red status since selection is the first, normal state",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_green").toHaveCount(0, {
message:
"should still not have one green status since selection is the first, normal state",
});
expect(".o_field_widget.o_field_state_selection span.o_status").toHaveCount(1, {
message: "should still have one grey status since selection is the first, normal state",
});
// Click on the status button to make the dropdown appear
await click(".o_field_widget.o_field_state_selection .o_status");
await animationFrame();
expect(".o-dropdown--menu .dropdown-item").toHaveCount(3, {
message: "there should be three options in the dropdown",
});
// Click on the last option, "Done"
await click(".o-dropdown--menu .dropdown-item:last-child");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(0, {
message: "there should not be a dropdown anymore",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_red").toHaveCount(0, {
message: "should not have one red status since selection is the third, done state",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_green").toHaveCount(1, {
message: "should have one green status since selection is the third, done state",
});
// save
await click(".o_form_button_save");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(0, {
message: "there should still not be a dropdown anymore",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_red").toHaveCount(0, {
message: "should still not have one red status since selection is the third, done state",
});
expect(".o_field_widget.o_field_state_selection span.o_status.o_status_green").toHaveCount(1, {
message: "should still have one green status since selection is the third, done state",
});
});
test("StateSelectionField with readonly modifier", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `<form><field name="selection" widget="state_selection" readonly="1"/></form>`,
resId: 1,
});
expect(".o_field_state_selection").toHaveClass("o_readonly_modifier");
expect(".dropdown-menu:visible").not.toHaveCount();
await click(".o_field_state_selection span.o_status");
await animationFrame();
expect(".dropdown-menu:visible").not.toHaveCount();
});
test("StateSelectionField for form view with hide_label option", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="selection" widget="state_selection" options="{'hide_label': False}"/>
</form>
`,
resId: 1,
});
expect(".o_status_label").toHaveCount(1);
});
test("StateSelectionField for list view with hide_label option", async () => {
onRpc("has_group", () => true);
Partner._fields.graph_type = fields.Selection({
type: "selection",
selection: [
["line", "Line"],
["bar", "Bar"],
],
});
Partner._records[0].graph_type = "bar";
Partner._records[1].graph_type = "line";
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list>
<field name="graph_type" widget="state_selection" options="{'hide_label': True}"/>
<field name="selection" widget="state_selection" options="{'hide_label': False}"/>
</list>
`,
});
expect(".o_state_selection_cell .o_field_state_selection span.o_status").toHaveCount(10, {
message: "should have ten status selection widgets",
});
const selector =
".o_state_selection_cell .o_field_state_selection[name=selection] span.o_status_label";
expect(selector).toHaveCount(5, { message: "should have five label on selection widgets" });
expect(`${selector}:contains("Done")`).toHaveCount(1, {
message: "should have one Done status label",
});
expect(`${selector}:contains("Normal")`).toHaveCount(3, {
message: "should have three Normal status label",
});
expect(
".o_state_selection_cell .o_field_state_selection[name=graph_type] span.o_status"
).toHaveCount(5, { message: "should have five status selection widgets" });
expect(
".o_state_selection_cell .o_field_state_selection[name=graph_type] span.o_status_label"
).toHaveCount(0, { message: "should not have status label in selection widgets" });
});
test("StateSelectionField in editable list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list editable="bottom">
<field name="foo"/>
<field name="selection" widget="state_selection"/>
</list>
`,
});
expect(".o_state_selection_cell .o_field_state_selection span.o_status").toHaveCount(5, {
message: "should have five status selection widgets",
});
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_red"
).toHaveCount(1, { message: "should have one red status" });
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_green"
).toHaveCount(1, { message: "should have one green status" });
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should not be a dropdown" });
// Click on the status button to make the dropdown appear
let cell = queryFirst("tbody td.o_state_selection_cell");
await click(".o_state_selection_cell .o_field_state_selection span.o_status");
await animationFrame();
expect(cell.parentElement).not.toHaveClass("o_selected_row", {
message: "should not be in edit mode since we clicked on the state selection widget",
});
expect(".o-dropdown--menu").toHaveCount(1, { message: "there should be a dropdown" });
expect(".o-dropdown--menu .dropdown-item").toHaveCount(3, {
message: "there should be three options in the dropdown",
});
// Click on the first option, "Normal"
await click(".o-dropdown--menu .dropdown-item");
await animationFrame();
expect(".o_state_selection_cell .o_field_state_selection span.o_status").toHaveCount(5, {
message: "should still have five status selection widgets",
});
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_red"
).toHaveCount(0, { message: "should now have no red status" });
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_green"
).toHaveCount(1, { message: "should still have one green status" });
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should not be a dropdown" });
expect("tr.o_selected_row").toHaveCount(0, { message: "should not be in edit mode" });
// switch to edit mode and check the result
cell = queryFirst("tbody td.o_state_selection_cell");
await click(cell);
await animationFrame();
expect(cell.parentElement).toHaveClass("o_selected_row", {
message: "should now be in edit mode",
});
expect(".o_state_selection_cell .o_field_state_selection span.o_status").toHaveCount(5, {
message: "should still have five status selection widgets",
});
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_red"
).toHaveCount(0, { message: "should now have no red status" });
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_green"
).toHaveCount(1, { message: "should still have one green status" });
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should not be a dropdown" });
// Click on the status button to make the dropdown appear
await click(".o_state_selection_cell .o_field_state_selection span.o_status");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(1, { message: "there should be a dropdown" });
expect(".o-dropdown--menu .dropdown-item").toHaveCount(3, {
message: "there should be three options in the dropdown",
});
// Click on another row
const lastCell = queryAll("tbody td.o_state_selection_cell")[4];
await click(lastCell);
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(0, {
message: "there should not be a dropdown anymore",
});
const firstCell = queryFirst("tbody td.o_state_selection_cell");
expect(firstCell.parentElement).not.toHaveClass("o_selected_row", {
message: "first row should not be in edit mode anymore",
});
expect(lastCell.parentElement).toHaveClass("o_selected_row", {
message: "last row should be in edit mode",
});
// Click on the third status button to make the dropdown appear
await click(".o_state_selection_cell .o_field_state_selection span.o_status:eq(2)");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(1, "there should be a dropdown".msg);
expect(".o-dropdown--menu .dropdown-item").toHaveCount(3, {
message: "there should be three options in the dropdown",
});
// Click on the last option, "Done"
await click(".o-dropdown--menu .dropdown-item:last-child");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(0, {
message: "there should not be a dropdown anymore",
});
expect(".o_state_selection_cell .o_field_state_selection span.o_status").toHaveCount(5, {
message: "should still have five status selection widgets",
});
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_red"
).toHaveCount(0, { message: "should still have no red status" });
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_green"
).toHaveCount(2, { message: "should now have two green status" });
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should not be a dropdown" });
// save
await click(".o_control_panel_main_buttons .o_list_button_save");
await animationFrame();
expect(".o_state_selection_cell .o_field_state_selection span.o_status").toHaveCount(5, {
message: "should have five status selection widgets",
});
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_red"
).toHaveCount(0, { message: "should have no red status" });
expect(
".o_state_selection_cell .o_field_state_selection span.o_status.o_status_green"
).toHaveCount(2, { message: "should have two green status" });
expect(".o-dropdown--menu").toHaveCount(0, { message: "there should not be a dropdown" });
});
test('StateSelectionField edited by the smart actions "Set kanban state as <state name>"', async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="selection" widget="state_selection"/>
</form>
`,
resId: 1,
});
expect(".o_status_red").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
expect(`.o_command:contains("Set kanban state as Normal\nALT + D")`).toHaveCount(1);
const doneItem = `.o_command:contains("Set kanban state as Done\nALT + G")`;
expect(doneItem).toHaveCount(1);
await click(doneItem);
await animationFrame();
expect(".o_status_green").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
expect(`.o_command:contains("Set kanban state as Normal\nALT + D")`).toHaveCount(1);
expect(`.o_command:contains("Set kanban state as Blocked\nALT + F")`).toHaveCount(1);
expect(`.o_command:contains("Set kanban state as Done\nALT + G")`).toHaveCount(0);
});
test("StateSelectionField uses legend_* fields", async () => {
Partner._fields.legend_normal = fields.Char();
Partner._fields.legend_blocked = fields.Char();
Partner._fields.legend_done = fields.Char();
Partner._records[0].legend_normal = "Custom normal";
Partner._records[0].legend_blocked = "Custom blocked";
Partner._records[0].legend_done = "Custom done";
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="legend_normal" invisible="1" />
<field name="legend_blocked" invisible="1" />
<field name="legend_done" invisible="1" />
<field name="selection" widget="state_selection"/>
</group>
</sheet>
</form>
`,
resId: 1,
});
await click(".o_status");
await animationFrame();
expect(queryAllTexts(".o-dropdown--menu .dropdown-item")).toEqual([
"Custom normal",
"Custom blocked",
"Custom done",
]);
await click(".dropdown-item .o_status");
await animationFrame();
await click(".o_status");
await animationFrame();
expect(queryAllTexts(".o-dropdown--menu .dropdown-item")).toEqual([
"Custom normal",
"Custom blocked",
"Custom done",
]);
});
test("works when required in a readonly view", async () => {
Partner._records[0].selection = "normal";
Partner._records = [Partner._records[0]];
onRpc("web_save", ({ method }) => expect.step(method));
await mountView({
type: "kanban",
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="card">
<field name="selection" widget="state_selection" required="1"/>
</t>
</templates>
</kanban>
`,
});
expect(".o_status_label").toHaveCount(0);
await click(".o_field_state_selection button");
await animationFrame();
await click(".dropdown-item:eq(2)");
await animationFrame();
expect.verifySteps(["web_save"]);
expect(".o_field_state_selection span").toHaveClass("o_status_green");
});
test("StateSelectionField - auto save record when field toggled", async () => {
onRpc("web_save", ({ method }) => expect.step(method));
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="state_selection"/>
</group>
</sheet>
</form>
`,
resId: 1,
});
await click(".o_field_widget.o_field_state_selection .o_status");
await animationFrame();
await click(".dropdown-menu .dropdown-item:last-child");
await animationFrame();
expect.verifySteps(["web_save"]);
});
test("StateSelectionField - prevent auto save with autosave option", async () => {
onRpc("write", ({ method }) => expect.step(method));
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="state_selection" options="{'autosave': False}"/>
</group>
</sheet>
</form>
`,
resId: 1,
});
await click(".o_field_widget.o_field_state_selection .o_status");
await animationFrame();
await click(".dropdown-menu .dropdown-item:last-child");
await animationFrame();
expect.verifySteps([]);
});
test("StateSelectionField - hotkey handling when there are more than 3 options available", async () => {
Partner._fields.selection = fields.Selection({
string: "Selection",
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
["martin", "Martin"],
["martine", "Martine"],
],
});
Partner._records[0].selection = null;
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="selection" widget="state_selection" options="{'autosave': False}"/>
</group>
</sheet>
</form>
`,
resId: 1,
});
await click(".o_field_widget.o_field_state_selection .o_status");
await animationFrame();
expect(".dropdown-menu .dropdown-item").toHaveCount(5, {
message: "Five choices are displayed",
});
await press(["control", "k"]);
await animationFrame();
expect(".o_command#o_command_2").toHaveText("Set kanban state as Done\nALT + G", {
message: "hotkey and command are present",
});
expect(".o_command#o_command_4").toHaveText("Set kanban state as Martine", {
message: "no hotkey is present, but the command exists",
});
await click(".o_command#o_command_2");
await animationFrame();
expect(".o_field_state_selection .o_status").toHaveClass("o_status_green", {
message: "green color and Done state have been set",
});
});

View file

@ -0,0 +1,810 @@
import { expect, test } from "@odoo/hoot";
import { click, edit, press, queryAllTexts, queryAttribute, queryFirst } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
clickSave,
defineActions,
defineModels,
fields,
getDropdownMenu,
getService,
models,
mockService,
mountView,
mountWithCleanup,
onRpc,
serverState,
} from "@web/../tests/web_test_helpers";
import { EventBus } from "@odoo/owl";
import { WebClient } from "@web/webclient/webclient";
class Partner extends models.Model {
name = fields.Char();
foo = fields.Char({ default: "My little Foo Value" });
bar = fields.Boolean({ default: true });
int_field = fields.Integer();
qux = fields.Float({ digits: [16, 1] });
p = fields.One2many({
relation: "partner",
relation_field: "trululu",
});
trululu = fields.Many2one({ relation: "partner" });
product_id = fields.Many2one({ relation: "product" });
color = fields.Selection({
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
});
user_id = fields.Many2one({ relation: "users" });
_records = [
{
id: 1,
name: "first record",
bar: true,
foo: "yop",
int_field: 10,
qux: 0.44,
p: [],
trululu: 4,
user_id: 17,
},
{
id: 2,
name: "second record",
bar: true,
foo: "blip",
int_field: 9,
qux: 13,
p: [],
trululu: 1,
product_id: 37,
user_id: 17,
},
{ id: 4, name: "aaa", bar: false },
];
}
class Product extends models.Model {
name = fields.Char();
_records = [
{ id: 37, name: "xphone" },
{ id: 41, name: "xpad" },
];
}
class Users extends models.Model {
name = fields.Char();
partner_ids = fields.One2many({
relation: "partner",
relation_field: "user_id",
});
_records = [
{ id: 17, name: "Aline", partner_ids: [1, 2] },
{ id: 19, name: "Christine" },
];
}
defineModels([Partner, Product, Users]);
test("static statusbar widget on many2one field", async () => {
Partner._fields.trululu = fields.Many2one({
relation: "partner",
domain: "[('bar', '=', True)]",
});
Partner._records[1].bar = false;
onRpc("search_read", ({ kwargs }) => expect.step(kwargs.fields.toString()));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" />
</header>
</form>
`,
});
// search_read should only fetch field display_name
expect.verifySteps(["display_name"]);
expect(".o_statusbar_status button:not(.dropdown-toggle)").toHaveCount(2);
expect(".o_statusbar_status button:disabled").toHaveCount(5);
expect('.o_statusbar_status button[data-value="4"]').toHaveClass("o_arrow_button_current");
});
test("folded statusbar widget on selection field has selected value in the toggler", async () => {
mockService("ui", (env) => {
Object.defineProperty(env, "isSmall", {
value: true,
});
return {
bus: new EventBus(),
size: 0,
isSmall: true,
};
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="color" widget="statusbar" />
</header>
</form>
`,
});
expect(".o_statusbar_status button.dropdown-toggle:contains(Red)").toHaveCount(1);
});
test("static statusbar widget on many2one field with domain", async () => {
expect.assertions(1);
serverState.userId = 17;
onRpc("search_read", ({ kwargs }) => {
expect(kwargs.domain).toEqual(["|", ["id", "=", 4], ["user_id", "=", 17]], {
message: "search_read should sent the correct domain",
});
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" domain="[('user_id', '=', uid)]" />
</header>
</form>
`,
});
});
test("clickable statusbar widget on many2one field", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'clickable': 1}" />
</header>
</form>
`,
});
expect(".o_statusbar_status button[data-value='4']").toHaveClass("o_arrow_button_current");
expect(".o_statusbar_status button[data-value='4']").not.toBeEnabled();
expect(
".o_statusbar_status button.btn:not(.dropdown-toggle):not(:disabled):not(.o_arrow_button_current)"
).toHaveCount(2);
await click(
".o_statusbar_status button.btn:not(.dropdown-toggle):not(:disabled):not(.o_arrow_button_current):eq(1)"
);
await animationFrame();
expect(".o_statusbar_status button[data-value='1']").toHaveClass("o_arrow_button_current");
expect(".o_statusbar_status button[data-value='1']").not.toBeEnabled();
});
test("statusbar with no status", async () => {
Partner._records[1].product_id = false;
Product._records = [];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" />
</header>
</form>
`,
});
expect(".o_statusbar_status").not.toHaveClass("o_field_empty");
expect(".o_statusbar_status > :not(.d-none)").toHaveCount(0, {
message: "statusbar widget should be empty",
});
});
test("statusbar with tooltip for help text", async () => {
Partner._fields.product_id = fields.Many2one({
relation: "product",
help: "some info about the field",
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" />
</header>
</form>
`,
});
expect(".o_statusbar_status").not.toHaveClass("o_field_empty");
expect(".o_field_statusbar").toHaveAttribute("data-tooltip-info");
const tooltipInfo = JSON.parse(queryAttribute(".o_field_statusbar", "data-tooltip-info"));
expect(tooltipInfo.field.help).toBe("some info about the field", {
message: "tooltip text is present on the field",
});
});
test("statusbar with required modifier", async () => {
mockService("notification", {
add() {
expect.step("Show error message");
return () => {};
},
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" required="1"/>
</header>
</form>
`,
});
await click(".o_form_button_save");
await animationFrame();
expect(".o_form_editable").toHaveCount(1, { message: "view should still be in edit" });
// should display an 'invalid fields' notificationaveCount(1, { message: "view should still be in edit" });
expect.verifySteps(["Show error message"]);
});
test("statusbar with no value in readonly", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" />
</header>
</form>
`,
});
expect(".o_statusbar_status").not.toHaveClass("o_field_empty");
expect(".o_statusbar_status button:visible").toHaveCount(2);
});
test("statusbar with domain but no value (create mode)", async () => {
Partner._fields.trululu = fields.Many2one({
relation: "partner",
domain: "[('bar', '=', True)]",
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" />
</header>
</form>
`,
});
expect(".o_statusbar_status button:disabled").toHaveCount(5);
});
test("clickable statusbar should change m2o fetching domain in edit mode", async () => {
Partner._fields.trululu = fields.Many2one({
relation: "partner",
domain: "[('bar', '=', True)]",
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'clickable': 1}" />
</header>
</form>
`,
});
expect(".o_statusbar_status button:not(.dropdown-toggle)").toHaveCount(3);
await click(".o_statusbar_status button:not(.dropdown-toggle):eq(-1)");
await animationFrame();
expect(".o_statusbar_status button:not(.dropdown-toggle)").toHaveCount(2);
});
test("statusbar fold_field option and statusbar_visible attribute", async () => {
Partner._records[0].bar = false;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'fold_field': 'bar'}" />
<field name="color" widget="statusbar" statusbar_visible="red" />
</header>
</form>
`,
});
await click(".o_statusbar_status .dropdown-toggle:not(.d-none)");
await animationFrame();
expect(".o_statusbar_status:first button:visible").toHaveCount(3);
expect(".o_statusbar_status:last button:visible").toHaveCount(1);
expect(".o_statusbar_status button").not.toBeEnabled({
message: "no status bar buttons should be enabled",
});
});
test("statusbar: choose an item from the folded menu", async () => {
Partner._records[0].bar = false;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'clickable': '1', 'fold_field': 'bar'}" />
</header>
</form>
`,
});
expect("[aria-checked='true']").toHaveText("aaa", {
message: "default status is 'aaa'",
});
expect(".o_statusbar_status .dropdown-toggle.o_arrow_button").toHaveText("...", {
message: "button has the correct text",
});
await click(".o_statusbar_status .dropdown-toggle:not(.d-none)");
await animationFrame();
await click(".o-dropdown--menu .dropdown-item");
await animationFrame();
expect("[aria-checked='true']").toHaveText("second record", {
message: "status has changed to the selected dropdown item",
});
});
test("statusbar with dynamic domain", async () => {
Partner._fields.trululu = fields.Many2one({
relation: "partner",
domain: "[('int_field', '>', qux)]",
});
Partner._records[2].int_field = 0;
onRpc("search_read", () => {
rpcCount++;
});
let rpcCount = 0;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" />
</header>
<field name="qux" />
<field name="foo" />
</form>
`,
});
expect(".o_statusbar_status button:disabled").toHaveCount(6);
expect(rpcCount).toBe(1, { message: "should have done 1 search_read rpc" });
await click(".o_field_widget[name='qux'] input");
await edit(9.5, { confirm: "enter" });
await runAllTimers();
await animationFrame();
expect(".o_statusbar_status button:disabled").toHaveCount(5);
expect(rpcCount).toBe(2, { message: "should have done 1 more search_read rpc" });
await edit("hey", { confirm: "enter" });
await animationFrame();
expect(rpcCount).toBe(2, { message: "should not have done 1 more search_read rpc" });
});
test(`statusbar edited by the smart action "Move to stage..."`, async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'clickable': '1'}"/>
</header>
</form>
`,
resId: 1,
});
expect(".o_field_widget").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
await click(`.o_command:contains("Move to Trululu")`);
await animationFrame();
expect(queryAllTexts(".o_command")).toEqual(["first record", "second record", "aaa"]);
await click("#o_command_2");
await animationFrame();
});
test("smart actions are unavailable if readonly", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" readonly="1"/>
</header>
</form>
`,
resId: 1,
});
expect(".o_field_widget").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
const moveStages = queryAllTexts(".o_command");
expect(moveStages).not.toInclude("Move to Trululu\nALT + SHIFT + X");
expect(moveStages).not.toInclude("Move to next\nALT + X");
});
test("hotkeys are unavailable if readonly", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" readonly="1"/>
</header>
</form>
`,
resId: 1,
});
expect(".o_field_widget").toHaveCount(1);
await press(["alt", "shift", "x"]); // Move to stage...
await animationFrame();
expect(".modal").toHaveCount(0, { message: "command palette should not open" });
await press(["alt", "x"]); // Move to next
await animationFrame();
expect(".modal").toHaveCount(0, { message: "command palette should not open" });
});
test("auto save record when field toggled", async () => {
onRpc("web_save", () => expect.step("web_save"));
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="trululu" widget="statusbar" options="{'clickable': 1}" />
</header>
</form>
`,
});
await click(
".o_statusbar_status button.btn:not(.dropdown-toggle):not(:disabled):not(.o_arrow_button_current):eq(-1)"
);
await animationFrame();
expect.verifySteps(["web_save"]);
});
test("For the same record, a single rpc is done to recover the specialData", async () => {
Partner._views = {
"list,3": '<list><field name="display_name"/></list>',
"search,9": `<search></search>`,
form: `
<form>
<header>
<field name="trululu" widget="statusbar" readonly="1"/>
</header>
</form>
`,
};
defineActions([
{
id: 1,
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[false, "form"],
],
},
]);
onRpc("has_group", () => true);
onRpc("search_read", () => expect.step("search_read"));
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
await click(".o_data_row .o_data_cell");
await animationFrame();
expect.verifySteps(["search_read"]);
await click(".o_back_button");
await animationFrame();
await click(".o_data_row .o_data_cell");
await animationFrame();
expect.verifySteps([]);
});
test("open form with statusbar, leave and come back to another one with other domain", async () => {
Partner._views = {
"list,3": '<list><field name="display_name"/></list>',
"search,9": `<search/>`,
form: `
<form>
<header>
<field name="trululu" widget="statusbar" domain="[['id', '>', id]]" readonly="1"/>
</header>
</form>
`,
};
defineActions([
{
id: 1,
name: "Partners",
res_model: "partner",
views: [
[false, "list"],
[false, "form"],
],
},
]);
onRpc("has_group", () => true);
onRpc("search_read", () => expect.step("search_read"));
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
// open first record
await click(".o_data_row .o_data_cell");
await animationFrame();
expect.verifySteps(["search_read"]);
// go back and open second record
await click(".o_back_button");
await animationFrame();
await click(".o_data_row:eq(1) .o_data_cell");
await animationFrame();
expect.verifySteps(["search_read"]);
});
test("clickable statusbar with readonly modifier set to false is editable", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" options="{'clickable': true}" readonly="False"/>
</header>
</form>
`,
});
expect(".o_statusbar_status button:visible").toHaveCount(2);
expect(".o_statusbar_status button[disabled][aria-checked='false']:visible").toHaveCount(0);
});
test("clickable statusbar with readonly modifier set to true is not editable", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" options="{'clickable': true}" readonly="True"/>
</header>
</form>
`,
});
expect(".o_statusbar_status button[disabled]:visible").toHaveCount(2);
});
test("non-clickable statusbar with readonly modifier set to false is not editable", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 2,
arch: /* xml */ `
<form>
<header>
<field name="product_id" widget="statusbar" options="{'clickable': false}" readonly="False"/>
</header>
</form>
`,
});
expect(".o_statusbar_status button[disabled]:visible").toHaveCount(2);
});
test("last status bar button have a border radius (no arrow shape) on the right side when a prior folded stage gets selected", async () => {
class Stage extends models.Model {
name = fields.Char();
folded = fields.Boolean({ default: false });
_records = [
{ id: 1, name: "New" },
{ id: 2, name: "In Progress", folded: true },
{ id: 3, name: "Done" },
];
}
class Task extends models.Model {
status = fields.Many2one({ relation: "stage" });
_records = [
{ id: 1, status: 1 },
{ id: 2, status: 2 },
{ id: 3, status: 3 },
];
}
defineModels([Stage, Task]);
await mountView({
type: "form",
resModel: "task",
resId: 3,
arch: /* xml */ `
<form>
<header>
<field name="status" widget="statusbar" options="{'clickable': true, 'fold_field': 'folded'}" />
</header>
</form>
`,
});
await click(".o_statusbar_status .dropdown-toggle:not(.d-none)");
await animationFrame();
await click(
queryFirst(".dropdown-item", {
root: getDropdownMenu(".o_statusbar_status .dropdown-toggle:not(.d-none)"),
})
);
await animationFrame();
expect(".o_statusbar_status button[data-value='3']").not.toHaveStyle({
borderTopRightRadius: "0px",
});
expect(".o_statusbar_status button[data-value='3']").toHaveClass("o_first");
});
test.tags("desktop");
test("correctly load statusbar when dynamic domain changes", async () => {
class Stage extends models.Model {
name = fields.Char();
folded = fields.Boolean({ default: false });
project_ids = fields.Many2many({ relation: "project" });
_records = [
{ id: 1, name: "Stage Project 1", project_ids: [1] },
{ id: 2, name: "Stage Project 2", project_ids: [2] },
];
}
class Project extends models.Model {
display_name = fields.Char();
_records = [
{ id: 1, display_name: "Project 1" },
{ id: 2, display_name: "Project 2" },
];
}
class Task extends models.Model {
status = fields.Many2one({ relation: "stage" });
project_id = fields.Many2one({ relation: "project" });
_records = [{ id: 1, project_id: 1, status: 1 }];
}
Task._onChanges.project_id = (obj) => {
obj.status = obj.project_id === 1 ? 1 : 2;
};
defineModels([Stage, Project, Task]);
onRpc("search_read", ({ kwargs }) => expect.step(JSON.stringify(kwargs.domain)));
await mountView({
type: "form",
resModel: "task",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="status" widget="statusbar" domain="[('project_ids', 'in', project_id)]" />
</header>
<field name="project_id"/>
</form>
`,
});
expect(queryAllTexts(".o_statusbar_status button:not(.d-none)")).toEqual(["Stage Project 1"]);
expect.verifySteps(['["|",["id","=",1],["project_ids","in",1]]']);
await click(`[name="project_id"] .dropdown input`);
await animationFrame();
await click(`[name="project_id"] .dropdown .dropdown-menu .ui-menu-item:contains("Project 2")`);
await animationFrame();
expect(queryAllTexts(".o_statusbar_status button:not(.d-none)")).toEqual(["Stage Project 2"]);
expect.verifySteps(['["|",["id","=",2],["project_ids","in",2]]']);
await clickSave();
expect(queryAllTexts(".o_statusbar_status button:not(.d-none)")).toEqual(["Stage Project 2"]);
expect.verifySteps([]);
});
test('"status" with no stages does not crash command palette', async () => {
class Stage extends models.Model {
name = fields.Char();
_records = []; // no stages
}
class Task extends models.Model {
status = fields.Many2one({ relation: "stage" });
_records = [{ id: 1, status: false }];
}
defineModels([Stage, Task]);
await mountView({
type: "form",
resModel: "task",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="status" widget="statusbar" options="{'withCommand': true, 'clickable': true}"/>
</header>
</form>
`,
});
// Open the command palette (Ctrl+K)
await press(["control", "k"]);
await animationFrame();
const commands = queryAllTexts(".o_command");
expect(commands).not.toInclude("Move to next Stage");
});

View file

@ -0,0 +1,267 @@
import { expect, test } from "@odoo/hoot";
import { press, queryAll, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
onRpc,
serverState,
} from "@web/../tests/web_test_helpers";
function fieldTextArea(name) {
return contains(`.o_field_widget[name='${name}'] textarea`);
}
class Product extends models.Model {
description = fields.Text();
}
defineModels([Product]);
onRpc("has_group", () => true);
test("basic rendering", async () => {
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="description"/></form>',
});
expect(".o_field_text textarea").toHaveCount(1);
expect(".o_field_text textarea").toHaveValue("Description as text");
});
test("doesn't have a scrollbar with long content", async () => {
Product._records = [{ id: 1, description: "L\no\nn\ng\nD\ne\ns\nc\nr\ni\np\nt\ni\no\nn\n" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="description"/></form>',
});
const textarea = queryOne(".o_field_text textarea");
expect(textarea.clientHeight).toBe(textarea.scrollHeight);
});
test("render following an onchange", async () => {
Product._fields.name = fields.Char({
onChange: (record) => {
expect.step("onchange");
record.description = "Content ".repeat(100); // long text
},
});
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="description"/><field name="name"/></form>`,
});
const textarea = queryOne(".o_field_text textarea");
const initialHeight = textarea.offsetHeight;
await fieldInput("name").edit("Let's trigger the onchange");
await animationFrame();
expect(textarea.offsetHeight).toBeGreaterThan(initialHeight);
await fieldTextArea("description").edit("Description as text");
expect(textarea.offsetHeight).toBe(initialHeight);
expect(textarea.clientHeight).toBe(textarea.scrollHeight);
expect.verifySteps(["onchange"]);
});
test("no scroll bar in editable list", async () => {
Product._records = [{ id: 1, description: "L\no\nn\ng\nD\ne\ns\nc\nr\ni\np\nt\ni\no\nn\n" }];
await mountView({
type: "list",
resModel: "product",
arch: '<list editable="top"><field name="description"/></list>',
});
await contains(".o_data_row .o_data_cell").click();
const textarea = queryOne(".o_field_text textarea");
expect(textarea.clientHeight).toBe(textarea.scrollHeight);
await contains("tr:not(.o_data_row)").click();
const cell = queryOne(".o_data_row .o_data_cell");
expect(cell.clientHeight).toBe(cell.scrollHeight);
});
test("set row on text fields", async () => {
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="description" rows="40"/><field name="description"/></form>`,
});
const textareas = queryAll(".o_field_text textarea");
expect(textareas[0].rows).toBe(40);
expect(textareas[0].clientHeight).toBeGreaterThan(textareas[1].clientHeight);
});
test("is translatable", async () => {
Product._fields.description.translate = true;
Product._records = [{ id: 1, description: "Description as text" }];
serverState.multiLang = true;
onRpc("get_installed", () => [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
]);
onRpc("get_field_translations", () => [
[
{ lang: "en_US", source: "Description as text", value: "Description as text" },
{
lang: "fr_BE",
source: "Description as text",
value: "Description sous forme de texte",
},
],
{ translation_type: "text", translation_show_source: false },
]);
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><sheet><group><field name="description"/></group></sheet></form>`,
});
expect(".o_field_text textarea").toHaveClass("o_field_translate");
await contains(".o_field_text textarea").click();
expect(".o_field_text .btn.o_field_translate").toHaveCount(1);
await contains(".o_field_text .btn.o_field_translate").click();
expect(".modal").toHaveCount(1);
});
test("is translatable on new record", async () => {
Product._fields.description.translate = true;
Product._records = [{ id: 1, description: "Description as text" }];
serverState.multiLang = true;
await mountView({
type: "form",
resModel: "product",
arch: `<form><sheet><group><field name="description"/></group></sheet></form>`,
});
expect(".o_field_text .btn.o_field_translate").toHaveCount(1);
});
test("press enter inside editable list", async () => {
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "list",
resModel: "product",
arch: `
<list editable="top">
<field name="description" />
</list>`,
});
await contains(".o_data_row .o_data_cell").click();
expect("textarea.o_input").toHaveCount(1);
expect("textarea.o_input").toHaveValue("Description as text");
expect("textarea.o_input").toBeFocused();
expect("textarea.o_input").toHaveValue("Description as text");
// clear selection before enter
await fieldTextArea("description").press(["right", "Enter"]);
expect("textarea.o_input").toHaveValue("Description as text\n");
expect("textarea.o_input").toBeFocused();
expect("tr.o_data_row").toHaveCount(1);
});
test("in editable list view", async () => {
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "list",
resModel: "product",
arch: '<list editable="top"><field name="description"/></list>',
});
await contains(".o_list_button_add").click();
expect("textarea").toBeFocused();
});
test.tags("desktop");
test("with dynamic placeholder", async () => {
onRpc("mail_allowed_qweb_expressions", () => []);
Product._fields.placeholder = fields.Char({ default: "product" });
await mountView({
type: "form",
resModel: "product",
arch: `
<form>
<field name="placeholder" invisible="1"/>
<sheet>
<group>
<field
name="description"
options="{
'dynamic_placeholder': true,
'dynamic_placeholder_model_reference_field': 'placeholder'
}"
/>
</group>
</sheet>
</form>`,
});
expect(".o_popover .o_model_field_selector_popover").toHaveCount(0);
await press(["alt", "#"]);
await animationFrame();
expect(".o_popover .o_model_field_selector_popover").toHaveCount(1);
});
test.tags("mobile");
test("with dynamic placeholder in mobile", async () => {
onRpc("mail_allowed_qweb_expressions", () => []);
Product._fields.placeholder = fields.Char({ default: "product" });
await mountView({
type: "form",
resModel: "product",
arch: `
<form>
<field name="placeholder" invisible="1"/>
<sheet>
<group>
<field
name="description"
options="{
'dynamic_placeholder': true,
'dynamic_placeholder_model_reference_field': 'placeholder'
}"
/>
</group>
</sheet>
</form>`,
});
expect(".o_popover .o_model_field_selector_popover").toHaveCount(0);
await fieldTextArea("description").focus();
await press(["alt", "#"]);
await animationFrame();
expect(".o_popover .o_model_field_selector_popover").toHaveCount(1);
});
test("text field without line breaks", async () => {
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="description" options="{'line_breaks': False}"/></form>`,
});
expect(".o_field_text textarea").toHaveCount(1);
expect(".o_field_text textarea").toHaveValue("Description as text");
await contains(".o_field_text textarea").click();
await press("Enter");
expect(".o_field_text textarea").toHaveValue("Description as text");
await contains(".o_field_text textarea").clear({ confirm: false });
await navigator.clipboard.writeText("text\nwith\nline\nbreaks\n"); // copy
await press(["ctrl", "v"]); // paste
expect(".o_field_text textarea").toHaveValue("text with line breaks ", {
message: "no line break should appear",
});
});

View file

@ -0,0 +1,70 @@
import { expect, test } from "@odoo/hoot";
import { queryText } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Localization extends models.Model {
country = fields.Selection({
selection: [
["belgium", "Belgium"],
["usa", "United States"],
],
onChange: (record) => {
record.tz_offset = "+4800";
},
});
tz_offset = fields.Char();
_records = [{ id: 1, country: "belgium" }];
}
defineModels([Localization]);
test("in a list view", async () => {
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "localization",
resId: 1,
arch: /*xml*/ `
<list string="Localizations" editable="top">
<field name="tz_offset" column_invisible="True"/>
<field name="country" widget="timezone_mismatch" />
</list>
`,
});
expect("td:contains(Belgium)").toHaveCount(1);
await contains(".o_data_cell").click();
expect(".o_field_widget[name=country] select").toHaveCount(1);
await contains(".o_field_widget[name=country] select").select(`"usa"`);
expect(".o_data_cell:first").toHaveText(
/United States\s+\([0-9]+\/[0-9]+\/[0-9]+ [0-9]+:[0-9]+:[0-9]+\)/
);
expect(".o_tz_warning").toHaveCount(1);
});
test("in a form view", async () => {
await mountView({
type: "form",
resModel: "localization",
resId: 1,
arch: /*xml*/ `
<form>
<field name="tz_offset" invisible="True"/>
<field name="country" widget="timezone_mismatch" />
</form>
`,
});
expect(`.o_field_widget[name="country"]:contains(Belgium)`).toHaveCount(1);
expect(".o_field_widget[name=country] select").toHaveCount(1);
await contains(".o_field_widget[name=country] select").select(`"usa"`);
expect(queryText(`.o_field_widget[name="country"]:first`)).toMatch(
/United States\s+\([0-9]+\/[0-9]+\/[0-9]+ [0-9]+:[0-9]+:[0-9]+\)/
);
expect(".o_tz_warning").toHaveCount(1);
});

View file

@ -0,0 +1,180 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { queryAllAttributes, queryAllTexts, queryFirst } from "@odoo/hoot-dom";
import {
contains,
defineModels,
fieldInput,
fields,
models,
mountView,
onRpc,
} from "../../web_test_helpers";
class Product extends models.Model {
url = fields.Char();
}
defineModels([Product]);
onRpc("has_group", () => true);
test("UrlField in form view", async () => {
Product._records = [{ id: 1, url: "https://www.example.com" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="url" widget="url"/></form>`,
});
expect(`.o_field_widget input[type="text"]`).toHaveCount(1);
expect(`.o_field_widget input[type="text"]`).toHaveValue("https://www.example.com");
expect(`.o_field_url a`).toHaveAttribute("href", "https://www.example.com");
await fieldInput("url").edit("https://www.odoo.com");
expect(`.o_field_widget input[type="text"]`).toHaveValue("https://www.odoo.com");
});
test("in form view (readonly)", async () => {
Product._records = [{ id: 1, url: "https://www.example.com" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="url" widget="url" readonly="1"/></form>`,
});
expect("a.o_field_widget.o_form_uri").toHaveCount(1);
expect("a.o_field_widget.o_form_uri").toHaveAttribute("href", "https://www.example.com");
expect("a.o_field_widget.o_form_uri").toHaveAttribute("target", "_blank");
expect("a.o_field_widget.o_form_uri").toHaveText("https://www.example.com");
});
test("it takes its text content from the text attribute", async () => {
Product._records = [{ id: 1, url: "https://www.example.com" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="url" widget="url" text="https://another.com" readonly="1"/></form>',
});
expect(`.o_field_url a`).toHaveText("https://another.com");
});
test("href attribute and website_path option", async () => {
Product._fields.url1 = fields.Char();
Product._fields.url2 = fields.Char();
Product._fields.url3 = fields.Char();
Product._fields.url4 = fields.Char();
Product._records = [
{
id: 1,
url1: "http://www.url1.com",
url2: "www.url2.com",
url3: "http://www.url3.com",
url4: "https://url4.com",
},
];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `
<form>
<field name="url1" widget="url" readonly="1"/>
<field name="url2" widget="url" readonly="1" options="{'website_path': True}"/>
<field name="url3" widget="url" readonly="1"/>
<field name="url4" widget="url" readonly="1"/>
</form>`,
});
expect(`.o_field_widget[name="url1"] a`).toHaveAttribute("href", "http://www.url1.com");
expect(`.o_field_widget[name="url2"] a`).toHaveAttribute("href", "www.url2.com");
expect(`.o_field_widget[name="url3"] a`).toHaveAttribute("href", "http://www.url3.com");
expect(`.o_field_widget[name="url4"] a`).toHaveAttribute("href", "https://url4.com");
});
test("in editable list view", async () => {
Product._records = [
{ id: 1, url: "example.com" },
{ id: 2, url: "odoo.com" },
];
await mountView({
type: "list",
resModel: "product",
arch: '<list editable="bottom"><field name="url" widget="url"/></list>',
});
expect("tbody td:not(.o_list_record_selector) a").toHaveCount(2);
expect(".o_field_url.o_field_widget[name='url'] a").toHaveCount(2);
expect(queryAllAttributes(".o_field_url.o_field_widget[name='url'] a", "href")).toEqual([
"http://example.com",
"http://odoo.com",
]);
expect(queryAllTexts(".o_field_url.o_field_widget[name='url'] a")).toEqual([
"example.com",
"odoo.com",
]);
let cell = queryFirst("tbody td:not(.o_list_record_selector)");
await contains(cell).click();
expect(cell.parentElement).toHaveClass("o_selected_row");
expect(cell.querySelector("input")).toHaveValue("example.com");
await fieldInput("url").edit("test");
await contains(getFixture()).click(); // click out
cell = queryFirst("tbody td:not(.o_list_record_selector)");
expect(cell.parentElement).not.toHaveClass("o_selected_row");
expect("tbody td:not(.o_list_record_selector) a").toHaveCount(2);
expect(".o_field_url.o_field_widget[name='url'] a").toHaveCount(2);
expect(queryAllAttributes(".o_field_url.o_field_widget[name='url'] a", "href")).toEqual([
"http://test",
"http://odoo.com",
]);
expect(queryAllTexts(".o_field_url.o_field_widget[name='url'] a")).toEqual([
"test",
"odoo.com",
]);
});
test("with falsy value", async () => {
Product._records = [{ id: 1, url: false }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="url" widget="url"/></form>',
});
expect(`[name=url] input`).toHaveCount(1);
expect(`[name=url] input`).toHaveValue("");
});
test("onchange scenario", async () => {
Product._fields.url_source = fields.Char({
onChange: (record) => (record.url = record.url_source),
});
Product._records = [{ id: 1, url: "odoo.com", url_source: "another.com" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="url" widget="url" readonly="True"/><field name="url_source"/></form>`,
});
expect(".o_field_widget[name=url]").toHaveText("odoo.com");
expect(".o_field_widget[name=url_source] input").toHaveValue("another.com");
await fieldInput("url_source").edit("example.com");
expect(".o_field_widget[name=url]").toHaveText("example.com");
});
test("with placeholder", async () => {
Product._records = [{ id: 1 }];
await mountView({
type: "form",
resModel: "product",
arch: `<form><field name="url" widget="url" placeholder="Placeholder"/></form>`,
});
expect(`.o_field_widget input`).toHaveAttribute("placeholder", "Placeholder");
});
test("with non falsy, but non url value", async () => {
Product._fields.url.default = "odoo://hello";
await mountView({
type: "form",
resModel: "product",
arch: `<form><field name="url" widget="url"/></form>`,
});
expect(".o_field_widget[name=url] a").toHaveAttribute("href", "http://odoo://hello");
});