vanilla 19.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:49:46 +02:00
parent 991d2234ca
commit d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions

View file

@ -1,128 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { getFixture, triggerEvents } from "@web/../tests/helpers/utils";
import { pagerNext } from "@web/../tests/search/helpers";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { fakeCookieService } from "@web/../tests/helpers/mock_services";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
foo: {
string: "Foo",
type: "text",
default: "My little Foo Value",
searchable: true,
trim: true,
},
},
records: [
{ id: 1, foo: `yop` },
{ id: 2, foo: `blip` },
],
},
},
};
setupViewRegistries();
registry.category("services").add("cookie", fakeCookieService);
});
QUnit.module("AceEditorField");
QUnit.test("AceEditorField on text fields works", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="foo" widget="ace" />
</form>`,
});
assert.ok("ace" in window, "the ace library should be loaded");
assert.containsOnce(
target,
"div.ace_content",
"should have rendered something with ace editor"
);
assert.ok(target.querySelector(".o_field_ace").textContent.includes("yop"));
});
QUnit.test("AceEditorField doesn't crash when editing", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="display_name" />
<field name="foo" widget="ace" />
</form>`,
});
await triggerEvents(target, ".ace-view-editor textarea", ["focus", "click"]);
assert.hasClass(target.querySelector(".ace-view-editor"), "ace_focus");
});
QUnit.test("AceEditorField is updated on value change", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
resIds: [1, 2],
serverData,
arch: /* xml */ `
<form>
<field name="foo" widget="ace" />
</form>`,
});
assert.ok(target.querySelector(".o_field_ace").textContent.includes("yop"));
await pagerNext(target);
assert.ok(target.querySelector(".o_field_ace").textContent.includes("blip"));
});
QUnit.test(
"leaving an untouched record with an unset ace field should not write",
async (assert) => {
serverData.models.partner.records.forEach((rec) => {
rec.foo = false;
});
await makeView({
type: "form",
resModel: "partner",
resId: 1,
resIds: [1, 2],
serverData,
arch: /* xml */ `
<form>
<field name="foo" widget="ace" />
</form>`,
mockRPC(route, args) {
if (args.method) {
assert.step(`${args.method}: ${JSON.stringify(args.args)}`);
}
},
});
assert.verifySteps(["get_views: []", 'read: [[1],["foo","display_name"]]']);
await pagerNext(target);
assert.verifySteps(['read: [[2],["foo","display_name"]]']);
}
);
});

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,116 @@
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);
});
test("BadgeField component with color_field option", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `
<list>
<field name="id" column_invisible="1"/>
<field name="display_name" widget="badge" options="{'color_field': 'id'}"/>
</list>
`,
});
expect(`.o_field_badge[name="display_name"]`).toHaveCount(4);
expect(`.o_field_badge[name="display_name"] .o_badge_color_1`).toHaveCount(1);
expect(`.o_field_badge[name="display_name"] .o_badge_color_2`).toHaveCount(1);
expect(`.o_field_badge[name="display_name"] .o_badge_color_3`).toHaveCount(0); //empty value
expect(`.o_field_badge[name="display_name"] .o_badge_color_4`).toHaveCount(1);
});

View file

@ -1,130 +0,0 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
partner: {
fields: {
display_name: {
string: "Char Field",
type: "char",
default: "Default char value",
searchable: true,
trim: true,
},
many2one_field: {
string: "Many2one Field",
type: "many2one",
relation: "partner",
searchable: true,
},
selection_field: {
string: "Selection",
type: "selection",
searchable: true,
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",
},
],
},
},
};
setupViewRegistries();
target = getFixture();
});
QUnit.module("BadgeField");
QUnit.test("BadgeField component on a char field in list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: '<list><field name="display_name" widget="badge"/></list>',
});
assert.containsOnce(target, '.o_field_badge[name="display_name"]:contains(first record)');
assert.containsOnce(target, '.o_field_badge[name="display_name"]:contains(second record)');
assert.containsOnce(target, '.o_field_badge[name="display_name"]:contains(fourth record)');
});
QUnit.test("BadgeField component on a selection field in list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: '<list><field name="selection_field" widget="badge"/></list>',
});
assert.containsOnce(target, '.o_field_badge[name="selection_field"]:contains(Blocked)');
assert.containsOnce(target, '.o_field_badge[name="selection_field"]:contains(Normal)');
assert.containsN(target, '.o_field_badge[name="selection_field"]:contains(Done)', 2);
});
QUnit.test("BadgeField component on a many2one field in list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: '<list><field name="many2one_field" widget="badge"/></list>',
});
assert.containsOnce(target, '.o_field_badge[name="many2one_field"]:contains(first record)');
assert.containsOnce(
target,
'.o_field_badge[name="many2one_field"]:contains(fourth record)'
);
});
QUnit.test("BadgeField component with decoration-xxx attributes", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
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>`,
});
assert.containsN(target, '.o_field_badge[name="display_name"]', 4);
assert.containsOnce(target, '.o_field_badge[name="display_name"] .text-bg-danger');
assert.containsOnce(target, '.o_field_badge[name="display_name"] .text-bg-warning');
});
});

View file

@ -0,0 +1,252 @@
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",
});
product_color_id = fields.Integer({
relation: "product",
related: "product_id.color",
default: 20,
});
_records = [{ id: 1 }, { id: 2, product_id: 37, product_color_id: 6 }];
}
class Product extends models.Model {
_rec_name = "display_name";
name = fields.Char("name");
color = fields.Integer("color");
_records = [
{ id: 37, display_name: "xphone", name: "xphone", color: 6 },
{ id: 41, display_name: "xpad", name: "xpad", color: 7 },
];
}
defineModels([Partner, Product]);
onRpc("has_group", () => true);
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",
});
});
test("BadgeSelectionField widget in list with the color_field option", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `
<list editable="top">
<field name="product_color_id" invisible="1"/>
<field name="product_id" widget="selection_badge" options="{'color_field': 'product_color_id'}"/>
</list>
`,
});
// Ensure that the correct o_badge_color is used.
expect(`.o_field_selection_badge[name="product_id"] .o_badge_color_6`).toHaveCount(1);
expect(`.o_field_selection_badge[name="product_id"] .o_badge_color_7`).toHaveCount(0);
expect(`div.o_field_selection_badge span:contains(xphone)`).toHaveCount(1);
expect(`div.o_field_selection_badge span:contains(xpad)`).toHaveCount(0);
// Open the M2O selection.
await contains(`.o_field_selection_badge[name="product_id"] .o_badge_color_6`).click();
// Ensure that the 'badge' display is used.
expect("span.btn-secondary.badge").toHaveCount(2);
expect(`span.btn-secondary.active:contains(xphone)`).toHaveCount(1);
expect(`span.btn-secondary.active:contains(xpad)`).toHaveCount(0);
// Select the second product.
await contains(`span.btn-secondary:contains(xpad)`).click();
expect(`span.btn-secondary.active:contains(xphone)`).toHaveCount(0);
expect(`span.btn-secondary.active:contains(xpad)`).toHaveCount(1);
// Save changes.
await contains(".o_list_button_save").click();
expect(`.o_field_selection_badge[name="product_id"] .o_badge_color_6`).toHaveCount(0);
expect(`.o_field_selection_badge[name="product_id"] .o_badge_color_7`).toHaveCount(1);
expect(`div.o_field_selection_badge span:contains(xphone)`).toHaveCount(0);
expect(`div.o_field_selection_badge span:contains(xpad)`).toHaveCount(1);
});
test("BadgeSelectionField widget in list without the color_field option", async () => {
await mountView({
resModel: "res.partner",
type: "list",
arch: `
<list editable="top">
<field name="id"/>
<field name="product_id" widget="selection_badge"/>
</list>
`,
});
// Ensure that the 'btn btn-secondary' display is used instead of the 'o_badge_color' one.
expect(`div.o_field_selection_badge span.btn-secondary`).toHaveCount(1);
expect(`div.o_field_selection_badge span.btn-secondary:contains(xphone)`).toHaveCount(1);
expect(`div.o_field_selection_badge span.btn-secondary:contains(xpad)`).toHaveCount(0);
// Open the M2O selection.
await contains(`div.o_field_selection_badge span:contains(xphone)`).click();
// Ensure that the 'badge' display is used.
expect("span.btn-secondary.badge").toHaveCount(2);
expect(`span.btn-secondary.active:contains(xphone)`).toHaveCount(1);
expect(`span.btn-secondary.active:contains(xpad)`).toHaveCount(0);
// Select the second product.
await contains(`span.btn-secondary:contains(xpad)`).click();
expect(`span.btn-secondary.active:contains(xphone)`).toHaveCount(0);
expect(`span.btn-secondary.active:contains(xpad)`).toHaveCount(1);
// Save changes.
await contains(".o_list_button_save").click();
expect(`div.o_field_selection_badge span.btn-secondary`).toHaveCount(1);
expect(`div.o_field_selection_badge span.btn-secondary:contains(xphone)`).toHaveCount(0);
expect(`div.o_field_selection_badge span.btn-secondary:contains(xpad)`).toHaveCount(1);
});

View file

@ -1,187 +0,0 @@
/** @odoo-module **/
import { click, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
product_id: { string: "Product", type: "many2one", relation: "product" },
color: {
type: "selection",
selection: [
["red", "Red"],
["black", "Black"],
],
default: "red",
string: "Color",
},
reference: {
string: "Reference Field",
type: "reference",
selection: [
["product", "Product"],
["partner_type", "Partner Type"],
["partner", "Partner"],
],
},
},
records: [
{
id: 1,
reference: "product,37",
},
{
id: 2,
product_id: 37,
},
],
},
product: {
records: [
{
id: 37,
display_name: "xphone",
},
{
id: 41,
display_name: "xpad",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("BadgeSelectionField");
QUnit.test("BadgeSelectionField widget on a many2one in a new record", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="product_id" widget="selection_badge"/></form>',
});
assert.containsOnce(
target,
"div.o_field_selection_badge",
"should have rendered outer div"
);
assert.containsN(target, "span.o_selection_badge", 2, "should have 2 possible choices");
assert.strictEqual(
target.querySelector("span.o_selection_badge").textContent,
"xphone",
"one of them should be xphone"
);
assert.containsNone(target, "span.active", "none of the input should be checked");
await click(target.querySelector("span.o_selection_badge"));
assert.containsOnce(target, "span.active", "one of the input should be checked");
await click(target, ".o_form_button_save");
var newRecord = _.last(serverData.models.partner.records);
assert.strictEqual(newRecord.product_id, 37, "should have saved record with correct value");
});
QUnit.test(
"BadgeSelectionField widget on a selection in a new record",
async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="color" widget="selection_badge"/></form>',
});
assert.containsOnce(
target,
"div.o_field_selection_badge",
"should have rendered outer div"
);
assert.containsN(target, "span.o_selection_badge", 2, "should have 2 possible choices");
assert.strictEqual(
target.querySelector("span.o_selection_badge").textContent,
"Red",
"one of them should be Red"
);
// click on 2nd option
await click(target.querySelector("span.o_selection_badge:last-child"));
await click(target.querySelector(".o_form_button_save"));
var newRecord = _.last(serverData.models.partner.records);
assert.strictEqual(
newRecord.color,
"black",
"should have saved record with correct value"
);
}
);
QUnit.test(
"BadgeSelectionField widget on a selection in a readonly mode",
async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="color" widget="selection_badge" readonly="1"/></form>',
});
assert.containsOnce(
target,
"div.o_readonly_modifier span",
"should have 1 possible value in readonly mode"
);
}
);
QUnit.test(
"BadgeSelectionField widget on a selection unchecking selected value",
async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="color" widget="selection_badge"/></form>',
});
assert.containsOnce(
target,
"div.o_field_selection_badge",
"should have rendered outer div"
);
assert.containsN(target, "span.o_selection_badge", 2, "should have 2 possible choices");
assert.strictEqual(
target.querySelector("span.o_selection_badge").textContent,
"Red",
"one of them should be Red"
);
// click again on red option
await click(target.querySelector("span.o_selection_badge.active"));
await click(target.querySelector(".o_form_button_save"));
var newRecord = _.last(serverData.models.partner.records);
assert.strictEqual(
newRecord.color,
false,
"the new value should be false as we have selected same value as default"
);
}
);
});

View file

@ -0,0 +1,220 @@
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 {
is_raining_outside = fields.Boolean();
mood = fields.Selection({
selection: [
["happy", "Happy"],
["sad", "Sad"],
],
});
color = fields.Selection({
selection: [
["white", "White"],
["grey", "Grey"],
["black", "Black"],
],
});
allowed_colors = fields.Json();
allowed_moods = fields.Json();
_onChanges = {
is_raining_outside(record) {
record.allowed_moods = ["happy"] + (record.is_raining_outside ? ["sad"] : []);
},
color(record) {
record.allowed_moods =
(record.color !== "black" ? ["happy"] : []) +
(record.color !== "white" ? ["sad"] : []);
},
mood(record) {
record.allowed_colors =
(record.mood === "happy" ? ["white"] : []) +
["grey"] +
(record.mood === "sad" ? ["black"] : []);
},
};
_records = [
{
id: 1,
allowed_colors: "['white', 'grey']",
allowed_moods: "['happy']",
display_name: "first record",
is_raining_outside: false,
mood: "happy",
color: "white",
},
];
}
defineModels([Partner]);
test("badge selection field with filter, empty list", async () => {
Partner._records[0].allowed_colors = [];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="allowed_colors" invisible="1"/>
<field name="color" widget="selection_badge_with_filter"
options="{'allowed_selection_field': 'allowed_colors'}"/>
</form>
`,
});
expect(".o_selection_badge").toHaveCount(0);
});
test("badge selection field with filter, single choice", async () => {
Partner._records[0].allowed_colors = ["grey"];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="allowed_colors" invisible="1"/>
<field name="color" widget="selection_badge_with_filter"
options="{'allowed_selection_field': 'allowed_colors'}"/>
</form>
`,
});
expect(".o_selection_badge").toHaveCount(1);
expect(".o_selection_badge[value='\"white\"']").toHaveCount(0);
expect(".o_selection_badge[value='\"grey\"']").toBeVisible();
expect(".o_selection_badge[value='\"black\"']").toHaveCount(0);
});
test("badge selection field with filter, all choices", async () => {
Partner._records[0].allowed_colors = ["white", "grey", "black"];
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="allowed_colors" invisible="1"/>
<field name="color" widget="selection_badge_with_filter"
options="{'allowed_selection_field': 'allowed_colors'}"/>
</form>
`,
});
expect(".o_selection_badge").toHaveCount(3);
expect(".o_selection_badge[value='\"white\"']").toBeVisible();
expect(".o_selection_badge[value='\"grey\"']").toBeVisible();
expect(".o_selection_badge[value='\"black\"']").toBeVisible();
});
test("badge selection field with filter, synchronize with other field", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<group>
<field name="is_raining_outside"/>
<field name="allowed_moods" invisible="1"/>
<field name="mood" widget="selection_badge_with_filter"
options="{'allowed_selection_field': 'allowed_moods'}"/>
</group>
</form>
`,
});
// not raining outside => sad should be invisible
expect("[name='is_raining_outside'] input").not.toBeChecked();
expect("div[name='mood'] .o_selection_badge").toHaveCount(1);
expect(".o_selection_badge[value='\"happy\"']").toBeVisible();
expect(".o_selection_badge[value='\"sad\"']").toHaveCount(0);
await click("[name='is_raining_outside'] input");
await animationFrame();
// raining outside => sad should be visible
expect("[name='is_raining_outside'] input").toBeChecked();
expect("div[name='mood'] .o_selection_badge").toHaveCount(2);
expect(".o_selection_badge[value='\"happy\"']").toBeVisible();
expect(".o_selection_badge[value='\"sad\"']").toBeVisible();
await click("[name='is_raining_outside'] input");
await animationFrame();
// not raining outside => sad should be invisible
expect("[name='is_raining_outside'] input").not.toBeChecked();
expect("div[name='mood'] .o_selection_badge").toHaveCount(1);
expect(".o_selection_badge[value='\"happy\"']").toBeVisible();
expect(".o_selection_badge[value='\"sad\"']").toHaveCount(0);
});
test("badge selection field with filter, cross badge synchronization", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<group>
<field name="allowed_moods"/>
<field name="allowed_colors"/>
<field name="mood" widget="selection_badge_with_filter"
options="{'allowed_selection_field': 'allowed_moods'}"/>
<field name="color" widget="selection_badge_with_filter"
options="{'allowed_selection_field': 'allowed_colors'}"/>
</group>
</form>
`,
});
// happy and white by default, sad and black should be invisible
expect("div[name='mood'] .o_selection_badge").toHaveCount(1);
expect("div[name='color'] .o_selection_badge").toHaveCount(2);
expect(".o_selection_badge[value='\"happy\"']").toBeVisible();
expect(".o_selection_badge[value='\"sad\"']").toHaveCount(0);
expect(".o_selection_badge[value='\"white\"']").toBeVisible();
expect(".o_selection_badge[value='\"grey\"']").toBeVisible();
expect(".o_selection_badge[value='\"black\"']").toHaveCount(0);
await click(".o_selection_badge[value='\"grey\"']");
await animationFrame();
// happy and grey, sad should be revealed
expect("div[name='mood'] .o_selection_badge").toHaveCount(2);
expect("div[name='color'] .o_selection_badge").toHaveCount(2);
expect(".o_selection_badge[value='\"happy\"']").toBeVisible();
expect(".o_selection_badge[value='\"sad\"']").toBeVisible();
expect(".o_selection_badge[value='\"white\"']").toBeVisible();
expect(".o_selection_badge[value='\"grey\"']").toBeVisible();
expect(".o_selection_badge[value='\"black\"']").toHaveCount(0);
await click(".o_selection_badge[value='\"sad\"']");
await animationFrame();
// sad and grey, white should disappear and black should appear
expect("div[name='mood'] .o_selection_badge").toHaveCount(2);
expect("div[name='color'] .o_selection_badge").toHaveCount(2);
expect(".o_selection_badge[value='\"happy\"']").toBeVisible();
expect(".o_selection_badge[value='\"sad\"']").toBeVisible();
expect(".o_selection_badge[value='\"white\"']").toHaveCount(0);
expect(".o_selection_badge[value='\"grey\"']").toBeVisible();
expect(".o_selection_badge[value='\"black\"']").toBeVisible();
await click(".o_selection_badge[value='\"black\"']");
await animationFrame();
// sad and black, happy should disappear
expect("div[name='mood'] .o_selection_badge").toHaveCount(1);
expect("div[name='color'] .o_selection_badge").toHaveCount(2);
expect(".o_selection_badge[value='\"happy\"']").toHaveCount(0);
expect(".o_selection_badge[value='\"sad\"']").toBeVisible();
expect(".o_selection_badge[value='\"white\"']").toHaveCount(0);
expect(".o_selection_badge[value='\"grey\"']").toBeVisible();
expect(".o_selection_badge[value='\"black\"']").toBeVisible();
});

View file

@ -0,0 +1,487 @@
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`).toBeVisible({
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("should accept file with allowed MIME type and reject others", async () => {
await mountView({
resModel: "res.partner",
resId: 1,
type: "form",
arch: `
<form>
<field name="document" filename="foo" widget="binary" options="{'allowed_mime_type' : 'application/pdf'}"/>
</form>
`,
});
await click(`.o_select_file_button`);
await animationFrame();
const pdfFile = new File(["test"], "fake_pdf.pdf", { type: "application/pdf" });
await setInputFiles([pdfFile]);
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_pdf.pdf");
await click(`.o_clear_file_button`);
await animationFrame();
await click(`.o_select_file_button`);
await animationFrame();
const textFile = new File(["test"], "text_file.txt", { type: "text/plain" });
await setInputFiles([textFile]);
await animationFrame();
expect(".o_notification").toHaveCount(1);
expect(".o_notification_content").toHaveText(
"Oops! 'text_file.txt' didnt upload since its format isnt allowed."
);
expect(".o_notification_bar").toHaveClass("bg-danger");
});
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

@ -1,581 +0,0 @@
/** @odoo-module **/
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeMockXHR } from "@web/../tests/helpers/mock_services";
import {
click,
clickSave,
editInput,
getFixture,
makeDeferred,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { browser } from "@web/core/browser/browser";
import { RPCError } from "@web/core/network/rpc_service";
import { MAX_FILENAME_SIZE_BYTES } from "@web/views/fields/binary/binary_field";
import { toBase64Length } from "@web/core/utils/binary";
const BINARY_FILE =
"R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
trim: true,
},
document: { string: "Binary", type: "binary" },
product_id: {
string: "Product",
type: "many2one",
relation: "product",
searchable: true,
},
},
records: [
{
foo: "coucou.txt",
document: "coucou==\n",
},
],
},
product: {
fields: {
name: { string: "Product Name", type: "char", searchable: true },
},
records: [
{
id: 37,
display_name: "xphone",
},
{
id: 41,
display_name: "xpad",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("BinaryField");
QUnit.test("BinaryField is correctly rendered (readonly)", async function (assert) {
assert.expect(6);
async function send(data) {
assert.ok(data instanceof FormData);
assert.strictEqual(
data.get("field"),
"document",
"we should download the field document"
);
assert.strictEqual(
data.get("data"),
"coucou==\n",
"we should download the correct data"
);
this.status = 200;
this.response = new Blob([data.get("data")], { type: "text/plain" });
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form edit="0">
<field name="document" filename="foo"/>
<field name="foo"/>
</form>`,
resId: 1,
});
assert.containsOnce(
target,
'.o_field_widget[name="document"] a > .fa-download',
"the binary field should be rendered as a downloadable link in readonly"
);
assert.strictEqual(
target.querySelector('.o_field_widget[name="document"]').textContent,
"coucou.txt",
"the binary field should display the name of the file in the link"
);
assert.strictEqual(
target.querySelector(".o_field_char").textContent,
"coucou.txt",
"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 prom = makeDeferred();
const downloadOnClick = (ev) => {
const target = ev.target;
if (target.tagName === "A" && "download" in target.attributes) {
ev.preventDefault();
document.removeEventListener("click", downloadOnClick);
prom.resolve();
}
};
document.addEventListener("click", downloadOnClick);
registerCleanup(() => document.removeEventListener("click", downloadOnClick));
await click(target.querySelector('.o_field_widget[name="document"] a'));
await prom;
});
QUnit.test("BinaryField is correctly rendered", async function (assert) {
assert.expect(12);
async function send(data) {
assert.ok(data instanceof FormData);
assert.strictEqual(
data.get("field"),
"document",
"we should download the field document"
);
assert.strictEqual(
data.get("data"),
"coucou==\n",
"we should download the correct data"
);
this.status = 200;
this.response = new Blob([data.get("data")], { type: "text/plain" });
}
const MockXHR = makeMockXHR("", send);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>`,
resId: 1,
});
assert.containsNone(
target,
'.o_field_widget[name="document"] a > .fa-download',
"the binary field should not be rendered as a downloadable link in edit"
);
assert.strictEqual(
target.querySelector('.o_field_widget[name="document"].o_field_binary .o_input').value,
"coucou.txt",
"the binary field should display the file name in the input edit mode"
);
assert.containsOnce(
target,
".o_field_binary .o_clear_file_button",
"there shoud be a button to clear the file"
);
assert.strictEqual(
target.querySelector(".o_field_char input").value,
"coucou.txt",
"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 prom = makeDeferred();
const downloadOnClick = (ev) => {
const target = ev.target;
if (target.tagName === "A" && "download" in target.attributes) {
ev.preventDefault();
document.removeEventListener("click", downloadOnClick);
prom.resolve();
}
};
document.addEventListener("click", downloadOnClick);
registerCleanup(() => document.removeEventListener("click", downloadOnClick));
await click(target.querySelector(".fa-download"));
await prom;
await click(target.querySelector(".o_field_binary .o_clear_file_button"));
assert.isNotVisible(
target.querySelector(".o_field_binary input"),
"the input should be hidden"
);
assert.containsOnce(
target,
".o_field_binary .o_select_file_button",
"there should be a button to upload the file"
);
assert.strictEqual(
target.querySelector(".o_field_char input").value,
"",
"the filename field should be empty since we removed the file"
);
await clickSave(target);
assert.containsNone(
target,
'.o_field_widget[name="document"] a > .fa-download',
"the binary field should not render as a downloadable link since we removed the file"
);
assert.containsNone(
target,
"o_field_widget span",
"the binary field should not display a filename in the link since we removed the file"
);
});
QUnit.test("BinaryField is correctly rendered (isDirty)", async function (assert) {
assert.expect(2);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>`,
resId: 1,
});
// Simulate a file upload
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await editInput(target, ".o_field_binary .o_input_file", file);
assert.containsNone(
target,
'.o_field_widget[name="document"] .fa-download',
"the binary field should not be rendered as a downloadable since the record is dirty"
);
await clickSave(target);
assert.containsOnce(
target,
'.o_field_widget[name="document"] .fa-download',
"the binary field should render as a downloadable link since the record is not dirty"
);
});
QUnit.test("file name field is not defined", async (assert) => {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="document" filename="foo"/>
</form>`,
resId: 1,
});
assert.strictEqual(
target.querySelector(".o_field_binary").textContent,
"",
"there should be no text since the name field is not in the view"
);
assert.isVisible(
target,
".o_field_binary .o_form_uri fa-download",
"download icon should be visible"
);
});
QUnit.test(
"binary fields input value is empty when clearing after uploading",
async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>`,
resId: 1,
});
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await editInput(target, ".o_field_binary .o_input_file", file);
assert.ok(
target.querySelector(".o_field_binary input[type=text]").hasAttribute("readonly")
);
assert.strictEqual(
target.querySelector(".o_field_binary input[type=text]").value,
"fake_file.txt",
'displayed value should be changed to "fake_file.txt"'
);
assert.strictEqual(
target.querySelector(".o_field_char input[type=text]").value,
"fake_file.txt",
'related value should be changed to "fake_file.txt"'
);
await click(target.querySelector(".o_clear_file_button"));
assert.strictEqual(
target.querySelector(".o_field_binary .o_input_file").value,
"",
"file input value should be empty"
);
assert.strictEqual(
target.querySelector(".o_field_char input").value,
"",
"related value should be empty"
);
}
);
QUnit.test("BinaryField: option accepted_file_extensions", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="document" widget="binary" options="{'accepted_file_extensions': '.dat,.bin'}"/>
</form>`,
});
assert.strictEqual(
target.querySelector("input.o_input_file").getAttribute("accept"),
".dat,.bin",
"the input should have the correct ``accept`` attribute"
);
});
QUnit.test(
"BinaryField that is readonly in create mode does not download",
async function (assert) {
async function download() {
assert.step("We shouldn't be getting the file.");
}
const MockXHR = makeMockXHR("", download);
patchWithCleanup(
browser,
{
XMLHttpRequest: MockXHR,
},
{ pure: true }
);
serverData.models.partner.onchanges = {
product_id: function (obj) {
obj.document = "onchange==\n";
},
};
serverData.models.partner.fields.document.readonly = true;
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="product_id"/>
<field name="document" filename="yooo"/>
</form>`,
resId: 1,
});
await click(target, ".o_form_button_create");
await click(target, ".o_field_many2one[name='product_id'] input");
await click(
target.querySelector(".o_field_many2one[name='product_id'] .dropdown-item")
);
assert.containsNone(
target,
'.o_field_widget[name="document"] a',
"The link to download the binary should not be present"
);
assert.containsNone(
target,
'.o_field_widget[name="document"] a > .fa-download',
"The download icon should not be present"
);
assert.verifySteps([], "We shouldn't have passed through steps");
}
);
QUnit.test("Binary field in list view", async function (assert) {
serverData.models.partner.records[0].document = BINARY_FILE;
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree>
<field name="document" filename="yooo"/>
</tree>`,
resId: 1,
});
assert.strictEqual(
target.querySelector(".o_data_row .o_data_cell").textContent,
"93.43 Bytes"
);
});
QUnit.test("Binary field for new record has no download button", async function (assert) {
serverData.models.partner.fields.document.default = BINARY_FILE;
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="document" filename="foo"/>
</form>
`,
});
assert.containsNone(target, "button.fa-download");
});
QUnit.test("Binary filename doesn't exceed 255 bytes", async function (assert) {
const LARGE_BINARY_FILE = BINARY_FILE.repeat(5);
assert.ok((LARGE_BINARY_FILE.length / 4 * 3) > MAX_FILENAME_SIZE_BYTES,
"The initial binary file should be larger than max bytes that can represent the filename");
serverData.models.partner.fields.document.default = LARGE_BINARY_FILE;
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="document"/>
</form>
`,
});
assert.strictEqual(
target.querySelector(".o_field_binary input[type=text]").value.length,
toBase64Length(MAX_FILENAME_SIZE_BYTES),
"The filename shouldn't exceed the maximum size in bytes in base64"
);
});
QUnit.test("BinaryField filename is updated when using the pager", async function (assert) {
serverData.models.partner.records.push(
{
id: 1,
document: "abc",
foo: "abc.txt",
},
{
id: 2,
document: "def",
foo: "def.txt",
}
);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="document" filename="foo"/>
<field name="foo"/>
</form>
`,
resIds: [1, 2],
resId: 1,
});
assert.strictEqual(
target.querySelector(".o_field_binary input[type=text]").value,
"abc.txt",
'displayed value should be "abc.txt"'
);
await click(target.querySelector(".o_pager_next"));
assert.strictEqual(
target.querySelector(".o_field_binary input[type=text]").value,
"def.txt",
'displayed value should be changed to "def.txt"'
);
});
QUnit.test('isUploading state should be set to false after upload', async function(assert) {
assert.expect(1);
serverData.models.partner.onchanges = {
document: function (obj) {
if (obj.document) {
const error = new RPCError();
error.exceptionName = "odoo.exceptions.ValidationError";
throw error;
}
},
};
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="document"/>
</form>`,
});
const file = new File(["test"], "fake_file.txt", { type: "text/plain" });
await editInput(target, ".o_field_binary .o_input_file", file);
assert.equal(
target.querySelector(".o_select_file_button").innerText,
"UPLOAD YOUR FILE",
"displayed value should be upload your file"
);
});
QUnit.test("doesn't crash if value is not a string", async (assert) => {
serverData.models.partner.records = [{
id: 1,
document: {},
}]
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document"/>
</form>`,
});
assert.equal(
target.querySelector(".o_field_binary input").value,
""
);
})
});

View file

@ -0,0 +1,262 @@
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 User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
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, User]);
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 .o_favorite`).toHaveClass("o_disabled");
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

@ -1,196 +0,0 @@
/** @odoo-module **/
import { click, clickSave, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
bar: { string: "Bar", type: "boolean", default: true, searchable: true },
},
records: [
{ id: 1, bar: true },
{ id: 2, bar: true },
{ id: 4, bar: true },
{ id: 3, bar: true },
{ id: 5, bar: false },
],
},
},
};
setupViewRegistries();
});
QUnit.module("BooleanFavoriteField");
QUnit.test("FavoriteField in kanban view", async function (assert) {
await makeView({
type: "kanban",
resModel: "partner",
serverData,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="bar" widget="boolean_favorite" />
</div>
</t>
</templates>
</kanban>`,
domain: [["id", "=", 1]],
});
assert.containsOnce(
target,
".o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star",
"should be favorite"
);
assert.strictEqual(
target.querySelector(".o_kanban_record .o_field_widget .o_favorite > a").textContent,
" Remove from Favorites",
'the label should say "Remove from Favorites"'
);
// click on favorite
await click(target, ".o_field_widget .o_favorite");
assert.containsNone(
target,
".o_kanban_record .o_field_widget .o_favorite > a i.fa.fa-star",
"should not be favorite"
);
assert.strictEqual(
target.querySelector(".o_kanban_record .o_field_widget .o_favorite > a").textContent,
" Add to Favorites",
'the label should say "Add to Favorites"'
);
});
QUnit.test("FavoriteField in form view", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<sheet>
<group>
<field name="bar" widget="boolean_favorite" />
</group>
</sheet>
</form>`,
});
assert.containsOnce(
target,
".o_field_widget .o_favorite > a i.fa.fa-star",
"should be favorite"
);
assert.strictEqual(
target.querySelector(".o_field_widget .o_favorite > a").textContent,
" Remove from Favorites",
'the label should say "Remove from Favorites"'
);
// click on favorite
await click(target, ".o_field_widget .o_favorite");
assert.containsNone(
target,
".o_field_widget .o_favorite > a i.fa.fa-star",
"should not be favorite"
);
assert.strictEqual(
target.querySelector(".o_field_widget .o_favorite > a").textContent,
" Add to Favorites",
'the label should say "Add to Favorites"'
);
assert.containsOnce(
target,
".o_field_widget .o_favorite > a i.fa.fa-star-o",
"should not be favorite"
);
assert.strictEqual(
target.querySelector(".o_field_widget .o_favorite > a").textContent,
" Add to Favorites",
'the label should say "Add to Favorites"'
);
// click on favorite
await click(target, ".o_field_widget .o_favorite");
assert.containsOnce(
target,
".o_field_widget .o_favorite > a i.fa.fa-star",
"should be favorite"
);
assert.strictEqual(
target.querySelector(".o_field_widget .o_favorite > a").textContent,
" Remove from Favorites",
'the label should say "Remove from Favorites"'
);
// save
await clickSave(target);
assert.containsOnce(
target,
".o_field_widget .o_favorite > a i.fa.fa-star",
"should be favorite"
);
assert.strictEqual(
target.querySelector(".o_field_widget .o_favorite > a").textContent,
" Remove from Favorites",
'the label should say "Remove from Favorites"'
);
});
QUnit.test("FavoriteField in editable list view without label", async function (assert) {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="bottom">
<field name="bar" widget="boolean_favorite" nolabel="1" />
</tree>`,
});
assert.containsOnce(
target,
".o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star",
"should be favorite"
);
// switch to edit mode
await click(target.querySelector("tbody td:not(.o_list_record_selector)"));
assert.containsOnce(
target,
".o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star",
"should be favorite"
);
// click on favorite
await click(target.querySelector(".o_data_row .o_field_widget .o_favorite"));
assert.containsNone(
target,
".o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star",
"should not be favorite"
);
// save
await clickSave(target);
assert.containsOnce(
target,
".o_data_row:first .o_field_widget .o_favorite > a i.fa.fa-star-o",
"should not be favorite"
);
});
});

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

@ -1,276 +0,0 @@
/** @odoo-module **/
import {
click,
clickSave,
getFixture,
nextTick,
triggerEvent,
triggerEvents,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
bar: { string: "Bar", type: "boolean", default: true, searchable: true },
},
records: [
{ id: 1, bar: true },
{ id: 2, bar: true },
{ id: 3, bar: true },
{ id: 4, bar: true },
{ id: 5, bar: false },
],
},
},
};
setupViewRegistries();
});
QUnit.module("BooleanField");
QUnit.test("boolean field in form view", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<label for="bar" string="Awesome checkbox" />
<field name="bar" />
</form>`,
});
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should still be checked"
);
assert.containsNone(
target,
".o_field_boolean input:disabled",
"checkbox should not be disabled"
);
// uncheck the checkbox
await click(target, ".o_field_boolean input:checked");
assert.containsNone(
target,
".o_field_boolean input:checked",
"checkbox should no longer be checked"
);
// save
await clickSave(target);
assert.containsNone(
target,
".o_field_boolean input:checked",
"checkbox should still no longer be checked"
);
assert.containsNone(
target,
".o_field_boolean input:checked",
"checkbox should still be unchecked"
);
// check the checkbox
await click(target, ".o_field_boolean input");
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should now be checked"
);
// uncheck it back
await click(target, ".o_field_boolean input");
assert.containsNone(
target,
".o_field_boolean input:checked",
"checkbox should now be unchecked"
);
// check the checkbox by clicking on label
await click(target, ".o_form_view label:not(.form-check-label)");
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should now be checked"
);
// uncheck it back
await click(target, ".o_form_view label:not(.form-check-label)");
assert.containsNone(
target,
".o_field_boolean input:checked",
"checkbox should now be unchecked"
);
// check the checkbox by hitting the "enter" key after focusing it
await triggerEvents(target, ".o_field_boolean input", [
["focusin"],
["keydown", { key: "Enter" }],
["keyup", { key: "Enter" }],
]);
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should now be checked"
);
// blindly press enter again, it should uncheck the checkbox
await triggerEvent(document.activeElement, null, "keydown", { key: "Enter" });
assert.containsNone(
target,
".o_field_boolean input:checked",
"checkbox should not be checked"
);
await nextTick();
// blindly press enter again, it should check the checkbox back
await triggerEvent(document.activeElement, null, "keydown", { key: "Enter" });
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should still be checked"
);
// save
await clickSave(target);
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should still be checked"
);
});
QUnit.test("boolean field in editable list view", async function (assert) {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="bottom">
<field name="bar" />
</tree>`,
});
assert.containsN(
target,
"tbody td:not(.o_list_record_selector) .o-checkbox input",
5,
"should have 5 checkboxes"
);
assert.containsN(
target,
"tbody td:not(.o_list_record_selector) .o-checkbox input:checked",
4,
"should have 4 checked input"
);
// Edit a line
let cell = target.querySelector("tr.o_data_row td:not(.o_list_record_selector)");
assert.ok(
cell.querySelector(".o-checkbox input:checked").disabled,
"input should be disabled in readonly mode"
);
await click(cell, ".o-checkbox");
assert.hasClass(
document.querySelector("tr.o_data_row:nth-child(1)"),
"o_selected_row",
"the row is now selected, in edition"
);
assert.ok(
!cell.querySelector(".o-checkbox input:checked").disabled,
"input should now be enabled"
);
await click(cell);
assert.notOk(
cell.querySelector(".o-checkbox input:checked").disabled,
"input should not have the disabled property in edit mode"
);
await click(cell, ".o-checkbox");
// save
await clickSave(target);
cell = target.querySelector("tr.o_data_row td:not(.o_list_record_selector)");
assert.ok(
cell.querySelector(".o-checkbox input:not(:checked)").disabled,
"input should be disabled again"
);
assert.containsN(
target,
"tbody td:not(.o_list_record_selector) .o-checkbox",
5,
"should still have 5 checkboxes"
);
assert.containsN(
target,
"tbody td:not(.o_list_record_selector) .o-checkbox input:checked",
3,
"should now have only 3 checked input"
);
// Re-Edit the line and fake-check the checkbox
await click(cell);
await click(cell, ".o-checkbox");
await click(cell, ".o-checkbox");
// Save
await clickSave(target);
assert.containsN(
target,
"tbody td:not(.o_list_record_selector) .o-checkbox",
5,
"should still have 5 checkboxes"
);
assert.containsN(
target,
"tbody td:not(.o_list_record_selector) .o-checkbox input:checked",
3,
"should still have only 3 checked input"
);
});
QUnit.test("readonly boolean field", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `<form><field name="bar" readonly="1"/></form>`,
});
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should still be checked"
);
assert.containsOnce(
target,
".o_field_boolean input:disabled",
"checkbox should still be disabled"
);
await click(target, ".o_field_boolean .o-checkbox");
assert.containsOnce(
target,
".o_field_boolean input:checked",
"checkbox should still be checked"
);
assert.containsOnce(
target,
".o_field_boolean input:disabled",
"checkbox should still be disabled"
);
});
});

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

@ -1,280 +0,0 @@
/** @odoo-module **/
import { click, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
bar: { string: "Bar", type: "boolean", default: true, searchable: true },
},
records: [{ id: 1, bar: false }],
},
},
};
setupViewRegistries();
});
QUnit.module("BooleanToggleField");
QUnit.test("use BooleanToggleField in form view", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="bar" widget="boolean_toggle" />
</form>`,
});
assert.containsOnce(
target,
".form-check.o_boolean_toggle",
"Boolean toggle widget applied to boolean field"
);
assert.containsOnce(
target,
".form-check.o_boolean_toggle input:checked",
"Boolean toggle should be checked"
);
await click(target, ".o_field_widget[name='bar'] input");
assert.containsOnce(
target,
".form-check.o_boolean_toggle input:not(:checked)",
"Boolean toggle shouldn't be checked"
);
});
QUnit.test("readonly BooleanToggleField is disabled in edit mode", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="bar" widget="boolean_toggle" readonly="1" />
</form>`,
});
assert.containsOnce(target, ".o_form_editable");
assert.ok(target.querySelector(".o_boolean_toggle input").disabled);
});
QUnit.test("BooleanToggleField is not disabled in readonly mode", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="bar" widget="boolean_toggle"/></form>',
resId: 1,
});
assert.containsOnce(target, ".o_form_editable");
assert.containsOnce(target, ".form-check.o_boolean_toggle");
assert.notOk(target.querySelector(".o_boolean_toggle input").disabled);
assert.notOk(target.querySelector(".o_boolean_toggle input").checked);
await click(target, ".o_field_widget[name='bar'] input");
assert.ok(target.querySelector(".o_boolean_toggle input").checked);
});
QUnit.test("BooleanToggleField is disabled with a readonly attribute", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="bar" widget="boolean_toggle" readonly="1"/></form>',
resId: 1,
});
assert.containsOnce(target, ".form-check.o_boolean_toggle");
assert.ok(target.querySelector(".o_boolean_toggle input").disabled);
});
QUnit.test("BooleanToggleField is enabled in edit mode", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="bar" widget="boolean_toggle"/></form>',
resId: 1,
});
assert.containsOnce(target, ".form-check.o_boolean_toggle");
assert.notOk(target.querySelector(".o_boolean_toggle input").disabled);
assert.notOk(target.querySelector(".o_boolean_toggle input").checked);
await click(target, ".o_field_widget[name='bar'] input");
assert.notOk(target.querySelector(".o_boolean_toggle input").disabled);
assert.ok(target.querySelector(".o_boolean_toggle input").checked);
});
QUnit.test("boolean toggle widget is not disabled in readonly mode", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="bar" widget="boolean_toggle" />
</form>`,
});
assert.containsOnce(
target,
".form-check.o_boolean_toggle",
"Boolean toggle widget applied to boolean field"
);
assert.containsNone(target, ".o_boolean_toggle input:checked");
await click(target, ".o_boolean_toggle");
assert.containsOnce(target, ".o_boolean_toggle input:checked");
});
QUnit.test(
"boolean toggle widget is disabled with a readonly attribute",
async function (assert) {
assert.expect(3);
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="bar" widget="boolean_toggle" readonly="1" />
</form>`,
});
assert.containsOnce(
target,
".form-check.o_boolean_toggle",
"Boolean toggle widget applied to boolean field"
);
assert.containsNone(target, ".o_boolean_toggle input:checked");
await click(target, ".o_boolean_toggle");
assert.containsNone(target, ".o_boolean_toggle input:checked");
}
);
QUnit.test("boolean toggle widget is enabled in edit mode", async function (assert) {
assert.expect(3);
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="bar" widget="boolean_toggle" />
</form>`,
});
assert.containsOnce(
target,
".form-check.o_boolean_toggle",
"Boolean toggle widget applied to boolean field"
);
assert.containsNone(target, ".o_boolean_toggle input:checked");
await click(target, ".o_boolean_toggle");
assert.containsOnce(target, ".o_boolean_toggle input:checked");
});
QUnit.test(
"BooleanToggleField is disabled if readonly in editable list",
async function (assert) {
serverData.models.partner.fields.bar.readonly = true;
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: `
<tree editable="bottom">
<field name="bar" widget="boolean_toggle" />
</tree>
`,
});
assert.containsOnce(
target,
".o_boolean_toggle input:disabled",
"field should be readonly"
);
assert.containsOnce(target, ".o_boolean_toggle input:not(:checked)");
await click(target, ".o_boolean_toggle");
assert.containsOnce(
target,
".o_boolean_toggle input:disabled",
"field should still be readonly"
);
assert.containsOnce(
target,
".o_boolean_toggle input:not(:checked)",
"should keep unchecked on cell click"
);
await click(target, ".o_boolean_toggle");
assert.containsOnce(
target,
".o_boolean_toggle input:not(:checked)",
"should keep unchecked on click"
);
}
);
QUnit.test("BooleanToggleField - auto save record when field toggled", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="bar" widget="boolean_toggle" />
</form>`,
resId: 1,
mockRPC(_route, { method }) {
if (method === "write") {
assert.step("write");
}
},
});
await click(target, ".o_field_widget[name='bar'] input");
assert.verifySteps(["write"]);
});
QUnit.test("BooleanToggleField - autosave option set to false", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="bar" widget="boolean_toggle" options="{'autosave': false}"/>
</form>`,
resId: 1,
mockRPC(_route, { method }) {
if (method === "write") {
assert.step("write");
}
},
});
await click(target, ".o_field_widget[name='bar'] input");
assert.verifySteps([]);
});
});

View file

@ -0,0 +1,937 @@
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("basic rendering text field", async () => {
Product._fields.description = fields.Text();
Product._records = [{ id: 1, description: "Description as text" }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="description" widget="char"/></form>',
});
expect(".o_field_widget input[type='text']").toHaveCount(1);
expect(".o_field_widget input[type='text']").toHaveValue("Description as text");
});
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("res.lang", "get_installed", () => [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
["es_ES", "Spanish"],
]);
onRpc("res.partner", "get_field_translations", () => [
[
{ 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("res.lang", "get_installed", () => [
["en_US", "English"],
["fr_BE", "French (Belgium)"],
]);
onRpc("res.partner", "get_field_translations", () => [
[
{
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,
},
]);
onRpc("res.partner", "update_field_translations", ({ args }) => {
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

@ -1,182 +0,0 @@
/** @odoo-module **/
import { click, editInput, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
hex_color: { string: "hexadecimal color", type: "char" },
foo: { type: "char" },
},
records: [
{
id: 1,
},
{
id: 2,
hex_color: "#ff4444",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("ColorField");
QUnit.test("field contains a color input", async function (assert) {
serverData.models.partner.onchanges = {
hex_color: () => {},
};
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="hex_color" widget="color" />
</group>
</form>`,
mockRPC(route, args) {
if (args.method === "onchange") {
assert.step(`onchange ${JSON.stringify(args.args)}`);
}
},
});
assert.containsOnce(
target,
".o_field_color input[type='color']",
"native color input is used by the field"
);
// style returns the value in the rgb format
assert.strictEqual(
target.querySelector(".o_field_color div").style.backgroundColor,
"initial",
"field has the transparent background if no color value has been selected"
);
assert.strictEqual(target.querySelector(".o_field_color input").value, "#000000");
await editInput(target, ".o_field_color input", "#fefefe");
assert.verifySteps([
'onchange [[1],{"id":1,"hex_color":"#fefefe"},"hex_color",{"hex_color":"1"}]',
]);
assert.strictEqual(target.querySelector(".o_field_color input").value, "#fefefe");
assert.strictEqual(
target.querySelector(".o_field_color div").style.backgroundColor,
"rgb(254, 254, 254)",
"field has the new color set as background"
);
});
QUnit.test("color field in editable list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: `
<tree editable="bottom">
<field name="hex_color" widget="color" />
</tree>`,
});
assert.containsN(
target,
".o_field_color input[type='color']",
2,
"native color input is used on each row"
);
await click(target.querySelector(".o_field_color input"));
assert.doesNotHaveClass(target.querySelector(".o_data_row"), "o_selected_row");
});
QUnit.test("read-only color field in editable list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: `
<tree editable="bottom">
<field name="hex_color" readonly="1" widget="color" />
</tree>`,
});
assert.containsN(
target,
'.o_field_color input:disabled',
2,
"the field should not be editable"
);
});
QUnit.test("color field read-only in model definition, in non-editable list", async function (assert) {
serverData.models.partner.fields.hex_color.readonly = true;
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: `
<tree>
<field name="hex_color" widget="color" />
</tree>`,
});
assert.containsN(
target,
'.o_field_color input:disabled',
2,
"the field should not be editable"
);
});
QUnit.test("color field change via another field's onchange", async (assert) => {
serverData.models.partner.onchanges = {
foo: (rec) => {
rec.hex_color = "#fefefe";
},
};
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="foo" />
<field name="hex_color" widget="color" />
</form>`,
mockRPC(route, args) {
if (args.method === "onchange") {
assert.step(`onchange ${JSON.stringify(args.args)}`);
}
},
});
assert.strictEqual(
target.querySelector(".o_field_color div").style.backgroundColor,
"initial",
"field has transparent background if no color value has been selected"
);
assert.strictEqual(target.querySelector(".o_field_color input").value, "#000000");
await editInput(target, ".o_field_char[name='foo'] input", "someValue");
assert.verifySteps([
'onchange [[1],{"id":1,"foo":"someValue","hex_color":false},"foo",{"foo":"1","hex_color":""}]',
]);
assert.strictEqual(target.querySelector(".o_field_color input").value, "#fefefe");
assert.strictEqual(
target.querySelector(".o_field_color div").style.backgroundColor,
"rgb(254, 254, 254)",
"field has the new color set as background"
);
});
});

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

@ -1,243 +0,0 @@
/** @odoo-module **/
import { click, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
trim: true,
},
int_field: {
string: "int_field",
type: "integer",
sortable: true,
searchable: true,
},
},
records: [
{
id: 1,
foo: "first",
int_field: 0,
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("ColorPickerField");
QUnit.test(
"No chosen color is a red line with a white background (color 0)",
async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<group>
<field name="int_field" widget="color_picker"/>
</group>
</form>`,
});
assert.hasClass(
target.querySelectorAll(".o_field_color_picker button"),
"o_colorlist_item_color_0",
"The default no color value does have the right class"
);
await click(target, ".o_field_color_picker button");
assert.hasClass(
target.querySelectorAll(".o_field_color_picker button"),
"o_colorlist_item_color_0",
"The no color item does have the right class in the list"
);
await click(target, ".o_field_color_picker .o_colorlist_item_color_3");
await click(target, ".o_field_color_picker button");
assert.hasClass(
target.querySelectorAll(".o_field_color_picker button"),
"o_colorlist_item_color_0",
"The no color item still have the right class in the list"
);
}
);
QUnit.test("closes when color selected or outside click", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<group>
<field name="int_field" widget="color_picker"/>
<field name="foo"/>
</group>
</form>`,
});
await click(target, ".o_field_color_picker button");
assert.strictEqual(
target.querySelectorAll(".o_field_color_picker button").length > 1,
true,
"there should be more color elements when the component is opened"
);
await click(target, ".o_field_color_picker .o_colorlist_item_color_3");
assert.strictEqual(
target.querySelectorAll(".o_field_color_picker button").length,
1,
"there should be one color element when the component is closed"
);
await click(target, ".o_field_color_picker button");
await click(target.querySelector('.o_field_widget[name="foo"] input'));
assert.strictEqual(
target.querySelectorAll(".o_field_color_picker button").length,
1,
"there should be one color element when the component is closed"
);
});
QUnit.test("color picker on tree view", async function (assert) {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree>
<field name="int_field" widget="color_picker"/>
<field name="display_name" />
</tree>`,
selectRecord() {
assert.step("record selected to open");
},
});
await click(target, ".o_field_color_picker button");
assert.verifySteps(
["record selected to open"],
"the color is not editable and the record has been opened"
);
});
QUnit.test("color picker in editable list view", async function (assert) {
serverData.models.partner.records.push({
int_field: 1,
});
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<list editable="bottom">
<field name="int_field" widget="color_picker"/>
</list>
`,
});
assert.containsOnce(
target,
".o_data_row:nth-child(1) .o_field_color_picker button",
"color picker list is not open by default"
);
await click(target, ".o_data_row:nth-child(1) .o_field_color_picker button");
assert.hasClass(
target.querySelector(".o_data_row:nth-child(1)"),
"o_selected_row",
"first row is selected"
);
assert.containsN(
target,
".o_data_row:nth-child(1) .o_field_color_picker button",
12,
"color picker list is open when the row is in edition"
);
await click(
target,
".o_data_row:nth-child(1) .o_field_color_picker .o_colorlist_item_color_6"
);
assert.containsN(
target,
".o_data_row:nth-child(1) .o_field_color_picker button",
12,
"color picker list is still open after color has been selected"
);
await click(target, ".o_data_row:nth-child(2) .o_data_cell");
assert.containsOnce(
target,
".o_data_row:nth-child(1) .o_field_color_picker button",
"color picker list is no longer open on the first row"
);
assert.containsN(
target,
".o_data_row:nth-child(2) .o_field_color_picker button",
12,
"color picker list is open when the row is in edition"
);
});
QUnit.test("column widths: dont overflow color picker in list", async function (assert) {
serverData.models.partner.fields.date_field = {
string: "Date field",
type: "date",
};
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: `
<tree editable="top">
<field name="date_field"/>
<field name="int_field" widget="color_picker"/>
</tree>`,
domain: [["id", "<", 0]],
});
await click(target.querySelector(".o_list_button_add"));
const date_column_width = target
.querySelector('.o_list_table thead th[data-name="date_field"]')
.style.width.replace("px", "");
const int_field_column_width = target
.querySelector('.o_list_table thead th[data-name="int_field"]')
.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.
assert.ok(
parseFloat(date_column_width) < parseFloat(int_field_column_width),
"colorpicker should display properly (Horizontly)"
);
});
});

View file

@ -0,0 +1,179 @@
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);
expect(".o_clipboard_button.o_btn_char_copy").toHaveClass("btn-primary");
expect(".o_clipboard_button.o_btn_char_copy").not.toHaveClass("btn-secondary");
await contains(".o_clipboard_button.o_btn_char_copy").click();
expect.verifySteps(["char value"]);
});
test("CopyClipboardButtonField with a secondary style", async () => {
await mountView({
type: "form",
resModel: "res.partner",
resId: 1,
arch: `
<form>
<group>
<field name="char_field" widget="CopyClipboardButton" options="{'btn_class': 'secondary'}"/>
</group>
</form>`,
});
expect(".o_field_widget[name=char_field] input").toHaveCount(0);
expect(".o_clipboard_button.o_btn_char_copy").toHaveCount(1);
expect(".o_clipboard_button.o_btn_char_copy").not.toHaveClass("btn-primary");
expect(".o_clipboard_button.o_btn_char_copy").toHaveClass("btn-secondary");
});
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

@ -1,270 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { click, getFixture, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
const serviceRegistry = registry.category("services");
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach((assert) => {
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char", searchable: true },
char_field: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
trim: true,
},
text_field: {
string: "txt",
type: "text",
default: "My little txt Value\nHo-ho-hoooo Merry Christmas",
},
},
records: [
{
id: 1,
char_field: "yop",
},
],
},
},
};
target = getFixture();
setupViewRegistries();
});
QUnit.module("CopyClipboardField");
QUnit.test("Char & Text Fields: Copy to clipboard button", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<div>
<field name="text_field" widget="CopyClipboardText"/>
<field name="char_field" widget="CopyClipboardChar"/>
</div>
</sheet>
</form>`,
resId: 1,
});
assert.containsOnce(
target,
".o_clipboard_button.o_btn_text_copy",
"Should have copy button on text type field"
);
assert.containsOnce(
target,
".o_clipboard_button.o_btn_char_copy",
"Should have copy button on char type field"
);
});
QUnit.test("CopyClipboardField on unset field", async function (assert) {
serverData.models.partner.records[0].char_field = false;
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="char_field" widget="CopyClipboardChar" />
</group>
</sheet>
</form>`,
resId: 1,
});
assert.containsNone(
target,
'.o_field_copy[name="char_field"] .o_clipboard_button',
"char_field (unset) should not contain a button"
);
assert.containsOnce(
target.querySelector(".o_field_widget[name=char_field]"),
"input",
"char_field (unset) should contain an input field"
);
});
QUnit.test(
"CopyClipboardField on readonly unset fields in create mode",
async function (assert) {
serverData.models.partner.fields.display_name.readonly = true;
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="display_name" widget="CopyClipboardChar" />
</group>
</sheet>
</form>`,
});
assert.containsNone(
target,
'.o_field_copy[name="display_name"] .o_clipboard_button',
"the readonly unset field should not contain a button"
);
}
);
QUnit.test("CopyClipboard fields: display a tooltip on click", async function (assert) {
const fakePopoverService = {
async start() {
return {
add(el, comp, params) {
assert.strictEqual(el.textContent, "Copy", "button has the right text");
assert.deepEqual(
params,
{ tooltip: "Copied" },
"tooltip has the right parameters"
);
assert.step("copied tooltip");
},
};
},
};
serviceRegistry.remove("popover");
serviceRegistry.add("popover", fakePopoverService);
patchWithCleanup(browser, {
navigator: {
clipboard: {
writeText: (text) => {
assert.strictEqual(
text,
"My little txt Value\nHo-ho-hoooo Merry Christmas",
"copied text is equal to displayed text"
);
return Promise.resolve();
},
},
},
});
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<div>
<field name="text_field" widget="CopyClipboardText"/>
</div>
</sheet>
</form>`,
resId: 1,
});
assert.containsOnce(
target,
".o_clipboard_button.o_btn_text_copy",
"should have copy button on text type field"
);
await click(target, ".o_clipboard_button");
await nextTick();
assert.verifySteps(["copied tooltip"]);
});
QUnit.test("CopyClipboard fields with clipboard not available", async function (assert) {
patchWithCleanup(browser, {
console: {
warn: (msg) => assert.step(msg),
},
navigator: {
clipboard: undefined,
},
});
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<div>
<field name="text_field" widget="CopyClipboardText"/>
</div>
</sheet>
</form>`,
resId: 1,
});
await click(target, ".o_clipboard_button");
await nextTick();
assert.verifySteps(
["This browser doesn't allow to copy to clipboard"],
"console simply displays a warning on failure"
);
});
QUnit.module("CopyToClipboardButtonField");
QUnit.test("CopyToClipboardButtonField in form view", async function (assert) {
patchWithCleanup(browser, {
navigator: {
clipboard: {
writeText: (text) => {
assert.step(text);
return Promise.resolve();
},
},
},
});
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<div>
<field name="text_field" widget="CopyClipboardButton"/>
<field name="char_field" widget="CopyClipboardButton"/>
</div>
</sheet>
</form>`,
resId: 1,
});
assert.containsNone(target.querySelector(".o_field_widget[name=char_field]"), "input");
assert.containsNone(target.querySelector(".o_field_widget[name=text_field]"), "input");
assert.containsOnce(target, ".o_clipboard_button.o_btn_text_copy");
assert.containsOnce(target, ".o_clipboard_button.o_btn_char_copy");
await click(target.querySelector(".o_clipboard_button.o_btn_text_copy"));
await click(target.querySelector(".o_clipboard_button.o_btn_char_copy"));
assert.verifySteps([
`My little txt Value
Ho-ho-hoooo Merry Christmas`,
"yop",
]);
});
});

View file

@ -0,0 +1,551 @@
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 button").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: [
[29, 30, 31, 1, 2, 3, 4],
[5, 6, 7, 8, [9], 10, 11],
[12, 13, 14, 15, 16, 17, 18],
[19, 20, 21, 22, 23, 24, 25],
[26, 27, 28, 29, 30, 31, 1],
[2, 3, 4, 5, 6, 7, 8],
],
daysOfWeek: ["", "S", "M", "T", "W", "T", "F", "S"],
weekNumbers: [1, 2, 3, 4, 5, 6],
},
],
});
});
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 button").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 });
await contains(".o_field_widget[name='date'] button").click();
expect(".o_field_widget[name='date'] input").toHaveValue("02/03/2017");
await fieldInput("date").edit("invalid date");
await contains(".o_field_widget[name='date'] button").click();
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 button").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").toHaveText("Feb 3, 2017");
// open datepicker and select another value
await contains(".o_field_date button").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").toHaveText("Feb 22, 2017");
await clickSave();
expect.verifySteps(["2017-02-22"]);
expect(".o_field_date").toHaveText("Feb 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 button").toHaveText("Feb 3, 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 button").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(".o_field_date button").toHaveClass("text-danger");
await contains(".o_field_date button").click();
await fieldInput("date").clear();
expect(".o_field_date input").not.toHaveClass("text-danger");
});
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']").toHaveText("Feb 3, 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("Feb 3, 2017");
await contains(cell).click();
expect(".o_field_date button").toHaveCount(1);
expect(".o_field_date button").toHaveText("Feb 3, 2017");
// open datepicker and select another value
await contains(".o_field_date button").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 button").toHaveText("Feb 22, 2017");
await contains(".o_list_button_save").click();
expect("tr.o_data_row td:not(.o_list_record_selector)").toHaveText("Feb 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 button").toHaveCount(1);
await contains(".o_field_date button").click();
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").toHaveText("Feb 3, 2017");
await contains(".o_field_date button").click();
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 button").click();
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: "%d-%m-%Y" } });
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_date").toHaveText("Feb 3, 2017");
await contains(".o_field_date button").click();
expect(".o_field_date input").toHaveValue("03-02-2017");
await contains(getPickerCell("22")).click();
await clickSave();
expect(".o_field_date").toHaveText("Feb 22, 2017");
});
test("date field supports internationalization", async () => {
serverState.lang = "nb_NO";
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
expect(".o_field_date").toHaveText("3. feb. 2017");
await contains(".o_field_date button").click();
expect(".o_field_date input").toHaveValue("02/03/2017");
expect(".o_zoom_out strong").toHaveText("februar 2017");
await contains(getPickerCell("22")).click();
await clickSave();
expect(".o_field_date").toHaveText("22. feb. 2017");
});
test("hit enter should update value", async () => {
mockTimeZone(2);
await mountView({ type: "form", resModel: "res.partner", resId: 1 });
await contains(".o_field_date button").click();
await contains(".o_field_date input").edit("01/08");
expect(".o_field_widget[name='date']").toHaveText("Jan 8");
await contains(".o_field_date button").click();
await contains(".o_field_date input").edit("08/01");
expect(".o_field_widget[name='date']").toHaveText("Aug 1");
});
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").toHaveText("Sep 15, 2019");
await contains(".o_field_date button").click();
await fieldInput("date").edit("+5d");
expect(".o_field_date").toHaveText("Feb 20");
// Discard and do it again
await contains(".o_form_button_cancel").click();
expect(".o_field_date").toHaveText("Sep 15, 2019");
await contains(".o_field_date button").click();
await fieldInput("date").edit("+5d");
expect(".o_field_date").toHaveText("Feb 20");
// Save and do it again
await clickSave();
expect(".o_field_date").toHaveText("Feb 20");
await contains(".o_field_date button").click();
await fieldInput("date").edit("+5d");
expect(".o_field_date").toHaveText("Feb 20");
});
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 button");
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']").toHaveText("Jan 1, 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 button");
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']").toHaveText("Jan 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").toHaveText("May 4"); // 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 button");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
await contains(getPickerCell("22")).click(); // 22 May 2009
expect(".o_field_date").toHaveText("May 4"); // value forced by the onchange
});

View file

@ -1,774 +0,0 @@
/** @odoo-module **/
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import {
click,
clickCreate,
clickDiscard,
clickSave,
editInput,
getFixture,
patchDate,
patchTimeZone,
patchWithCleanup,
triggerEvent,
triggerEvents,
triggerScroll,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { strftimeToLuxonFormat } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
date: { string: "A date", type: "date", searchable: true },
datetime: { string: "A datetime", type: "datetime", searchable: true },
display_name: { string: "Displayed name", type: "char", searchable: true },
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
trim: true,
},
},
records: [
{
id: 1,
date: "2017-02-03",
datetime: "2017-02-08 10:00:00",
display_name: "first record",
foo: "yop",
},
{
id: 2,
display_name: "second record",
foo: "blip",
},
{
id: 4,
display_name: "aaa",
foo: "abc",
},
{ id: 3, foo: "gnap" },
{ id: 5, foo: "blop" },
],
},
},
};
setupViewRegistries();
});
QUnit.module("DateField");
QUnit.test("DateField: toggle datepicker", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="foo" />
<field name="date" />
</form>`,
});
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed initially"
);
await click(target, ".o_datepicker .o_datepicker_input");
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
// focus another field
await click(target, ".o_field_widget[name='foo'] input");
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should close itself when the user clicks outside"
);
});
QUnit.test("DateField: toggle datepicker far in the future", async function (assert) {
serverData.models.partner.records = [
{
id: 1,
date: "9999-12-30",
foo: "yop",
},
];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="foo" />
<field name="date" />
</form>`,
});
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed initially"
);
await click(target, ".o_datepicker .o_datepicker_input");
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
// focus another field
await click(target, ".o_field_widget[name='foo'] input");
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should close itself when the user clicks outside"
);
});
QUnit.test("date field is empty if no date is set", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: '<form><field name="date"/></form>',
});
assert.containsOnce(
target,
".o_field_widget .o_datepicker_input",
"should have one input in the form view"
);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"",
"and it should be empty"
);
});
QUnit.test(
"DateField: set an invalid date when the field is already set",
async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="date"/></form>',
});
const input = target.querySelector(".o_field_widget[name='date'] input");
assert.strictEqual(input.value, "02/03/2017");
input.value = "mmmh";
await triggerEvent(input, null, "change");
assert.strictEqual(input.value, "02/03/2017", "should have reset the original value");
}
);
QUnit.test(
"DateField: set an invalid date when the field is not set yet",
async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: '<form><field name="date"/></form>',
});
const input = target.querySelector(".o_field_widget[name='date'] input");
assert.strictEqual(input.value, "");
input.value = "mmmh";
await triggerEvent(input, null, "change");
assert.strictEqual(input.value, "", "The date field should be empty");
}
);
QUnit.test("DateField value should not set on first click", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: '<form><field name="date"/></form>',
});
await click(target, ".o_datepicker .o_datepicker_input");
// open datepicker and select a date
assert.strictEqual(
target.querySelector(".o_field_widget[name='date'] input").value,
"",
"date field's input should be empty on first click"
);
await click(document.body, ".day[data-day*='/22/']");
// re-open datepicker
await click(target, ".o_datepicker .o_datepicker_input");
assert.strictEqual(
document.body.querySelector(".day.active").textContent,
"22",
"datepicker should be highlight with 22nd day of month"
);
});
QUnit.test("DateField in form view (with positive time zone offset)", async function (assert) {
assert.expect(7);
patchTimeZone(120); // Should be ignored by date fields
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="date"/></form>',
mockRPC(route, { args }) {
if (route === "/web/dataset/call_kw/partner/write") {
assert.strictEqual(
args[1].date,
"2017-02-22",
"the correct value should be saved"
);
}
},
});
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/03/2017",
"the date should be correct in edit mode"
);
// open datepicker and select another value
await click(target, ".o_datepicker_input");
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
assert.containsOnce(
document.body,
".day.active[data-day='02/03/2017']",
"datepicker should be highlight February 3"
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[1]);
await click(document.body.querySelector(".day[data-day*='/22/']"));
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/22/2017",
"the selected date should be displayed in the input"
);
// save
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_date input").value,
"02/22/2017",
"the selected date should be displayed after saving"
);
});
QUnit.test("DateField in form view (with negative time zone offset)", async function (assert) {
patchTimeZone(-120); // Should be ignored by date fields
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="date"/></form>',
});
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/03/2017",
"the date should be correct in edit mode"
);
});
QUnit.test("DateField dropdown disappears on scroll", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<div class="scrollable" style="height: 2000px;">
<field name="date" />
</div>
</form>`,
});
await click(target, ".o_datepicker .o_datepicker_input");
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
await triggerScroll(target, { top: 50 });
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
});
QUnit.test("DateField with label opens datepicker on click", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<label for="date" string="What date is it" />
<field name="date" />
</form>`,
});
await click(target.querySelector("label.o_form_label"));
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
});
QUnit.test("DateField with warn_future option", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 4,
serverData,
arch: `
<form>
<field name="date" options="{ 'datepicker': { 'warn_future': true } }" />
</form>`,
});
// open datepicker and select another value
await click(target, ".o_datepicker .o_datepicker_input");
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[11]);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[11]);
await click(document.body, ".day[data-day*='/31/']");
assert.containsOnce(
target,
".o_datepicker_warning",
"should have a warning in the form view"
);
const input = target.querySelector(".o_field_widget[name='date'] input");
input.value = "";
await triggerEvent(input, null, "change"); // remove the value
assert.containsNone(
target,
".o_datepicker_warning",
"the warning in the form view should be hidden"
);
});
QUnit.test(
"DateField with warn_future option: do not overwrite datepicker option",
async function (assert) {
// Making sure we don't have a legit default value
// or any onchange that would set the value
serverData.models.partner.fields.date.default = undefined;
serverData.models.partner.onchanges = {};
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="foo" /> <!-- Do not let the date field get the focus in the first place -->
<field name="date" options="{ 'datepicker': { 'warn_future': true } }" />
</form>`,
});
assert.strictEqual(
target.querySelector(".o_field_widget[name='date'] input").value,
"02/03/2017",
"The existing record should have a value for the date field"
);
//Create a new record
await clickCreate(target);
assert.notOk(
target.querySelector(".o_field_widget[name='date'] input").value,
"The new record should not have a value that the framework would have set"
);
}
);
QUnit.test("DateField in editable list view", async function (assert) {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: '<tree editable="bottom"><field name="date"/></tree>',
});
const cell = target.querySelector("tr.o_data_row td:not(.o_list_record_selector)");
assert.strictEqual(
cell.textContent,
"02/03/2017",
"the date should be displayed correctly in readonly"
);
await click(cell);
assert.containsOnce(
target,
"input.o_datepicker_input",
"the view should have a date input for editable mode"
);
assert.strictEqual(
target.querySelector("input.o_datepicker_input"),
document.activeElement,
"date input should have the focus"
);
assert.strictEqual(
target.querySelector("input.o_datepicker_input").value,
"02/03/2017",
"the date should be correct in edit mode"
);
// open datepicker and select another value
await click(target, ".o_datepicker_input");
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[1]);
await click(document.body.querySelector(".day[data-day*='/22/']"));
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/22/2017",
"the selected date should be displayed in the input"
);
// save
await clickSave(target);
assert.strictEqual(
target.querySelector("tr.o_data_row td:not(.o_list_record_selector)").textContent,
"02/22/2017",
"the selected date should be displayed after saving"
);
});
QUnit.test(
"multi edition of DateField in list view: clear date in input",
async function (assert) {
serverData.models.partner.records[1].date = "2017-02-03";
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: '<tree multi_edit="1"><field name="date"/></tree>',
});
const rows = target.querySelectorAll(".o_data_row");
// select two records and edit them
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
await click(rows[0], ".o_data_cell");
assert.containsOnce(target, "input.o_datepicker_input");
await editInput(target, ".o_datepicker_input", "");
assert.containsOnce(document.body, ".modal");
await click(target, ".modal .modal-footer .btn-primary");
assert.strictEqual(
target.querySelector(".o_data_row:first-child .o_data_cell").textContent,
""
);
assert.strictEqual(
target.querySelector(".o_data_row:nth-child(2) .o_data_cell").textContent,
""
);
}
);
QUnit.test("DateField remove value", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="date"/></form>',
mockRPC(route, { args }) {
if (route === "/web/dataset/call_kw/partner/write") {
assert.strictEqual(args[1].date, false, "the correct value should be saved");
}
},
});
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/03/2017",
"the date should be correct in edit mode"
);
const input = target.querySelector(".o_datepicker_input");
input.value = "";
await triggerEvents(input, null, ["input", "change", "focusout"]);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"",
"should have correctly removed the value"
);
// save
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_date").textContent,
"",
"the selected date should be displayed after saving"
);
});
QUnit.test(
"do not trigger a field_changed for datetime field with date widget",
async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="datetime" widget="date"/></form>',
mockRPC(route, { method }) {
assert.step(method);
},
});
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/08/2017",
"the date should be correct"
);
const input = target.querySelector(".o_field_widget[name='datetime'] input");
input.value = "02/08/2017";
await triggerEvents(input, null, ["input", "change", "focusout"]);
assert.containsOnce(target, ".o_form_saved");
assert.verifySteps(["get_views", "read"]); // should not have save as nothing changed
}
);
QUnit.test(
"field date should select its content onclick when there is one",
async function (assert) {
assert.expect(3);
const done = assert.async();
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="date"/></form>',
});
$(target).on("show.datetimepicker", () => {
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"bootstrap-datetimepicker is visible"
);
const active = document.activeElement;
assert.strictEqual(
active.tagName,
"INPUT",
"The datepicker input should be focused"
);
assert.strictEqual(
active.value.slice(active.selectionStart, active.selectionEnd),
"02/03/2017",
"The whole input of the date field should have been selected"
);
done();
});
await click(target, ".o_datepicker .o_datepicker_input");
}
);
QUnit.test("DateField support internationalization", async function (assert) {
// The DatePicker component needs the locale to be available since it
// is still using Moment.js for the bootstrap datepicker
const originalLocale = moment.locale();
moment.defineLocale("no", {
monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"),
monthsParseExact: true,
dayOfMonthOrdinalParse: /\d{1,2}\./,
ordinal: "%d.",
});
registry.category("services").remove("localization");
registry
.category("services")
.add(
"localization",
makeFakeLocalizationService({ dateFormat: strftimeToLuxonFormat("%d-%m/%Y") })
);
patchWithCleanup(luxon.Settings, {
defaultLocale: "no",
});
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="date"/></form>',
resId: 1,
});
const dateViewForm = target.querySelector(".o_field_date input").value;
await click(target, ".o_datepicker .o_datepicker_input");
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
dateViewForm,
"input date field should be the same as it was in the view form"
);
await click(document.body.querySelector(".day[data-day*='/22/']"));
const dateEditForm = target.querySelector(".o_datepicker_input").value;
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_date input").value,
dateEditForm,
"date field should be the same as the one selected in the view form"
);
moment.locale(originalLocale);
moment.updateLocale("no", null);
});
QUnit.test("DateField: hit enter should update value", async function (assert) {
patchTimeZone(120);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="date"/></form>',
});
const year = new Date().getFullYear();
const input = target.querySelector(".o_field_widget[name='date'] input");
input.value = "01/08";
await triggerEvent(input, null, "keydown", { key: "Enter" });
await triggerEvent(input, null, "change");
assert.strictEqual(
target.querySelector(".o_field_widget[name='date'] input").value,
`01/08/${year}`
);
input.value = "08/01";
await triggerEvent(input, null, "keydown", { key: "Enter" });
await triggerEvent(input, null, "change");
assert.strictEqual(
target.querySelector(".o_field_widget[name='date'] input").value,
`08/01/${year}`
);
});
QUnit.test("DateField: allow to use compute dates (+5d for instance)", async function (assert) {
patchDate(2021, 1, 15, 10, 0, 0); // current date : 15 Feb 2021 10:00:00
serverData.models.partner.fields.date.default = "2019-09-15";
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="date"></field></form>',
});
assert.strictEqual(target.querySelector(".o_field_widget input").value, "09/15/2019"); // default date
// Calculate a new date from current date + 5 days
await editInput(target, ".o_field_widget[name=date] .o_datepicker_input", "+5d");
assert.strictEqual(target.querySelector(".o_field_widget input").value, "02/20/2021");
// Discard and do it again
await clickDiscard(target);
assert.strictEqual(target.querySelector(".o_field_widget input").value, "09/15/2019"); // default date
await editInput(target, ".o_field_widget[name=date] .o_datepicker_input", "+5d");
assert.strictEqual(target.querySelector(".o_field_widget input").value, "02/20/2021");
// Save and do it again
await clickSave(target);
// new computed date (current date + 5 days) is saved
assert.strictEqual(target.querySelector(".o_field_widget input").value, "02/20/2021");
await editInput(target, ".o_field_widget[name=date] .o_datepicker_input", "+5d");
assert.strictEqual(target.querySelector(".o_field_widget input").value, "02/20/2021");
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,690 @@
import { after, expect, test } from "@odoo/hoot";
import {
click,
edit,
queryAll,
queryAllProperties,
queryAllTexts,
queryRect,
resize,
} from "@odoo/hoot-dom";
import { animationFrame, mockTimeZone } from "@odoo/hoot-mock";
import {
editTime,
getPickerCell,
zoomOut,
} from "@web/../tests/core/datetime/datetime_test_helpers";
import {
clickSave,
defineModels,
defineParams,
fields,
models,
mountView,
onRpc,
contains,
} from "@web/../tests/web_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";
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"/>
<field name="datetime" readonly="1"/>
</form>`,
});
const expectedDateString = "02/08/2017 12:00:00"; // 10:00:00 without timezone
expect(".o_field_datetime button").toHaveValue(expectedDateString, {
message: "the datetime should be correctly displayed",
});
expect(".o_field_datetime button").toHaveAttribute("data-tooltip", expectedDateString);
expect(".o_field_datetime span").toHaveAttribute("data-tooltip", expectedDateString);
// datepicker should not open on focus
expect(".o_datetime_picker").toHaveCount(0);
await click(".o_field_datetime button");
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();
await editTime("8:25");
// 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 button").toHaveValue(newExpectedDateString, {
message: "the selected date should be displayed in the input",
});
// save
await clickSave();
expect(".o_field_datetime button").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 button");
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([]);
await editTime("8:25");
expect.verifySteps([]);
// Close the datepicker
await click(document.body);
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
expect(".o_field_datetime button").toHaveValue("04/22/2018 08:25:00");
expect.verifySteps(["onchange"]);
});
test("DatetimeField edit hour/minute and click away", async () => {
mockTimeZone(0);
onRpc("web_save", ({ args }) => {
expect(args[1].datetime).toBe("2017-02-08 08:30:00", {
message: "the correct value should be saved",
});
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ '<form><field name="datetime"/></form>',
});
// Open the datepicker
await click(".o_field_datetime button");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
// Manually change the time without { confirm: "enter" }
await click(`.o_time_picker_input:eq(0)`);
await animationFrame();
await edit("8:30");
await animationFrame();
expect(".o_field_datetime input").toHaveValue("02/08/2017 10:00:00", {
message: "Input value shouldn't be updated yet",
});
// Close the datepicker
await click(document.body);
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
expect(".o_field_datetime button").toHaveValue("02/08/2017 08:30:00");
await clickSave();
});
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 button").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>`,
});
expect("tr.o_data_row td:not(.o_list_record_selector):first").toHaveText(
"Feb 8, 2017, 12:00 PM",
{
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 button").toHaveCount(1, {
message: "the view should have a date input for editable mode",
});
expect(".o_field_datetime button").toBeFocused({
message: "date input should have the focus",
});
expect(".o_field_datetime button").toHaveValue("02/08/2017 12:00:00", {
message: "the date should be correct in edit mode",
});
expect(".o_datetime_picker").toHaveCount(0);
await click(".o_field_datetime button");
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();
await editTime("8:25");
await animationFrame();
expect(".o_field_datetime input").toHaveValue("04/22/2018 08:25:00", {
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(
"Apr 22, 2018, 8:25 AM",
{ message: "the selected datetime should be displayed after saving" }
);
});
test("DatetimeField input in editable list view keeps its parent's width when empty", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `<list editable="bottom"><field name="datetime"/></list>`,
});
await contains(".o_data_row:eq(1) .o_data_cell").click();
expect(".o_data_row:eq(1) .o_data_cell input").toHaveRect(
queryRect(".o_data_row:eq(1) .o_data_cell .o_field_datetime"),
{ message: "input should have the same size as its parent when empty" }
);
});
test.tags("desktop");
test("multi edition of DatetimeField in list view: edit date in input", async () => {
mockTimeZone(0);
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 button").toHaveCount(1);
await click(".o_field_datetime button");
await animationFrame();
await click(".o_field_datetime input");
await animationFrame();
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("Oct 2, 9:00 AM");
expect(".o_data_row:nth-child(2) .o_data_cell:first").toHaveText("Oct 2, 9:00 AM");
});
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 button").toHaveCount(1);
await click(".o_field_datetime button");
await animationFrame();
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 button").toHaveValue("02/08/2017 12:00:00", {
message: "the date should be correct in edit mode",
});
await click(".o_field_datetime button");
await animationFrame();
await click(".o_field_datetime input");
await animationFrame();
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("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 button");
await animationFrame();
await click(".o_field_datetime input");
await animationFrame();
await edit("01/08/22 14:30", { confirm: "Enter" });
const datetimeValue = `01/08/2022 14:30:00`;
expect(".o_field_datetime input:first").toHaveValue(datetimeValue);
// Click outside the field to check that the field is not changed
await click(document.body);
await animationFrame();
expect(".o_field_datetime button").toHaveValue(datetimeValue);
// Save and check that it's still ok
await clickSave();
expect(".o_field_datetime button").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] button").toHaveValue("٠٢/٠٨/٢٠١٧ ١١:٠٠:٠٠");
await click("[name=datetime] button");
await animationFrame();
await editTime("11:45");
expect("[name=datetime] input").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" string="show_seconds as false"/>
<field name="datetime" widget="datetime" options="{'show_seconds': true}" string="show_seconds as true"/>
</list>`,
});
expect(queryAllTexts(".o_data_row:first .o_field_datetime")).toEqual([
"Feb 8, 2017, 12:00 PM",
"Feb 8, 2017, 12:00:00 PM",
]);
});
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" string="show_seconds as false"/>
<field name="datetime" widget="datetime" options="{'show_seconds': true}" string="show_seconds as true"/>
</form>`,
});
await contains(".o_input:eq(0)").click();
await animationFrame();
expect(".o_time_picker_input").toHaveValue("11:00");
await edit("02/08/2017 11:00:00", { confirm: "Enter" });
await animationFrame();
expect(".o_input:eq(0)").toHaveValue("02/08/2017 11:00:00", {
message: "seconds should be hidden for showSeconds false",
});
await contains(".o_input:eq(1)").click();
await animationFrame();
expect(".o_time_picker_input").toHaveValue("11:00:00");
await edit("02/08/2017 11:00:30", { confirm: "Enter" });
await animationFrame();
expect(".o_input:eq(1)").toHaveValue("02/08/2017 11:00:30", {
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("Feb 8, 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("Feb 8, 2017", {
message: "for date field only date should be visible with date widget",
});
expect(dates[1]).toHaveText("Feb 8, 2017, 12:00 PM", {
message: "for datetime field only date should be visible with date widget",
});
await contains(dates[0]).click();
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("placeholder_field shows as placeholder (char)", async () => {
Partner._fields.char = fields.Char({
default: "My Placeholder",
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="char"/>
<field name="datetime" options="{'placeholder_field': 'char'}"/>
</form>`,
});
await contains("div[name='datetime'] .o_input").click();
expect("div[name='datetime'] .o_input").toHaveAttribute("placeholder", "My Placeholder", {
message: "placeholder_field should be the placeholder",
});
});
test("placeholder_field shows as placeholder (datetime)", async () => {
mockTimeZone(0);
defineParams({
lang_parameters: {
date_format: "%d/%m/%Y",
time_format: "%H:%M",
},
});
Partner._fields.datetime_example = fields.Datetime({
string: "A datetime",
searchable: true,
default: "2025-04-01 09:11:11",
required: true,
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="datetime_example"/>
<field name="datetime" options="{'placeholder_field': 'datetime_example'}"/>
</form>`,
});
await contains("div[name='datetime'] button").click();
expect("div[name='datetime'] input").toHaveAttribute("placeholder", "Apr 1, 2025, 9:11AM", {
message: "placeholder_field should be the placeholder",
});
});
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(["Feb 8, 2017", "partner,1"]);
expect(queryAllProperties(".o_list_table thead th", "offsetWidth")).toEqual([40, 99, 661]);
});
test("list datetime: column widths (numeric format)", 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="{'numeric': true }" />
<field name="display_name" />
</list>`,
});
expect(queryAllTexts(".o_data_row:eq(0) .o_data_cell")).toEqual([
"02/08/2017 11:00:00",
"partner,1",
]);
expect(queryAllProperties(".o_list_table thead th", "offsetWidth")).toEqual([40, 144, 616]);
});

View file

@ -1,661 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import {
click,
clickSave,
editInput,
getFixture,
patchTimeZone,
triggerEvent,
triggerEvents,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
date: { string: "A date", type: "date", searchable: true },
datetime: { string: "A datetime", type: "datetime", searchable: true },
p: {
string: "one2many field",
type: "one2many",
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,
},
],
onchanges: {},
},
},
};
setupViewRegistries();
});
QUnit.module("DatetimeField");
QUnit.test("DatetimeField in form view", async function (assert) {
patchTimeZone(120);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="datetime"/></form>',
});
const expectedDateString = "02/08/2017 12:00:00"; // 10:00:00 without timezone
assert.strictEqual(
target.querySelector(".o_field_datetime input").value,
expectedDateString,
"the datetime should be correctly displayed in readonly"
);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
expectedDateString,
"the datetime should be correct in edit mode"
);
// datepicker should not open on focus
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
// select 22 February at 8:25:35
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[3]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .day[data-day*='/22/']")
);
await click(document.body.querySelector(".bootstrap-datetimepicker-widget .fa-clock-o"));
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-hour")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .hour")[8]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-minute")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .minute")[5]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-second")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .second")[7]);
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
const newExpectedDateString = "04/22/2017 08:25:35";
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
newExpectedDateString,
"the selected date should be displayed in the input"
);
// save
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_datetime input").value,
newExpectedDateString,
"the selected date should be displayed after saving"
);
});
QUnit.test(
"DatetimeField does not trigger fieldChange before datetime completly picked",
async function (assert) {
patchTimeZone(120);
serverData.models.partner.onchanges = {
datetime() {},
};
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="datetime"/></form>',
mockRPC(route, { method }) {
if (method === "onchange") {
assert.step("onchange");
}
},
});
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
// select a date and time
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[3]
);
await click(
document.body.querySelector(
".bootstrap-datetimepicker-widget .day[data-day*='/22/']"
)
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .fa-clock-o")
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-hour")
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .hour")[8]
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-minute")
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .minute")[5]
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-second")
);
assert.verifySteps([], "should not have done any onchange yet");
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .second")[7]
);
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"04/22/2017 08:25:35"
);
assert.verifySteps(["onchange"], "should have done only one onchange");
}
);
QUnit.test("DatetimeField with datetime formatted without second", async function (assert) {
patchTimeZone(0);
serverData.models.partner.fields.datetime.default = "2017-08-02 12:00:05";
serverData.models.partner.fields.datetime.required = true;
registry.category("services").add(
"localization",
makeFakeLocalizationService({
dateFormat: "MM/dd/yyyy",
timeFormat: "HH:mm",
dateTimeFormat: "MM/dd/yyyy HH:mm",
}),
{ force: true }
);
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="datetime"/></form>',
});
const expectedDateString = "08/02/2017 12:00";
assert.strictEqual(
target.querySelector(".o_field_datetime input").value,
expectedDateString,
"the datetime should be correctly displayed in readonly"
);
await click(target, ".o_form_button_cancel");
assert.containsNone(document.body, ".modal", "there should not be a Warning dialog");
});
QUnit.test("DatetimeField in editable list view", async function (assert) {
patchTimeZone(120);
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: `<tree editable="bottom"><field name="datetime"/></tree>`,
});
const expectedDateString = "02/08/2017 12:00:00"; // 10:00:00 without timezone
const cell = target.querySelector("tr.o_data_row td:not(.o_list_record_selector)");
assert.strictEqual(
cell.textContent,
expectedDateString,
"the datetime should be correctly displayed in readonly"
);
// switch to edit mode
await click(target.querySelector(".o_data_row .o_data_cell"));
assert.containsOnce(
target,
"input.o_datepicker_input",
"the view should have a date input for editable mode"
);
assert.strictEqual(
target.querySelector("input.o_datepicker_input"),
document.activeElement,
"date input should have the focus"
);
assert.strictEqual(
target.querySelector("input.o_datepicker_input").value,
expectedDateString,
"the date should be correct in edit mode"
);
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
// select 22 February at 8:25:35
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0]
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[3]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .day[data-day*='/22/']")
);
await click(document.body.querySelector(".bootstrap-datetimepicker-widget .fa-clock-o"));
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-hour")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .hour")[8]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-minute")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .minute")[5]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-second")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .second")[7]);
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
const newExpectedDateString = "04/22/2017 08:25:35";
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
newExpectedDateString,
"the selected datetime should be displayed in the input"
);
// save
await clickSave(target);
assert.strictEqual(
target.querySelector("tr.o_data_row td:not(.o_list_record_selector)").textContent,
newExpectedDateString,
"the selected datetime should be displayed after saving"
);
});
QUnit.test(
"multi edition of DatetimeField in list view: edit date in input",
async function (assert) {
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: '<tree multi_edit="1"><field name="datetime"/></tree>',
});
const rows = target.querySelectorAll(".o_data_row");
// select two records and edit them
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
await click(rows[0], ".o_data_cell");
assert.containsOnce(target, "input.o_datepicker_input");
await editInput(target, ".o_datepicker_input", "10/02/2019 09:00:00");
assert.containsOnce(document.body, ".modal");
await click(target.querySelector(".modal .modal-footer .btn-primary"));
assert.strictEqual(
target.querySelector(".o_data_row:first-child .o_data_cell").textContent,
"10/02/2019 09:00:00"
);
assert.strictEqual(
target.querySelector(".o_data_row:nth-child(2) .o_data_cell").textContent,
"10/02/2019 09:00:00"
);
}
);
QUnit.test(
"multi edition of DatetimeField in list view: clear date in input",
async function (assert) {
serverData.models.partner.records[1].datetime = "2017-02-08 10:00:00";
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: '<tree multi_edit="1"><field name="datetime"/></tree>',
});
const rows = target.querySelectorAll(".o_data_row");
// select two records and edit them
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
await click(rows[0], ".o_data_cell");
assert.containsOnce(target, "input.o_datepicker_input");
await editInput(target, ".o_datepicker_input", "");
assert.containsOnce(document.body, ".modal");
await click(target, ".modal .modal-footer .btn-primary");
assert.strictEqual(
target.querySelector(".o_data_row:first-child .o_data_cell").textContent,
""
);
assert.strictEqual(
target.querySelector(".o_data_row:nth-child(2) .o_data_cell").textContent,
""
);
}
);
QUnit.test("DatetimeField remove value", async function (assert) {
assert.expect(4);
patchTimeZone(120);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: '<form><field name="datetime"/></form>',
mockRPC(route, { args }) {
if (route === "/web/dataset/call_kw/partner/write") {
assert.strictEqual(
args[1].datetime,
false,
"the correct value should be saved"
);
}
},
});
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"02/08/2017 12:00:00",
"the date time should be correct in edit mode"
);
const input = target.querySelector(".o_datepicker_input");
input.value = "";
await triggerEvents(input, null, ["input", "change", "focusout"]);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"",
"should have an empty input"
);
// save
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_datetime").textContent,
"",
"the selected date should be displayed after saving"
);
});
QUnit.test(
"DatetimeField with date/datetime widget (with day change)",
async function (assert) {
patchTimeZone(-240);
serverData.models.partner.records[0].p = [2];
serverData.models.partner.records[1].datetime = "2017-02-08 02:00:00"; // UTC
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="p">
<tree>
<field name="datetime" />
</tree>
<form>
<field name="datetime" widget="date" />
</form>
</field>
</form>`,
});
const expectedDateString = "02/07/2017 22:00:00"; // local time zone
assert.strictEqual(
target.querySelector(".o_field_widget[name='p'] .o_data_cell").textContent,
expectedDateString,
"the datetime (datetime widget) should be correctly displayed in tree view"
);
// switch to form view
await click(target, ".o_field_widget[name='p'] .o_data_cell");
assert.strictEqual(
document.body.querySelector(".modal .o_field_date[name='datetime'] input").value,
"02/07/2017",
"the datetime (date widget) should be correctly displayed in form view"
);
}
);
QUnit.test(
"DatetimeField with date/datetime widget (without day change)",
async function (assert) {
patchTimeZone(-240);
serverData.models.partner.records[0].p = [2];
serverData.models.partner.records[1].datetime = "2017-02-08 10:00:00"; // without timezone
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="p">
<tree>
<field name="datetime" />
</tree>
<form>
<field name="datetime" widget="date" />
</form>
</field>
</form>`,
});
const expectedDateString = "02/08/2017 06:00:00"; // with timezone
assert.strictEqual(
target.querySelector(".o_field_widget[name='p'] .o_data_cell").textContent,
expectedDateString,
"the datetime (datetime widget) should be correctly displayed in tree view"
);
// switch to form view
await click(target, ".o_field_widget[name='p'] .o_data_cell");
assert.strictEqual(
document.body.querySelector(".modal .o_field_date[name='datetime'] input").value,
"02/08/2017",
"the datetime (date widget) should be correctly displayed in form view"
);
}
);
QUnit.test("datepicker option: daysOfWeekDisabled", async function (assert) {
serverData.models.partner.fields.datetime.default = "2017-08-02 12:00:05";
serverData.models.partner.fields.datetime.required = true;
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="datetime" options="{'datepicker': { 'daysOfWeekDisabled': [0, 6] }}" />
</form>`,
});
await click(target, ".o_datepicker_input");
for (const el of document.body.querySelectorAll(".day:nth-child(2), .day:last-child")) {
assert.hasClass(el, "disabled", "first and last days must be disabled");
}
// the assertions below could be replaced by a single hasClass classic on the jQuery set using the idea
// All not <=> not Exists. But we want to be sure that the set is non empty. We don't have an helper
// function for that.
for (const el of document.body.querySelectorAll(
".day:not(:nth-child(2)):not(:last-child)"
)) {
assert.doesNotHaveClass(el, "disabled", "other days must stay clickable");
}
});
QUnit.test("datetime field: hit enter should update value", async function (assert) {
// 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
patchTimeZone(120);
registry.category("services").add(
"localization",
makeFakeLocalizationService({
dateFormat: "%m/%d/%Y",
timeFormat: "%H:%M:%S",
}),
{ force: true }
);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="datetime"/></form>',
resId: 1,
});
const datetime = target.querySelector(".o_field_datetime input");
// Enter a beginning of date and press enter to validate
await editInput(datetime, null, "01/08/22 14:30:40");
await triggerEvent(datetime, null, "keydown", { key: "Enter" });
const datetimeValue = `01/08/2022 14:30:40`;
assert.strictEqual(datetime.value, datetimeValue);
// Click outside the field to check that the field is not changed
await click(target);
assert.strictEqual(datetime.value, datetimeValue);
// Save and check that it's still ok
await clickSave(target);
const { value } = target.querySelector(".o_field_datetime input");
assert.strictEqual(value, datetimeValue);
});
QUnit.test(
"datetime field with date widget: hit enter should update value",
async function (assert) {
/**
* Don't think this test is usefull in the new system.
*/
patchTimeZone(120);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="datetime" widget="date"/></form>',
resId: 1,
});
await editInput(target, ".o_field_widget .o_datepicker_input", "01/08/22");
await triggerEvent(target, ".o_field_widget .o_datepicker_input", "keydown", {
key: "Enter",
});
assert.strictEqual(target.querySelector(".o_field_widget input").value, "01/08/2022");
// Click outside the field to check that the field is not changed
await clickSave(target);
assert.strictEqual(target.querySelector(".o_field_widget input").value, "01/08/2022");
}
);
QUnit.test("DateTimeField with label opens datepicker on click", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<label for="datetime" string="When is it" />
<field name="datetime" />
</form>`,
});
await click(target.querySelector("label.o_form_label"));
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
});
});

View file

@ -0,0 +1,174 @@
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" });
properties = fields.Properties({
string: "Properties",
definition_record: "product_id",
definition_record_field: "properties_definitions",
});
_records = [
{
id: 1,
char: "yop",
product_id: 37,
properties: { f80b6fb58d0d4c72: 3, f424643eee1f3655: 41 },
},
{ 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" });
properties_definitions = fields.PropertiesDefinition();
_records = [
{
id: 37,
name: "xphone",
properties_definitions: [
{ name: "f80b6fb58d0d4c72", type: "integer", string: "prop 1" },
{ name: "f424643eee1f3655", type: "many2one", string: "prop 2", comodel: "product" },
],
},
{ 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("dynamic placeholder properties", 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);
expect(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('Properties')").toHaveCount(1);
// select the properties
await contains(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('Properties') + .o_model_field_selector_popover_item_relation").click();
expect(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('prop 1 (xphone)')").toHaveCount(1);
expect(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('prop 2 (xphone)')").toHaveCount(1);
// select the many2one property
await contains(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('prop 2 (xphone)') + .o_model_field_selector_popover_item_relation").click();
expect(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('Created on')").toHaveCount(1);
// select the product name
await contains(".o_model_field_selector_popover .o_model_field_selector_popover_item_name:contains('Product Name')").click();
// click on insert
await contains(".o_model_field_selector_popover button:contains('Insert')").click();
const value = document.querySelector(".o_field_placeholder").value.trim();
expect(value).toBe("{{object.properties.get('f424643eee1f3655', env['product']).name}}");
});
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,131 @@
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("placeholder_field shows as placeholder", async () => {
Contact._fields.char = fields.Char({
default: "My Placeholder",
});
await mountView({
type: "form",
resModel: "contact",
arch: `<form>
<field name="email" widget="email" options="{'placeholder_field' : 'char'}"/>
<field name="char"/>
</form>`,
});
expect(`.o_field_email input`).toHaveAttribute("placeholder", "My 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

@ -1,259 +0,0 @@
/** @odoo-module **/
import { click, clickSave, editInput, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
trim: true,
},
empty_string: {
string: "Empty string",
type: "char",
default: false,
searchable: true,
trim: true,
},
int_field: {
string: "int_field",
type: "integer",
sortable: true,
searchable: true,
},
},
records: [{ foo: "yop" }, { foo: "blip" }],
},
},
};
setupViewRegistries();
});
QUnit.module("EmailField");
QUnit.test("EmailField in form view", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="foo" widget="email"/>
</group>
</sheet>
</form>`,
resId: 1,
});
// switch to edit mode and check the result
const mailtoEdit = target.querySelector('.o_field_email input[type="email"]');
assert.containsOnce(target, mailtoEdit, "should have an input for the email field");
assert.strictEqual(
mailtoEdit.value,
"yop",
"input should contain field value in edit mode"
);
const emailBtn = target.querySelector(".o_field_email a");
assert.containsOnce(
target,
emailBtn,
"should have rendered the email button as a link with correct classes"
);
assert.hasAttrValue(emailBtn, "href", "mailto:yop", "should have proper mailto prefix");
// change value in edit mode
await editInput(target, ".o_field_email input[type='email']", "new");
// save
await clickSave(target);
const mailtoLink = target.querySelector(".o_field_email input[type='email']");
assert.strictEqual(mailtoLink.value, "new", "new value should be displayed properly");
});
QUnit.test("EmailField in editable list view", async function (assert) {
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: '<tree editable="bottom"><field name="foo" widget="email"/></tree>',
});
assert.strictEqual(
target.querySelectorAll("tbody td:not(.o_list_record_selector) a").length,
2,
"should have 2 cells with a link"
);
assert.strictEqual(
target.querySelector("tbody td:not(.o_list_record_selector)").textContent,
"yop",
"value should be displayed properly as text"
);
let mailtoLink = target.querySelectorAll(".o_field_email a");
assert.containsN(
target,
".o_field_email a",
2,
"should have 2 anchors with correct classes"
);
assert.hasAttrValue(
mailtoLink[0],
"href",
"mailto:yop",
"should have proper mailto prefix"
);
// Edit a line and check the result
let cell = target.querySelector("tbody td:not(.o_list_record_selector)");
await click(cell);
assert.hasClass(cell.parentElement, "o_selected_row", "should be set as edit mode");
const mailField = cell.querySelector("input");
assert.strictEqual(
mailField.value,
"yop",
"should have the correct value in internal input"
);
await editInput(cell, "input", "new");
// save
await clickSave(target);
cell = target.querySelector("tbody td:not(.o_list_record_selector)");
assert.doesNotHaveClass(
cell.parentElement,
"o_selected_row",
"should not be in edit mode anymore"
);
assert.strictEqual(
target.querySelector("tbody td:not(.o_list_record_selector)").textContent,
"new",
"value should be properly updated"
);
mailtoLink = target.querySelectorAll(".o_field_widget[name='foo'] a");
assert.strictEqual(mailtoLink.length, 2, "should still have anchors with correct classes");
assert.hasAttrValue(
mailtoLink[0],
"href",
"mailto:new",
"should still have proper mailto prefix"
);
});
QUnit.test("EmailField with empty value", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="empty_string" widget="email" placeholder="Placeholder"/>
</group>
</sheet>
</form>`,
});
const input = target.querySelector(".o_field_email input");
assert.strictEqual(input.placeholder, "Placeholder");
assert.strictEqual(input.value, "", "the value should be displayed properly");
});
QUnit.test("EmailField trim user value", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="foo" widget="email"/></form>',
});
await editInput(target, ".o_field_widget[name='foo'] input", " abc@abc.com ");
const mailFieldInput = target.querySelector('.o_field_widget[name="foo"] input');
await clickSave(target);
assert.strictEqual(
mailFieldInput.value,
"abc@abc.com",
"Foo value should have been trimmed"
);
});
QUnit.test(
"readonly EmailField is properly rerendered after been changed by onchange",
async function (assert) {
serverData.models.partner.records[0].foo = "dolores.abernathy@delos";
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="int_field" on_change="1"/> <!-- onchange to update mobile in readonly mode directly -->
<field name="foo" widget="email" readonly="1"/> <!-- readonly only, we don't want to go through write mode -->
</group>
</sheet>
</form>`,
resId: 1,
mockRPC(route, { method }) {
if (method === "onchange") {
return Promise.resolve({
value: {
foo: "lara.espin@unknown", // onchange to update foo in readonly mode directly
},
});
}
},
});
// check initial rendering
assert.strictEqual(
target.querySelector(".o_field_email").textContent,
"dolores.abernathy@delos",
"Initial email text should be set"
);
// edit the phone field, but with the mail in readonly mode
await editInput(target, ".o_field_widget[name='int_field'] input", 3);
// check rendering after changes
assert.strictEqual(
target.querySelector(".o_field_email").textContent,
"lara.espin@unknown",
"email text should be updated"
);
}
);
QUnit.test("email field with placeholder", async function (assert) {
serverData.models.partner.fields.foo.default = false;
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<sheet>
<group>
<field name="foo" placeholder="New Placeholder" />
</group>
</sheet>
</form>`,
});
assert.strictEqual(
target.querySelector(".o_field_widget[name='foo'] input").placeholder,
"New Placeholder"
);
});
});

View file

@ -0,0 +1,155 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame, queryAllTexts } from "@odoo/hoot-dom";
import { followRelation } from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
import { contains, defineModels, fields, models, mountView } from "../../web_test_helpers";
class Contact extends models.Model {
email = fields.Char();
child_ids = fields.One2many({ relation: "contact" });
}
class Lead extends models.Model {
contact_id = fields.Many2one({ relation: "contact" });
salesperson_id = fields.Many2one({ relation: "contact" });
note = fields.Text();
}
class UpdateRecordAction extends models.Model {
model = fields.Char();
update_path = fields.Char();
non_searchable = fields.Char({ searchable: false });
}
defineModels([Contact, Lead, UpdateRecordAction]);
test("readonly", async () => {
UpdateRecordAction._records = [
{
id: 1,
update_path: "non_searchable",
},
];
await mountView({
type: "form",
resModel: "update.record.action",
resId: 1,
arch: /* xml */ `
<form>
<field name="update_path" widget="field_selector" readonly="1"/>
</form>
`,
});
expect(".o_field_widget[name='update_path']").toHaveText("non_searchable");
expect(".o_field_widget[name='update_path']").toHaveClass(
"o_field_widget o_readonly_modifier o_field_field_selector"
);
expect(".o_field_widget[name='update_path'] .o_input").toHaveCount(0);
});
test("no specified options", async () => {
await mountView({
type: "form",
resModel: "update.record.action",
arch: /* xml */ `
<form>
<field name="update_path" widget="field_selector"/>
</form>
`,
});
await contains(".o_field_widget[name='update_path'] .o_input").click();
expect(queryAllTexts(".o_model_field_selector_popover_item")).toEqual(
[
"Created on",
"Display name",
"Id",
"Last Modified on",
"Model",
"Non searchable",
"Update path",
],
{ message: "should display fields from same model by default" }
);
});
test("only_searchable option", async () => {
await mountView({
type: "form",
resModel: "update.record.action",
arch: /* xml */ `
<form>
<field name="update_path" widget="field_selector" options="{'only_searchable': true}"/>
</form>
`,
});
await contains(".o_field_widget[name='update_path'] .o_input").click();
expect(queryAllTexts(".o_model_field_selector_popover_item")).toEqual(
["Created on", "Display name", "Id", "Last Modified on", "Model", "Update path"],
{ message: "should not display non searchable fields" }
);
});
test("model option", async () => {
await mountView({
type: "form",
resModel: "update.record.action",
arch: /* xml */ `
<form>
<field name="model"/>
<field name="update_path" widget="field_selector" options="{'model': 'model'}"/>
</form>
`,
});
await contains(".o_field_widget[name='update_path'] .o_input").click();
expect(queryAllTexts(".o_model_field_selector_popover_item")).toEqual(
[
"Created on",
"Display name",
"Id",
"Last Modified on",
"Model",
"Non searchable",
"Update path",
],
{ message: "should display fields from same model by default" }
);
await contains(".o_field_widget[name='model'] .o_input").edit("lead");
await contains(".o_field_widget[name='update_path'] .o_input").click();
expect(queryAllTexts(".o_model_field_selector_popover_item")).toEqual(
["Contact", "Created on", "Display name", "Id", "Last Modified on", "Note", "Salesperson"],
{ message: "should display fields of the specified model" }
);
expect(".o_model_field_selector_popover_item_relation").toHaveCount(2, {
message: "following relations is supported by default",
});
await followRelation();
await animationFrame();
expect(queryAllTexts(".o_model_field_selector_popover_item")).toEqual(
["Childs", "Created on", "Display name", "Email", "Id", "Last Modified on"],
{ message: "should display fields of the selected relation" }
);
});
test("follow_relations option", async () => {
await mountView({
type: "form",
resModel: "update.record.action",
arch: /* xml */ `
<form>
<field name="model"/>
<field name="update_path" widget="field_selector" options="{
'model': 'model',
'follow_relations': false,
}"/>
</form>
`,
});
await contains(".o_field_widget[name='model'] .o_input").edit("lead");
await contains(".o_field_widget[name='update_path'] .o_input").click();
expect(queryAllTexts(".o_model_field_selector_popover_item")).toEqual(
["Contact", "Created on", "Display name", "Id", "Last Modified on", "Note", "Salesperson"],
{ message: "should display fields of the specified model" }
);
expect(".o_model_field_selector_popover_item_relation").toHaveCount(0, {
message: "should not allow to follow relations",
});
});

View file

@ -0,0 +1,100 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import {
contains,
defineModels,
editSelectMenu,
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]);
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,
});
await contains(".o_field_widget[name='type'] input").click();
expect(`.o_select_menu_item`).toHaveCount(2);
expect(queryAllTexts(".o_select_menu_item")).toEqual(["Coupons", "Promotion"]);
});
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,
});
await contains(".o_field_widget[name='type'] input").click();
expect(`.o_select_menu_item`).toHaveCount(2);
expect(queryAllTexts(".o_select_menu_item")).toEqual(["Coupons", "Promotion"]);
});
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,
});
await contains(".o_field_widget[name='type'] input").click();
expect(`.o_select_menu_item`).toHaveCount(3);
expect(queryAllTexts(".o_select_menu_item")).toEqual(["Coupons", "Promotion", "Gift card"]);
await editSelectMenu(".o_field_widget[name='type'] input", { value: "Coupons" });
await contains(".o_field_widget[name='type'] input").click();
expect(`.o_select_menu_item`).toHaveCount(2);
expect(queryAllTexts(".o_select_menu_item")).toEqual(["Coupons", "Promotion"]);
});
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,
});
await contains(".o_field_widget[name='type'] input").click();
expect(`.o_select_menu_item`).toHaveCount(2);
expect(queryAllTexts(".o_select_menu_item")).toEqual(["Coupons", "Promotion"]);
});

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

@ -1,63 +0,0 @@
/** @odoo-module **/
import { clickSave, editInput, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: { qux: { string: "Qux", type: "float", digits: [16, 1] } },
records: [{ id: 1, qux: 9.1 }],
onchanges: {},
},
},
};
setupViewRegistries();
});
QUnit.module("FloatFactorField");
QUnit.test("FloatFactorField in form view", async function (assert) {
assert.expect(3);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<sheet>
<field name="qux" widget="float_factor" options="{'factor': 0.5}" digits="[16,2]" />
</sheet>
</form>`,
mockRPC(route, { args }) {
if (route === "/web/dataset/call_kw/partner/write") {
// 2.3 / 0.5 = 4.6
assert.strictEqual(args[1].qux, 4.6, "the correct float value should be saved");
}
},
});
assert.strictEqual(
target.querySelector(".o_field_widget[name='qux'] input").value,
"4.55",
"The value should be rendered correctly in the input."
);
await editInput(target, ".o_field_widget[name='qux'] input", "2.3");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"2.30",
"The new value should be saved and displayed properly."
);
});
});

View file

@ -0,0 +1,458 @@
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("with 'hide_trailing_zeros' option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `<form><field name="float_field" options="{'hide_trailing_zeros': true}"/></form>`,
});
expect(".o_field_widget input").toHaveValue("9.1", {
message: "Input would show 9.10 without the option",
});
});
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 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

@ -1,479 +0,0 @@
/** @odoo-module **/
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { click, clickSave, editInput, getFixture, triggerEvent } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
float_field: { string: "Float field", type: "float" },
int_field: { string: "Int field", type: "integer" },
},
records: [
{ id: 1, float_field: 0.36 },
{ id: 2, float_field: 0 },
{ id: 3, float_field: -3.89859 },
{ id: 4, float_field: false },
{ id: 5, float_field: 9.1 },
],
},
},
};
setupViewRegistries();
});
QUnit.module("FloatField");
QUnit.test("unset field should be set to 0", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 4,
arch: '<form><field name="float_field"/></form>',
});
assert.doesNotHaveClass(
target.querySelector(".o_field_widget"),
"o_field_empty",
"Non-set float field should be considered as 0.00"
);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"0.00",
"Non-set float field should be considered as 0."
);
});
QUnit.test("use correct digit precision from field definition", async function (assert) {
serverData.models.partner.fields.float_field.digits = [0, 1];
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="float_field"/></form>',
});
assert.strictEqual(
target.querySelector(".o_field_float input").value,
"0.4",
"should contain a number rounded to 1 decimal"
);
});
QUnit.test("use correct digit precision from options", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `<form><field name="float_field" options="{ 'digits': [0, 1] }" /></form>`,
});
assert.strictEqual(
target.querySelector(".o_field_float input").value,
"0.4",
"should contain a number rounded to 1 decimal"
);
});
QUnit.test("use correct digit precision from field attrs", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="float_field" digits="[0, 1]" /></form>',
});
assert.strictEqual(
target.querySelector(".o_field_float input").value,
"0.4",
"should contain a number rounded to 1 decimal"
);
});
QUnit.test("with 'step' option", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `<form><field name="float_field" options="{'type': 'number', 'step': 0.3}"/></form>`,
});
assert.ok(
target.querySelector(".o_field_widget input").hasAttribute("step"),
"Integer field with option type must have a step attribute."
);
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"step",
"0.3",
'Integer field with option type must have a step attribute equals to "3".'
);
});
QUnit.test("basic flow in form view", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 2,
arch: `<form><field name="float_field" options="{ 'digits': [0, 3] }" /></form>`,
});
assert.doesNotHaveClass(
target.querySelector(".o_field_widget"),
"o_field_empty",
"Float field should be considered set for value 0."
);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"0.000",
"The value should be displayed properly."
);
await editInput(target, 'div[name="float_field"] input', "108.2451938598598");
assert.strictEqual(
target.querySelector(".o_field_widget[name=float_field] input").value,
"108.245",
"The value should have been formatted on blur."
);
await editInput(target, ".o_field_widget[name=float_field] input", "18.8958938598598");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"18.896",
"The new value should be rounded properly."
);
});
QUnit.test("use a formula", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 2,
arch: `<form><field name="float_field" options="{ 'digits': [0, 3] }" /></form>`,
});
await editInput(target, ".o_field_widget[name=float_field] input", "=20+3*2");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"26.000",
"The new value should be calculated properly."
);
await editInput(target, ".o_field_widget[name=float_field] input", "=2**3");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"8.000",
"The new value should be calculated properly."
);
await editInput(target, ".o_field_widget[name=float_field] input", "=2^3");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"8.000",
"The new value should be calculated properly."
);
await editInput(target, ".o_field_widget[name=float_field] input", "=100/3");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"33.333",
"The new value should be calculated properly."
);
});
QUnit.test("use incorrect formula", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 2,
arch: `<form><field name="float_field" options="{ 'digits': [0, 3] }" /></form>`,
});
await editInput(target, ".o_field_widget[name=float_field] input", "=abc");
await clickSave(target);
assert.hasClass(
target.querySelector(".o_field_widget[name=float_field]"),
"o_field_invalid",
"fload field should be displayed as invalid"
);
assert.containsOnce(target, ".o_form_editable", "form view should still be editable");
await editInput(target, ".o_field_widget[name=float_field] input", "=3:2?+4");
await clickSave(target);
assert.containsOnce(target, ".o_form_editable", "form view should still be editable");
assert.hasClass(
target.querySelector(".o_field_widget[name=float_field]"),
"o_field_invalid",
"float field should be displayed as invalid"
);
});
QUnit.test("float field in editable list view", async function (assert) {
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: `
<tree editable="bottom">
<field name="float_field" widget="float" digits="[5,3]" />
</tree>`,
});
// switch to edit mode
var cell = target.querySelector("tr.o_data_row td:not(.o_list_record_selector)");
await click(cell);
assert.containsOnce(
target,
'div[name="float_field"] input',
"The view should have 1 input for editable float."
);
await editInput(target, 'div[name="float_field"] input', "108.2458938598598");
assert.strictEqual(
target.querySelector('div[name="float_field"] input').value,
"108.246",
"The value should have been formatted on blur."
);
await editInput(target, 'div[name="float_field"] input', "18.8958938598598");
await click(target.querySelector(".o_list_button_save"));
assert.strictEqual(
target.querySelector(".o_field_widget").textContent,
"18.896",
"The new value should be rounded properly."
);
});
QUnit.test("float field with type number option", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" options="{'type': 'number'}"/>
</form>`,
resId: 4,
});
registry.category("services").remove("localization");
registry
.category("services")
.add(
"localization",
makeFakeLocalizationService({ thousandsSep: ",", grouping: [3, 0] })
);
assert.ok(
target.querySelector(".o_field_widget input").hasAttribute("type"),
"Float field with option type must have a type attribute."
);
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"type",
"number",
'Float field with option type must have a type attribute equals to "number".'
);
await editInput(target, ".o_field_widget[name=float_field] input", "123456.7890");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"123456.789",
"Float value must be not formatted if input type is number. (but the trailing 0 is gone)"
);
});
QUnit.test(
"float field with type number option and comma decimal separator",
async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" options="{'type': 'number'}"/>
</form>`,
resId: 4,
});
registry.category("services").remove("localization");
registry.category("services").add(
"localization",
makeFakeLocalizationService({
thousandsSep: ".",
decimalPoint: ",",
grouping: [3, 0],
})
);
assert.ok(
target.querySelector(".o_field_widget input").hasAttribute("type"),
"Float field with option type must have a type attribute."
);
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"type",
"number",
'Float field with option type must have a type attribute equals to "number".'
);
await editInput(target, ".o_field_widget[name=float_field] input", "123456.789");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"123456.789",
"Float value must be not formatted if input type is number."
);
}
);
QUnit.test("float field without type number option", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="float_field"/></form>',
resId: 4,
});
registry.category("services").remove("localization");
registry
.category("services")
.add(
"localization",
makeFakeLocalizationService({ thousandsSep: ",", grouping: [3, 0] })
);
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"type",
"text",
"Float field with option type must have a text type (default type)."
);
await editInput(target, ".o_field_widget[name=float_field] input", "123456.7890");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"123,456.79",
"Float value must be formatted if input type isn't number."
);
});
QUnit.test("float_field field with placeholder", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="float_field" placeholder="Placeholder"/></form>',
});
const input = target.querySelector(".o_field_widget[name='float_field'] input");
input.value = "";
await triggerEvent(input, null, "input");
assert.strictEqual(
target.querySelector(".o_field_widget[name='float_field'] input").placeholder,
"Placeholder"
);
});
QUnit.test("float field can be updated by another field/widget", async function (assert) {
class MyWidget extends owl.Component {
onClick() {
const val = this.props.record.data.float_field;
this.props.record.update({ float_field: val + 1 });
}
}
MyWidget.template = owl.xml`<button t-on-click="onClick">do it</button>`;
registry.category("view_widgets").add("wi", MyWidget);
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="float_field"/>
<field name="float_field"/>
<widget name="wi"/>
</form>`,
});
await editInput(
target.querySelector(".o_field_widget[name=float_field] input"),
null,
"40"
);
assert.strictEqual(
"40.00",
target.querySelectorAll(".o_field_widget[name=float_field] input")[0].value
);
assert.strictEqual(
"40.00",
target.querySelectorAll(".o_field_widget[name=float_field] input")[1].value
);
await click(target, ".o_widget button");
assert.strictEqual(
"41.00",
target.querySelectorAll(".o_field_widget[name=float_field] input")[0].value
);
assert.strictEqual(
"41.00",
target.querySelectorAll(".o_field_widget[name=float_field] input")[1].value
);
});
QUnit.test("float field with digits=0", async function (assert) {
// This isn't in the orm documentation, so it shouldn't be supported, but
// people do it and thus now we need to support it.
// Historically, it behaves like "no digits attribute defined", so it
// fallbacks on a precision of 2 digits.
// We will change that in master s.t. we do not round anymore in that case.
serverData.models.partner.fields.float_field.digits = 0;
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="float_field"/></form>',
});
assert.strictEqual(
target.querySelector(".o_field_float input").value,
"0.36",
"should contain a number rounded to 1 decimal"
);
});
});

View file

@ -0,0 +1,122 @@
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_content").toHaveText("Missing required fields");
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 does not have an inputmode attribute", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time"/>
</form>`,
});
expect(".o_field_widget[name='qux'] input").not.toHaveAttribute("inputmode");
});

View file

@ -1,194 +0,0 @@
/** @odoo-module **/
import { clickSave, editInput, getFixture, triggerEvent } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
qux: { string: "Qux", type: "float", digits: [16, 1], searchable: true },
},
records: [{ id: 5, qux: 9.1 }],
},
},
};
setupViewRegistries();
});
QUnit.module("FloatTimeField");
QUnit.test("FloatTimeField in form view", async function (assert) {
assert.expect(4);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<field name="qux" widget="float_time"/>
</sheet>
</form>`,
mockRPC(route, args) {
if (route === "/web/dataset/call_kw/partner/write") {
// 48 / 60 = 0.8
assert.strictEqual(
args.args[1].qux,
-11.8,
"the correct float value should be saved"
);
}
},
resId: 5,
});
// 9 + 0.1 * 60 = 9.06
assert.strictEqual(
target.querySelector(".o_field_float_time[name=qux] input").value,
"09:06",
"The value should be rendered correctly in the input."
);
await editInput(
target.querySelector(".o_field_float_time[name=qux] input"),
null,
"-11:48"
);
assert.strictEqual(
target.querySelector(".o_field_float_time[name=qux] input").value,
"-11:48",
"The new value should be displayed properly in the input."
);
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"-11:48",
"The new value should be saved and displayed properly."
);
});
QUnit.test("FloatTimeField value formatted on blur", async function (assert) {
assert.expect(4);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time"/>
</form>`,
mockRPC(route, args) {
if (route === "/web/dataset/call_kw/partner/write") {
assert.strictEqual(
args.args[1].qux,
9.5,
"the correct float value should be saved"
);
}
},
resId: 5,
});
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"09:06",
"The formatted time value should be displayed properly."
);
await editInput(target.querySelector(".o_field_float_time[name=qux] input"), null, "9.5");
assert.strictEqual(
target.querySelector(".o_field_float_time[name=qux] input").value,
"09:30",
"The new value should be displayed properly in the input."
);
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"09:30",
"The new value should be saved and displayed properly."
);
});
QUnit.test("FloatTimeField with invalid value", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time"/>
</form>`,
});
await editInput(
target.querySelector(".o_field_float_time[name=qux] input"),
null,
"blabla"
);
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_notification_title").textContent,
"Invalid fields: "
);
assert.strictEqual(
target.querySelector(".o_notification_content").innerHTML,
"<ul><li>Qux</li></ul>"
);
assert.hasClass(target.querySelector(".o_notification"), "border-danger");
assert.hasClass(target.querySelector(".o_field_float_time[name=qux]"), "o_field_invalid");
await editInput(target.querySelector(".o_field_float_time[name=qux] input"), null, "6.5");
assert.doesNotHaveClass(
target.querySelector(".o_field_float_time[name=qux] input"),
"o_field_invalid",
"date field should not be displayed as invalid now"
);
});
QUnit.test("float_time field with placeholder", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time" placeholder="Placeholder"/>
</form>`,
});
const input = target.querySelector(".o_field_widget[name='qux'] input");
input.value = "";
await triggerEvent(input, null, "input");
assert.strictEqual(
target.querySelector(".o_field_widget[name='qux'] input").placeholder,
"Placeholder"
);
});
QUnit.test("float_time field does not have an inputmode attribute", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="qux" widget="float_time" placeholder="Placeholder"/>
</form>`,
});
const input = target.querySelector(".o_field_widget[name='qux'] input");
assert.notOk(input.attributes.inputMode);
});
});

View file

@ -0,0 +1,97 @@
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 }];
}
class User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
defineModels([Partner, User]);
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

@ -1,117 +0,0 @@
/** @odoo-module **/
import { click, clickSave, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
partner: {
fields: {
float_field: { string: "Float field", type: "float" },
},
records: [{ id: 1, float_field: 0.44444 }],
},
},
};
setupViewRegistries();
target = getFixture();
});
QUnit.module("FloatToggleField");
QUnit.test("basic flow in form view", async function (assert) {
await makeView({
type: "form",
serverData,
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>`,
mockRPC(route, { args }) {
if (route === "/web/dataset/call_kw/partner/write") {
// 1.000 / 0.125 = 8
assert.step(args[1].float_field.toString());
}
},
});
assert.strictEqual(
target.querySelector(".o_field_widget").textContent,
"0.056", // 0.4444 * 0.125
"The formatted time value should be displayed properly."
);
assert.strictEqual(
target.querySelector("button.o_field_float_toggle").textContent,
"0.056",
"The value should be rendered correctly on the button."
);
await click(target.querySelector("button.o_field_float_toggle"));
assert.strictEqual(
target.querySelector("button.o_field_float_toggle").textContent,
"0.000",
"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 click(target.querySelector("button.o_field_float_toggle"));
assert.strictEqual(
target.querySelector("button.o_field_float_toggle").textContent,
"1.000",
"The value should be rendered correctly on the button."
);
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget").textContent,
"1.000",
"The new value should be saved and displayed properly."
);
assert.verifySteps(["8"]);
});
QUnit.test("kanban view (readonly) with option force_button", async function (assert) {
await makeView({
type: "kanban",
serverData,
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="float_field" widget="float_toggle" options="{'force_button': true}"/>
</div>
</t>
</templates>
</kanban>`,
});
assert.containsOnce(
target,
"button.o_field_float_toggle",
"should have rendered toggle button"
);
const value = target.querySelector("button.o_field_float_toggle").textContent;
await click(target.querySelector("button.o_field_float_toggle"));
assert.notEqual(
target.querySelector("button.o_field_float_toggle").textContent,
value,
"float_field field value should be changed"
);
});
});

View file

@ -1,91 +0,0 @@
/** @odoo-module **/
import { editSelect, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
fonts: {
type: "selection",
selection: [
["Lato", "Lato"],
["Oswald", "Oswald"],
],
default: "Lato",
string: "Fonts",
},
},
},
},
};
setupViewRegistries();
});
QUnit.module("FontSelectionField");
QUnit.test("FontSelectionField displays the correct fonts on options", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="fonts" widget="font" placeholder="Placeholder"/></form>',
});
const options = target.querySelectorAll('.o_field_widget[name="fonts"] option');
assert.strictEqual(
target.querySelector('.o_field_widget[name="fonts"] > *').style.fontFamily,
"Lato",
"Widget font should be default (Lato)"
);
assert.strictEqual(options[0].value, "false", "Unselected option has no value");
assert.strictEqual(
options[0].textContent,
"Placeholder",
"Unselected option is the placeholder"
);
assert.strictEqual(
options[1].style.fontFamily,
"Lato",
"Option 1 should have the correct font (Lato)"
);
assert.strictEqual(
options[2].style.fontFamily,
"Oswald",
"Option 2 should have the correct font (Oswald)"
);
await editSelect(target, ".o_input", options[2].value);
assert.strictEqual(
target.querySelector('.o_field_widget[name="fonts"] > *').style.fontFamily,
"Oswald",
"Widget font should be updated (Oswald)"
);
});
QUnit.test(
"FontSelectionField displays one blank option (not required)",
async function (assert) {
serverData.models.partner.fields.fonts.selection = [
[false, ""],
...serverData.models.partner.fields.fonts.selection,
];
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="fonts" widget="font"/></form>',
});
assert.containsN(target.querySelector(".o_field_widget[name='fonts']"), "option", 3);
}
);
});

View file

@ -0,0 +1,235 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { allowTranslations, 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,
formatDate,
formatDateTime,
} from "@web/views/fields/formatters";
const { DateTime } = luxon;
describe.current.tags("headless");
beforeEach(() => {
allowTranslations();
patchWithCleanup(localization, {
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
dateFormat: "MM/dd/yyyy",
decimalPoint: ".",
thousandsSep: ",",
grouping: [3, 0],
});
});
test("formatFloat", () => {
expect(formatFloat(false)).toBe("");
expect(formatFloat(200)).toBe("200.00");
expect(formatFloat(200, { trailingZeros: false })).toBe("200");
});
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");
expect(formatMany2one({ id: false, display_name: "M2O value" })).toBe("M2O value");
expect(formatMany2one({ id: 1, display_name: false })).toBe("Unnamed");
expect(formatMany2one({ id: 1, display_name: "M2O value" })).toBe("M2O value");
expect(formatMany2one({ id: 1, display_name: "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, currencyId: 10, data, trailingZeros: false })).toBe(
"200\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");
});
test("formatDate", () => {
expect(formatDate(false)).toBe("");
expect(formatDate(DateTime.fromObject({ day: 22, month: 1, year: 1990 }))).toBe("Jan 22, 1990");
expect(
formatDate(DateTime.fromObject({ day: 22, month: 1, year: 1990 }), { numeric: true })
).toBe("01/22/1990");
expect(formatDate(DateTime.fromObject({ day: 22, month: 1 }))).toBe("Jan 22");
});
test("formatDateTime", () => {
const datetime = DateTime.fromObject({
day: 22,
month: 1,
year: 1990,
hour: 10,
minute: 30,
second: 45,
});
expect(formatDateTime(false)).toBe("");
expect(formatDateTime(datetime)).toBe("Jan 22, 1990, 10:30 AM");
expect(formatDateTime(datetime, { showDate: false })).toBe("10:30 AM");
expect(formatDateTime(datetime, { showSeconds: true })).toBe("Jan 22, 1990, 10:30:45 AM");
expect(formatDateTime(datetime, { showTime: false })).toBe("Jan 22, 1990");
expect(formatDateTime(datetime, { numeric: true })).toBe("01/22/1990 10:30:45");
expect(formatDateTime(DateTime.fromObject({ day: 22, month: 1, hour: 10, minute: 30 }))).toBe(
"Jan 22, 10:30 AM"
);
});

View file

@ -1,358 +0,0 @@
/** @odoo-module **/
import { defaultLocalization } from "@web/../tests/helpers/mock_services";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { localization } from "@web/core/l10n/localization";
import { session } from "@web/session";
import {
formatFloat,
formatFloatFactor,
formatFloatTime,
formatJson,
formatInteger,
formatMany2one,
formatMonetary,
formatPercentage,
formatReference,
formatX2many,
} from "@web/views/fields/formatters";
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
});
QUnit.module("Formatters");
QUnit.test("formatFloat", function (assert) {
assert.strictEqual(formatFloat(false), "");
assert.strictEqual(formatFloat(null), "0.00");
assert.strictEqual(formatFloat(1000000), "1,000,000.00");
const options = { grouping: [3, 2, -1], decimalPoint: "?", thousandsSep: "€" };
assert.strictEqual(formatFloat(106500, options), "1€06€500?00");
assert.strictEqual(formatFloat(1500, { thousandsSep: "" }), "1500.00");
assert.strictEqual(formatFloat(-1.01), "-1.01");
assert.strictEqual(formatFloat(-0.01), "-0.01");
assert.strictEqual(formatFloat(38.0001, { noTrailingZeros: true }), "38");
assert.strictEqual(formatFloat(38.1, { noTrailingZeros: true }), "38.1");
patchWithCleanup(localization, { grouping: [3, 3, 3, 3] });
assert.strictEqual(formatFloat(1000000), "1,000,000.00");
patchWithCleanup(localization, { grouping: [3, 2, -1] });
assert.strictEqual(formatFloat(106500), "1,06,500.00");
patchWithCleanup(localization, { grouping: [1, 2, -1] });
assert.strictEqual(formatFloat(106500), "106,50,0.00");
patchWithCleanup(localization, {
grouping: [2, 0],
decimalPoint: "!",
thousandsSep: "@",
});
assert.strictEqual(formatFloat(6000), "60@00!00");
});
QUnit.test("formatFloat (humanReadable=true)", async (assert) => {
assert.strictEqual(
formatFloat(1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.02k"
);
assert.strictEqual(
formatFloat(1020000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"1,020k"
);
assert.strictEqual(
formatFloat(10200000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"10.20M"
);
assert.strictEqual(
formatFloat(1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.02k"
);
assert.strictEqual(
formatFloat(1002, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.00k"
);
assert.strictEqual(
formatFloat(101, { humanReadable: true, decimals: 2, minDigits: 1 }),
"101.00"
);
assert.strictEqual(
formatFloat(64.2, { humanReadable: true, decimals: 2, minDigits: 1 }),
"64.20"
);
assert.strictEqual(formatFloat(1e18, { humanReadable: true }), "1E");
assert.strictEqual(
formatFloat(1e21, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1e+21"
);
assert.strictEqual(
formatFloat(1.0045e22, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1e+22"
);
assert.strictEqual(
formatFloat(1.0045e22, { humanReadable: true, decimals: 3, minDigits: 1 }),
"1.005e+22"
);
assert.strictEqual(
formatFloat(1.012e43, { humanReadable: true, decimals: 2, minDigits: 1 }),
"1.01e+43"
);
assert.strictEqual(
formatFloat(1.012e43, { humanReadable: true, decimals: 2, minDigits: 2 }),
"1.01e+43"
);
assert.strictEqual(
formatFloat(-1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.02k"
);
assert.strictEqual(
formatFloat(-1020000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"-1,020k"
);
assert.strictEqual(
formatFloat(-10200000, { humanReadable: true, decimals: 2, minDigits: 2 }),
"-10.20M"
);
assert.strictEqual(
formatFloat(-1020, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.02k"
);
assert.strictEqual(
formatFloat(-1002, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.00k"
);
assert.strictEqual(
formatFloat(-101, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-101.00"
);
assert.strictEqual(
formatFloat(-64.2, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-64.20"
);
assert.strictEqual(formatFloat(-1e18, { humanReadable: true }), "-1E");
assert.strictEqual(
formatFloat(-1e21, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1e+21"
);
assert.strictEqual(
formatFloat(-1.0045e22, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1e+22"
);
assert.strictEqual(
formatFloat(-1.0045e22, { humanReadable: true, decimals: 3, minDigits: 1 }),
"-1.004e+22"
);
assert.strictEqual(
formatFloat(-1.012e43, { humanReadable: true, decimals: 2, minDigits: 1 }),
"-1.01e+43"
);
assert.strictEqual(
formatFloat(-1.012e43, { humanReadable: true, decimals: 2, minDigits: 2 }),
"-1.01e+43"
);
});
QUnit.test("formatFloatFactor", function (assert) {
assert.strictEqual(formatFloatFactor(false), "");
assert.strictEqual(formatFloatFactor(6000), "6,000.00");
assert.strictEqual(formatFloatFactor(6000, { factor: 3 }), "18,000.00");
assert.strictEqual(formatFloatFactor(6000, { factor: 0.5 }), "3,000.00");
});
QUnit.test("formatFloatTime", function (assert) {
assert.strictEqual(formatFloatTime(2), "02:00");
assert.strictEqual(formatFloatTime(3.5), "03:30");
assert.strictEqual(formatFloatTime(0.25), "00:15");
assert.strictEqual(formatFloatTime(0.58), "00:35");
assert.strictEqual(formatFloatTime(2 / 60, { displaySeconds: true }), "00:02:00");
assert.strictEqual(
formatFloatTime(2 / 60 + 1 / 3600, { displaySeconds: true }),
"00:02:01"
);
assert.strictEqual(
formatFloatTime(2 / 60 + 2 / 3600, { displaySeconds: true }),
"00:02:02"
);
assert.strictEqual(
formatFloatTime(2 / 60 + 3 / 3600, { displaySeconds: true }),
"00:02:03"
);
assert.strictEqual(formatFloatTime(0.25, { displaySeconds: true }), "00:15:00");
assert.strictEqual(formatFloatTime(0.25 + 15 / 3600, { displaySeconds: true }), "00:15:15");
assert.strictEqual(formatFloatTime(0.25 + 45 / 3600, { displaySeconds: true }), "00:15:45");
assert.strictEqual(formatFloatTime(56 / 3600, { displaySeconds: true }), "00:00:56");
assert.strictEqual(formatFloatTime(-0.5), "-00:30");
const options = { noLeadingZeroHour: true };
assert.strictEqual(formatFloatTime(2, options), "2:00");
assert.strictEqual(formatFloatTime(3.5, options), "3:30");
assert.strictEqual(formatFloatTime(3.5, { ...options, displaySeconds: true }), "3:30:00");
assert.strictEqual(
formatFloatTime(3.5 + 15 / 3600, { ...options, displaySeconds: true }),
"3:30:15"
);
assert.strictEqual(
formatFloatTime(3.5 + 45 / 3600, { ...options, displaySeconds: true }),
"3:30:45"
);
assert.strictEqual(
formatFloatTime(56 / 3600, { ...options, displaySeconds: true }),
"0:00:56"
);
assert.strictEqual(formatFloatTime(-0.5, options), "-0:30");
});
QUnit.test("formatJson", function (assert) {
assert.strictEqual(formatJson(false), "");
assert.strictEqual(formatJson({}), "{}");
assert.strictEqual(formatJson({ 1: 111 }), '{"1":111}');
assert.strictEqual(formatJson({ 9: 11, 666: 42 }), '{"9":11,"666":42}');
});
QUnit.test("formatInteger", function (assert) {
assert.strictEqual(formatInteger(false), "");
assert.strictEqual(formatInteger(0), "0");
patchWithCleanup(localization, { grouping: [3, 3, 3, 3] });
assert.strictEqual(formatInteger(1000000), "1,000,000");
patchWithCleanup(localization, { grouping: [3, 2, -1] });
assert.strictEqual(formatInteger(106500), "1,06,500");
patchWithCleanup(localization, { grouping: [1, 2, -1] });
assert.strictEqual(formatInteger(106500), "106,50,0");
const options = { grouping: [2, 0], thousandsSep: "€" };
assert.strictEqual(formatInteger(6000, options), "60€00");
});
QUnit.test("formatMany2one", function (assert) {
assert.strictEqual(formatMany2one(false), "");
assert.strictEqual(formatMany2one([false, "M2O value"]), "M2O value");
assert.strictEqual(formatMany2one([1, "M2O value"]), "M2O value");
assert.strictEqual(formatMany2one([1, "M2O value"], { escape: true }), "M2O%20value");
});
QUnit.test("formatX2many", function (assert) {
// Results are cast as strings since they're lazy translated.
assert.strictEqual(String(formatX2many({ currentIds: [] })), "No records");
assert.strictEqual(String(formatX2many({ currentIds: [1] })), "1 record");
assert.strictEqual(String(formatX2many({ currentIds: [1, 3] })), "2 records");
});
QUnit.test("formatMonetary", function (assert) {
patchWithCleanup(session.currencies, {
10: {
digits: [69, 2],
position: "after",
symbol: "€",
},
11: {
digits: [69, 2],
position: "before",
symbol: "$",
},
12: {
digits: [69, 2],
position: "after",
symbol: "&",
},
});
assert.strictEqual(formatMonetary(false), "");
assert.strictEqual(formatMonetary(200), "200.00");
assert.deepEqual(formatMonetary(1234567.654, { currencyId: 10 }), "1,234,567.65\u00a0€");
assert.deepEqual(formatMonetary(1234567.654, { currencyId: 11 }), "$\u00a01,234,567.65");
assert.deepEqual(formatMonetary(1234567.654, { currencyId: 44 }), "1,234,567.65");
assert.deepEqual(
formatMonetary(1234567.654, { currencyId: 10, noSymbol: true }),
"1,234,567.65"
);
assert.deepEqual(
formatMonetary(8.0, { currencyId: 10, humanReadable: true }),
"8.00\u00a0€"
);
assert.deepEqual(
formatMonetary(1234567.654, { currencyId: 10, humanReadable: true }),
"1.23M\u00a0€"
);
assert.deepEqual(
formatMonetary(1990000.001, { currencyId: 10, humanReadable: true }),
"1.99M\u00a0€"
);
assert.deepEqual(
formatMonetary(1234567.654, { currencyId: 44, digits: [69, 1] }),
"1,234,567.7"
);
assert.deepEqual(
formatMonetary(1234567.654, { currencyId: 11, digits: [69, 1] }),
"$\u00a01,234,567.7",
"options digits should take over currency digits when both are defined"
);
// GES TODO do we keep below behavior ?
// with field and data
// const field = {
// type: "monetary",
// currency_field: "c_x",
// };
// let data = {
// c_x: { res_id: 11 },
// c_y: { res_id: 12 },
// };
// assert.strictEqual(formatMonetary(200, { field, currencyId: 10, data }), "200.00 €");
// assert.strictEqual(formatMonetary(200, { field, data }), "$ 200.00");
// assert.strictEqual(formatMonetary(200, { field, currencyField: "c_y", data }), "200.00 &");
//
// const floatField = { type: "float" };
// data = {
// currency_id: { res_id: 11 },
// };
// assert.strictEqual(formatMonetary(200, { field: floatField, data }), "$ 200.00");
});
QUnit.test("formatMonetary without currency", function (assert) {
patchWithCleanup(session, {
currencies: {},
});
assert.deepEqual(
formatMonetary(1234567.654, { currencyId: 10, humanReadable: true }),
"1.23M"
);
assert.deepEqual(formatMonetary(1234567.654, { currencyId: 10 }), "1,234,567.65");
});
QUnit.test("formatPercentage", function (assert) {
assert.strictEqual(formatPercentage(false), "0%");
assert.strictEqual(formatPercentage(0), "0%");
assert.strictEqual(formatPercentage(0.5), "50%");
assert.strictEqual(formatPercentage(1), "100%");
assert.strictEqual(formatPercentage(-0.2), "-20%");
assert.strictEqual(formatPercentage(2.5), "250%");
assert.strictEqual(formatPercentage(0.125), "12.5%");
assert.strictEqual(formatPercentage(0.666666), "66.67%");
assert.strictEqual(formatPercentage(125), "12500%");
assert.strictEqual(formatPercentage(50, { humanReadable: true }), "5k%");
assert.strictEqual(formatPercentage(0.5, { noSymbol: true }), "50");
patchWithCleanup(localization, { grouping: [3, 0], decimalPoint: ",", thousandsSep: "." });
assert.strictEqual(formatPercentage(0.125), "12,5%");
assert.strictEqual(formatPercentage(0.666666), "66,67%");
});
QUnit.test("formatReference", function (assert) {
assert.strictEqual(formatReference(false), "");
const value = { resModel: "product", resId: 2, displayName: "Chair" };
assert.strictEqual(formatReference(value), "Chair");
});
});

View file

@ -0,0 +1,87 @@
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 },
];
}
class User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
defineModels([Partner, User]);
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,113 @@
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.o_disabled").toHaveCount(3, {
message: "there should be 3 handles but they should be disabled from readonly",
});
});

View file

@ -1,153 +0,0 @@
/** @odoo-module **/
import { click, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char", searchable: true },
p: {
string: "one2many field",
type: "one2many",
relation: "partner",
searchable: true,
},
sequence: { type: "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,
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("HandleField");
QUnit.test("HandleField in x2m", async function (assert) {
serverData.models.partner.records[0].p = [2, 4];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="p">
<tree editable="bottom">
<field name="sequence" widget="handle" />
<field name="display_name" />
</tree>
</field>
</form>`,
});
assert.strictEqual(
target.querySelector("td span.o_row_handle").textContent,
"",
"handle should not have any content"
);
assert.isVisible(
target.querySelector("td span.o_row_handle"),
"handle should be invisible"
);
assert.containsN(target, "span.o_row_handle", 2, "should have 2 handles");
assert.hasClass(
target.querySelector("td"),
"o_handle_cell",
"column widget should be displayed in css class"
);
assert.notStrictEqual(
getComputedStyle(target.querySelector("td span.o_row_handle")).display,
"none",
"handle should be visible in edit mode"
);
await click(target.querySelectorAll("td")[1]);
assert.containsOnce(
target.querySelector("td"),
"span.o_row_handle",
"content of the cell should have been replaced"
);
});
QUnit.test("HandleField with falsy values", async function (assert) {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree>
<field name="sequence" widget="handle" />
<field name="display_name" />
</tree>`,
});
const visibleRowHandles = [...target.querySelectorAll(".o_row_handle")].filter(
(el) => getComputedStyle(el).display !== "none"
);
assert.containsN(
target,
visibleRowHandles,
serverData.models.partner.records.length,
"there should be a visible handle for each record"
);
});
QUnit.test("HandleField in a readonly one2many", async function (assert) {
serverData.models.partner.records[0].p = [1, 2, 4];
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="p" readonly="1">
<tree editable="top">
<field name="sequence" widget="handle" />
<field name="display_name" />
</tree>
</field>
</form>`,
resId: 1,
});
assert.containsN(target, ".o_row_handle", 3, "there should be 3 handles, one for each row");
assert.strictEqual(
getComputedStyle(target.querySelector("td span.o_row_handle")).display,
"none",
"handle should be invisible"
);
});
});

View file

@ -0,0 +1,336 @@
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 }];
}
class User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
defineModels([Partner, User]);
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(queryFirst(".o_notification_content")).toHaveText("Missing required fields");
});
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", () => [
["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

@ -1,298 +0,0 @@
/** @odoo-module **/
import {
click,
clickSave,
editInput,
getFixture,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
import { HtmlField } from "@web/views/fields/html/html_field";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { session } from "@web/session";
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>`;
const serviceRegistry = registry.category("services");
QUnit.module("Fields", ({ beforeEach }) => {
let serverData;
let target;
beforeEach(() => {
serverData = {
models: {
partner: {
fields: {
txt: { string: "txt", type: "html", trim: true },
},
records: [{ id: 1, txt: RED_TEXT }],
},
},
};
target = getFixture();
setupViewRegistries();
// Explicitly removed by web_editor, we need to add it back
registry.category("fields").add("html", HtmlField, { force: true });
});
QUnit.module("HtmlField");
QUnit.test("html fields are correctly rendered in form view (readonly)", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: /* xml */ `<form><field name="txt" readonly="1" /></form>`,
});
assert.containsOnce(target, "div.kek");
assert.strictEqual(target.querySelector(".o_field_html .kek").style.color, "red");
assert.strictEqual(target.querySelector(".o_field_html").textContent, "some text");
});
QUnit.test("html field with required attribute", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: /* xml */ `<form><field name="txt" required="1"/></form>`,
});
const textarea = target.querySelector(".o_field_html textarea");
assert.ok(textarea, "should have a text area");
await editInput(textarea, null, "");
assert.strictEqual(textarea.value, "");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_notification_title").textContent,
"Invalid fields: "
);
assert.strictEqual(
target.querySelector(".o_notification_content").innerHTML,
"<ul><li>txt</li></ul>"
);
});
QUnit.test("html fields are correctly rendered (edit)", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: /* xml */ `<form><field name="txt" /></form>`,
});
const textarea = target.querySelector(".o_field_html textarea");
assert.ok(textarea, "should have a text area");
assert.strictEqual(textarea.value, RED_TEXT);
await editInput(textarea, null, GREEN_TEXT);
assert.strictEqual(textarea.value, GREEN_TEXT);
assert.containsNone(target.querySelector(".o_field_html"), ".kek");
await editInput(textarea, null, BLUE_TEXT);
assert.strictEqual(textarea.value, BLUE_TEXT);
});
QUnit.test("html fields are correctly rendered in list view", async (assert) => {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="top">
<field name="txt"/>
</tree>`,
});
const txt = target.querySelector(".o_data_row [name='txt']");
assert.strictEqual(txt.textContent, "some text");
assert.strictEqual(txt.querySelector(".kek").style.color, "red");
await click(target.querySelector(".o_data_row [name='txt']"));
assert.strictEqual(
target.querySelector(".o_data_row [name='txt'] textarea").value,
'<div class="kek" style="color:red">some text</div>'
);
});
QUnit.test(
"html field displays an empty string for the value false in list view",
async (assert) => {
serverData.models.partner.records[0].txt = false;
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="top">
<field name="txt"/>
</tree>`,
});
assert.strictEqual(target.querySelector(".o_data_row [name='txt']").textContent, "");
await click(target.querySelector(".o_data_row [name='txt']"));
assert.strictEqual(target.querySelector(".o_data_row [name='txt'] textarea").value, "");
}
);
QUnit.test("html fields are correctly rendered in kanban view", async (assert) => {
await makeView({
type: "kanban",
resModel: "partner",
serverData,
arch: `
<kanban class="o_kanban_test">
<templates>
<t t-name="kanban-box">
<div>
<field name="txt"/>
</div>
</t>
</templates>
</kanban>`,
});
const txt = target.querySelector(".kek");
assert.strictEqual(txt.textContent, "some text");
assert.strictEqual(txt.style.color, "red");
});
QUnit.test("field html translatable", async (assert) => {
assert.expect(10);
serverData.models.partner.fields.txt.translate = true;
serviceRegistry.add("localization", makeFakeLocalizationService({ multiLang: true }), {
force: true,
});
patchWithCleanup(session.user_context, {
lang: "en_US",
});
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form string="Partner">
<sheet>
<group>
<field name="txt" widget="html"/>
</group>
</sheet>
</form>`,
mockRPC(route, { args, method, model }) {
if (route === "/web/dataset/call_kw/partner/get_field_translations") {
assert.deepEqual(
args,
[[1], "txt"],
"should translate the txt field of the record"
);
return Promise.resolve([
[
{ 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 },
]);
}
if (route === "/web/dataset/call_kw/res.lang/get_installed") {
return Promise.resolve([
["en_US", "English"],
["fr_BE", "French (Belgium)"],
]);
}
if (route === "/web/dataset/call_kw/partner/update_field_translations") {
assert.deepEqual(
args,
[
[1],
"txt",
{
en_US: { "first paragraph": "first paragraph modified" },
fr_BE: {
"first paragraph": "premier paragraphe modifié",
"deuxième paragraphe": "deuxième paragraphe modifié",
},
},
],
"the new translation value should be written"
);
return Promise.resolve(null);
}
},
});
assert.hasClass(target.querySelector("[name=txt] textarea"), "o_field_translate");
assert.containsOnce(
target,
".o_field_html .btn.o_field_translate",
"should have a translate button"
);
assert.strictEqual(
target.querySelector(".o_field_html .btn.o_field_translate").textContent,
"EN",
"the button should have as test the current language"
);
await click(target, ".o_field_html .btn.o_field_translate");
assert.containsOnce(target, ".modal", "a translate modal should be visible");
assert.containsN(target, ".translation", 4, "four rows should be visible");
const translations = target.querySelectorAll(
".modal .o_translation_dialog .translation input"
);
const enField1 = translations[0];
assert.strictEqual(
enField1.value,
"first paragraph",
"first part of english translation should be filled"
);
await editInput(enField1, null, "first paragraph modified");
const frField1 = translations[2];
assert.strictEqual(
frField1.value,
"",
"first part of french translation should not be filled"
);
await editInput(frField1, null, "premier paragraphe modifié");
const frField2 = translations[3];
assert.strictEqual(
frField2.value,
"deuxième paragraphe",
"second part of french translation should be filled"
);
await editInput(frField2, null, "deuxième paragraphe modifié");
await click(target, ".modal button.btn-primary"); // save
});
});

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

@ -1,86 +0,0 @@
/** @odoo-module **/
import { editInput, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
report: {
fields: {
int_field: { string: "Int Field", type: "integer" },
html_field: { string: "Content of report", type: "html" },
},
records: [
{
id: 1,
html_field: `
<html>
<head>
<style>
body { color : rgb(255, 0, 0); }
</style>
<head>
<body>
<div class="nice_div"><p>Some content</p></div>
</body>
</html>`,
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("IframeWrapperField");
QUnit.test("IframeWrapperField in form view", async function (assert) {
await makeView({
type: "form",
resModel: "report",
serverData,
resId: 1,
arch: `
<form>
<field name="html_field" widget="iframe_wrapper"/>
</form>`,
});
const iframeDoc = target.querySelector("iframe").contentDocument;
assert.strictEqual(iframeDoc.querySelector(".nice_div").innerHTML, "<p>Some content</p>");
assert.strictEqual($(iframeDoc).find(".nice_div p").css("color"), "rgb(255, 0, 0)");
});
QUnit.test("IframeWrapperField in form view with onchange", async function (assert) {
serverData.models.report.onchanges = {
int_field(record) {
record.html_field = record.html_field.replace("Some content", "New content");
},
};
await makeView({
type: "form",
resModel: "report",
serverData,
resId: 1,
arch: `
<form>
<field name="int_field"/>
<field name="html_field" widget="iframe_wrapper"/>
</form>`,
});
const iframeDoc = target.querySelector("iframe").contentDocument;
assert.strictEqual(iframeDoc.querySelector(".nice_div").innerHTML, "<p>Some content</p>");
assert.strictEqual($(iframeDoc).find(".nice_div p").css("color"), "rgb(255, 0, 0)");
await editInput(target, ".o_field_widget[name=int_field] input", 264);
assert.strictEqual(iframeDoc.querySelector(".nice_div").innerHTML, "<p>New content</p>");
});
});

View file

@ -0,0 +1,880 @@
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,
contains,
} 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):not(.o-kanban-button-new)").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):not(.o-kanban-button-new)");
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 contains("div[name='write_date'] > div > button").click();
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

@ -1,963 +0,0 @@
/** @odoo-module **/
import {
click,
getFixture,
nextTick,
triggerEvent,
clickSave,
editInput,
patchDate,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { pagerNext } from "@web/../tests/search/helpers";
const { DateTime } = luxon;
const MY_IMAGE =
"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
const PRODUCT_IMAGE =
"R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7";
let serverData;
let target;
function getUnique(target) {
const src = target.dataset.src;
return new URL(src).searchParams.get("unique");
}
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: {
string: "Displayed name",
type: "char",
searchable: true,
},
timmy: {
string: "pokemon",
type: "many2many",
relation: "partner_type",
searchable: true,
},
foo: { type: "char" },
document: { string: "Binary", type: "binary" },
},
records: [
{
id: 1,
display_name: "first record",
timmy: [],
document: "coucou==",
},
{
id: 2,
display_name: "second record",
timmy: [],
},
{
id: 4,
display_name: "aaa",
},
],
},
partner_type: {
fields: {
name: { string: "Partner Type", type: "char", searchable: true },
color: { string: "Color index", type: "integer", searchable: true },
},
records: [
{ id: 12, display_name: "gold", color: 2 },
{ id: 14, display_name: "silver", color: 5 },
],
},
},
};
setupViewRegistries();
});
QUnit.module("ImageField");
QUnit.test("ImageField is correctly rendered", async function (assert) {
assert.expect(12);
serverData.models.partner.records[0].__last_update = "2017-02-08 10:00:00";
serverData.models.partner.records[0].document = MY_IMAGE;
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}" />
</form>`,
mockRPC(route, { args }) {
if (route === "/web/dataset/call_kw/partner/read") {
assert.deepEqual(
args[1],
["__last_update", "document", "display_name"],
"The fields document, display_name and __last_update should be present when reading an image"
);
}
},
});
assert.hasClass(
target.querySelector(".o_field_widget[name='document']"),
"o_field_image",
"the widget should have the correct class"
);
assert.containsOnce(
target,
".o_field_widget[name='document'] img",
"the widget should contain an image"
);
assert.strictEqual(
target.querySelector('div[name="document"] img').dataset.src,
`data:image/png;base64,${MY_IMAGE}`,
"the image should have the correct src"
);
assert.hasClass(
target.querySelector(".o_field_widget[name='document'] img"),
"img-fluid",
"the image should have the correct class"
);
assert.hasAttrValue(
target.querySelector(".o_field_widget[name='document'] img"),
"width",
"90",
"the image should correctly set its attributes"
);
assert.strictEqual(
target.querySelector(".o_field_widget[name='document'] img").style.maxWidth,
"90px",
"the image should correctly set its attributes"
);
const computedStyle = window.getComputedStyle(
target.querySelector(".o_field_widget[name='document'] img")
);
assert.strictEqual(
computedStyle.width,
"90px",
"the image should correctly set its attributes"
);
assert.strictEqual(
computedStyle.height,
"90px",
"the image should correctly set its attributes"
);
assert.containsOnce(
target,
".o_field_image .o_select_file_button",
"the image can be edited"
);
assert.containsOnce(
target,
".o_field_image .o_clear_file_button",
"the image can be deleted"
);
assert.strictEqual(
target.querySelector("input.o_input_file").getAttribute("accept"),
"image/*",
'the default value for the attribute "accept" on the "image" widget must be "image/*"'
);
});
QUnit.test(
"ImageField is correctly replaced when given an incorrect value",
async function (assert) {
serverData.models.partner.records[0].document = "incorrect_base64_value";
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>`,
});
assert.strictEqual(
target.querySelector('div[name="document"] img').dataset.src,
"data:image/png;base64,incorrect_base64_value",
"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
await triggerEvent(target, 'div[name="document"] img', "error");
assert.hasClass(
target.querySelector('.o_field_widget[name="document"]'),
"o_field_image",
"the widget should have the correct class"
);
assert.containsOnce(
target,
".o_field_widget[name='document'] img",
"the widget should contain an image"
);
assert.strictEqual(
target.querySelector('div[name="document"] img').dataset.src,
"/web/static/img/placeholder.png",
"the image should have the correct src"
);
assert.hasClass(
target.querySelector(".o_field_widget[name='document'] img"),
"img-fluid",
"the image should have the correct class"
);
assert.hasAttrValue(
target.querySelector(".o_field_widget[name='document'] img"),
"width",
"90",
"the image should correctly set its attributes"
);
assert.strictEqual(
target.querySelector(".o_field_widget[name='document'] img").style.maxWidth,
"90px",
"the image should correctly set its attributes"
);
assert.containsOnce(
target,
".o_field_image .o_select_file_button",
"the image can be edited"
);
assert.containsNone(
target,
".o_field_image .o_clear_file_button",
"the image cannot be deleted as it has not been uploaded"
);
}
);
QUnit.test("ImageField preview is updated when an image is uploaded", async function (assert) {
const imageData = Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)));
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>`,
});
assert.strictEqual(
target.querySelector('div[name="document"] img').dataset.src,
"data:image/png;base64,coucou==",
"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.
const fileInput = target.querySelector("input[type=file]");
const fakeInput = {
files: [new File([imageData], "fake_file.png", { type: "png" })],
};
fileInput.addEventListener(
"change",
(ev) => {
Object.defineProperty(ev, "target", { value: fakeInput });
},
{ capture: true }
);
fileInput.dispatchEvent(new Event("change"));
// It can take some time to encode the data as a base64 url
await new Promise((resolve) => setTimeout(resolve, 50));
// Wait for a render
await nextTick();
assert.strictEqual(
target.querySelector("div[name=document] img").dataset.src,
`data:image/png;base64,${MY_IMAGE}`,
"the image should have the new src"
);
});
QUnit.test(
"clicking save manually after uploading new image should change the unique of the image src",
async function (assert) {
serverData.models.partner.onchanges = { foo: () => {} };
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.__last_update = "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;
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: /* xml */ `
<form>
<field name="foo"/>
<field name="document" widget="image" />
</form>`,
mockRPC(_route, { method, args }) {
if (method === "write") {
args[1].__last_update = lastUpdates[index];
args[1].document = "4 kb";
index++;
}
},
});
assert.strictEqual(
getUnique(target.querySelector(".o_field_image img")),
"1659688620000"
);
await editInput(
target,
"input[type=file]",
new File(
[Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file.png",
{ type: "png" }
)
);
assert.strictEqual(
target.querySelector("div[name=document] img").dataset.src,
`data:image/png;base64,${MY_IMAGE}`
);
await editInput(target, ".o_field_widget[name='foo'] input", "grrr");
assert.strictEqual(
target.querySelector("div[name=document] img").dataset.src,
`data:image/png;base64,${MY_IMAGE}`
);
await clickSave(target);
assert.strictEqual(
getUnique(target.querySelector(".o_field_image img")),
"1659692220000"
);
// Change the image again. After clicking save, it should have the correct new url.
await editInput(
target,
"input[type=file]",
new File(
[Uint8Array.from([...atob(PRODUCT_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file2.gif",
{ type: "png" }
)
);
assert.strictEqual(
target.querySelector("div[name=document] img").dataset.src,
`data:image/gif;base64,${PRODUCT_IMAGE}`
);
await clickSave(target);
assert.strictEqual(
getUnique(target.querySelector(".o_field_image img")),
"1659695820000"
);
}
);
QUnit.test("save record with image field modified by onchange", async function (assert) {
serverData.models.partner.onchanges = {
foo: (data) => {
data.document = MY_IMAGE;
},
};
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.__last_update = "2022-08-05 08:37:00"; // 1659688620000
// 1659692220000
const lastUpdates = ["2022-08-05 09:37:00"];
let index = 0;
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: /* xml */ `
<form>
<field name="foo"/>
<field name="document" widget="image" />
</form>`,
mockRPC(_route, { method, args }) {
if (method === "write") {
args[1].__last_update = lastUpdates[index];
args[1].document = "3 kb";
index++;
}
},
});
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
await editInput(target, "[name='foo'] input", "grrr");
assert.strictEqual(
target.querySelector("div[name=document] img").dataset.src,
`data:image/png;base64,${MY_IMAGE}`
);
await clickSave(target);
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659692220000");
});
QUnit.test("ImageField: option accepted_file_extensions", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'accepted_file_extensions': '.png,.jpeg'}" />
</form>`,
});
// The view must be in edit mode
assert.strictEqual(
target.querySelector("input.o_input_file").getAttribute("accept"),
".png,.jpeg",
"the input should have the correct ``accept`` attribute"
);
});
QUnit.test("ImageField: set 0 width/height in the size option", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<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 = target.querySelectorAll(".o_field_widget img");
assert.deepEqual(
[imgs[0].attributes.width, imgs[0].attributes.height],
[undefined, undefined],
"if both size are set to 0, both attributes are undefined"
);
assert.deepEqual(
[imgs[1].attributes.width, imgs[1].attributes.height.value],
[undefined, "50"],
"if only the width is set to 0, the width attribute is not set on the img"
);
assert.deepEqual(
[
imgs[1].style.width,
imgs[1].style.maxWidth,
imgs[1].style.height,
imgs[1].style.maxHeight,
],
["auto", "100%", "", "50px"],
"the image should correctly set its attributes"
);
assert.deepEqual(
[imgs[2].attributes.width.value, imgs[2].attributes.height],
["50", undefined],
"if only the height is set to 0, the height attribute is not set on the img"
);
assert.deepEqual(
[
imgs[2].style.width,
imgs[2].style.maxWidth,
imgs[2].style.height,
imgs[2].style.maxHeight,
],
["", "50px", "auto", "100%"],
"the image should correctly set its attributes"
);
});
QUnit.test("ImageField: zoom and zoom_delay options (readonly)", async (assert) => {
serverData.models.partner.records[0].document = MY_IMAGE;
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'zoom': true, 'zoom_delay': 600}" readonly="1" />
</form>`,
});
// data-tooltip attribute is used by the tooltip service
assert.strictEqual(
JSON.parse(target.querySelector(".o_field_image img").dataset["tooltipInfo"]).url,
`data:image/png;base64,${MY_IMAGE}`,
"shows a tooltip on hover"
);
assert.strictEqual(
target.querySelector(".o_field_image img").dataset["tooltipDelay"],
"600",
"tooltip has the right delay"
);
});
QUnit.test("ImageField: zoom and zoom_delay options (edit)", async function (assert) {
serverData.models.partner.records[0].document = "3 kb";
serverData.models.partner.records[0].__last_update = "2022-08-05 08:37:00";
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'zoom': true, 'zoom_delay': 600}" />
</form>`,
});
assert.ok(
JSON.parse(
target.querySelector(".o_field_image img").dataset["tooltipInfo"]
).url.endsWith("/web/image?model=partner&id=1&field=document&unique=1659688620000"),
"tooltip show the full image from the field value"
);
assert.strictEqual(
target.querySelector(".o_field_image img").dataset["tooltipDelay"],
"600",
"tooltip has the right delay"
);
});
QUnit.test(
"ImageField displays the right images with zoom and preview_image options (readonly)",
async function (assert) {
serverData.models.partner.records[0].document = "3 kb";
serverData.models.partner.records[0].__last_update = "2022-08-05 08:37:00";
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'zoom': true, 'preview_image': 'document_preview', 'zoom_delay': 600}" readonly="1" />
</form>`,
});
assert.ok(
JSON.parse(
target.querySelector(".o_field_image img").dataset["tooltipInfo"]
).url.endsWith("/web/image?model=partner&id=1&field=document&unique=1659688620000"),
"tooltip show the full image from the field value"
);
assert.strictEqual(
target.querySelector(".o_field_image img").dataset["tooltipDelay"],
"600",
"tooltip has the right delay"
);
assert.ok(
target
.querySelector(".o_field_image img")
.dataset.src.endsWith(
"/web/image?model=partner&id=1&field=document_preview&unique=1659688620000"
),
"image src is the preview image given in option"
);
}
);
QUnit.test("ImageField in subviews is loaded correctly", async function (assert) {
serverData.models.partner.records[0].__last_update = "2017-02-08 10:00:00";
serverData.models.partner.records[0].document = MY_IMAGE;
serverData.models.partner_type.fields.image = {
name: "image",
type: "binary",
};
serverData.models.partner_type.records[0].image = PRODUCT_IMAGE;
serverData.models.partner.records[0].timmy = [12];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="document" widget="image" options="{'size': [90, 90]}" />
<field name="timmy" widget="many2many" mode="kanban">
<kanban>
<field name="display_name" />
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<span>
<t t-esc="record.display_name.value" />
</span>
</div>
</t>
</templates>
</kanban>
<form>
<field name="image" widget="image" />
</form>
</field>
</form>`,
});
assert.containsOnce(target, `img[data-src="data:image/png;base64,${MY_IMAGE}"]`);
assert.containsOnce(target, ".o_kanban_record .oe_kanban_global_click");
// Actual flow: click on an element of the m2m to get its form view
await click(target, ".oe_kanban_global_click");
assert.containsOnce(target, ".modal", "The modal should have opened");
assert.containsOnce(target, `img[data-src="data:image/gif;base64,${PRODUCT_IMAGE}"]`);
});
QUnit.test("ImageField in x2many list is loaded correctly", async function (assert) {
serverData.models.partner_type.fields.image = {
name: "image",
type: "binary",
};
serverData.models.partner_type.records[0].image = PRODUCT_IMAGE;
serverData.models.partner.records[0].timmy = [12];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="timmy" widget="many2many">
<tree>
<field name="image" widget="image" />
</tree>
</field>
</form>`,
});
assert.containsOnce(target, "tr.o_data_row", "There should be one record in the many2many");
assert.ok(
document.querySelector(`img[data-src="data:image/gif;base64,${PRODUCT_IMAGE}"]`),
"The list's image is in the DOM"
);
});
QUnit.test("ImageField with required attribute", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="document" widget="image" required="1" />
</form>`,
mockRPC(route, { method }) {
if (method === "create") {
throw new Error("Should not do a create RPC with unset required image field");
}
},
});
await clickSave(target);
assert.containsOnce(
target.querySelector(".o_form_view"),
".o_form_editable",
"form view should still be editable"
);
assert.hasClass(
target.querySelector(".o_field_widget"),
"o_field_invalid",
"image field should be displayed as invalid"
);
});
QUnit.test("ImageField is reset when changing record", async function (assert) {
const imageData = Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)));
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `<form>
<field name="document" widget="image" options="{'size': [90, 90]}"/>
</form>`,
});
async function setFiles() {
const list = new DataTransfer();
list.items.add(new File([imageData], "fake_file.png", { type: "png" }));
const fileInput = target.querySelector("input[type=file]");
fileInput.files = list.files;
fileInput.dispatchEvent(new Event("change"));
// It can take some time to encode the data as a base64 url
await new Promise((resolve) => setTimeout(resolve, 50));
// Wait for a render
await nextTick();
}
assert.strictEqual(
target.querySelector("img[data-alt='Binary file']").dataset.src,
"/web/static/img/placeholder.png",
"image field should not be set"
);
await setFiles();
assert.ok(
target
.querySelector("img[data-alt='Binary file']")
.dataset.src.includes("data:image/png;base64"),
"image field should be set"
);
await clickSave(target);
await click(target, ".o_form_button_create");
assert.strictEqual(
target.querySelector("img[data-alt='Binary file']").dataset.src,
"/web/static/img/placeholder.png",
"image field should be reset"
);
await setFiles();
assert.ok(
target
.querySelector("img[data-alt='Binary file']")
.dataset.src.includes("data:image/png;base64"),
"image field should be set"
);
});
QUnit.test("unique in url doesn't change on onchange", async (assert) => {
serverData.models.partner.onchanges = {
foo: () => {},
};
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.__last_update = "2022-08-05 08:37:00";
await makeView({
resId: 1,
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="foo" />
<field name="document" widget="image" required="1" />
</form>`,
mockRPC(route, { method, args }) {
assert.step(method);
if (method === "write") {
// 1659692220000
args[1].__last_update = "2022-08-05 09:37:00";
}
},
});
assert.verifySteps(["get_views", "read"]);
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
assert.verifySteps([]);
// same unique as before
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
await editInput(target, ".o_field_widget[name='foo'] input", "grrr");
assert.verifySteps(["onchange"]);
// also same unique
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
await clickSave(target);
assert.verifySteps(["write", "read"]);
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659692220000");
});
QUnit.test("unique in url change on record change", async (assert) => {
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.__last_update = "2022-08-05 08:37:00";
const rec2 = serverData.models.partner.records.find((rec) => rec.id === 2);
rec2.document = "3 kb";
rec2.__last_update = "2022-08-05 09:37:00";
await makeView({
resIds: [1, 2],
resId: 1,
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="document" widget="image" required="1" />
</form>`,
});
function getUnique(target) {
const src = target.dataset.src;
return new URL(src).searchParams.get("unique");
}
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
await pagerNext(target);
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659692220000");
});
QUnit.test(
"unique in url does not change on record change if no_reload option is set",
async (assert) => {
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
rec.document = "3 kb";
rec.__last_update = "2022-08-05 08:37:00";
await makeView({
resIds: [1, 2],
resId: 1,
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="document" widget="image" required="1" options="{'no_reload': true}" />
<field name="__last_update" />
</form>`,
});
function getUnique(target) {
const src = target.dataset.src;
return new URL(src).searchParams.get("unique");
}
assert.strictEqual(
getUnique(target.querySelector(".o_field_image img")),
"1659688620000"
);
await editInput(
target.querySelector(
"div[name='__last_update'] > div > input",
"2022-08-05 08:39:00"
)
);
await click(target, ".o_form_button_save");
assert.strictEqual(
getUnique(target.querySelector(".o_field_image img")),
"1659688620000"
);
}
);
QUnit.test(
"url should not use the record last updated date when the field is related",
async function (assert) {
serverData.models.partner.fields.related = {
name: "Binary",
type: "binary",
related: "user.image",
};
serverData.models.partner.fields.user = {
name: "User",
type: "many2one",
relation: "user",
default: 1,
};
serverData.models.user = {
fields: {
image: {
name: "Image",
type: "binary",
},
},
records: [
{
id: 1,
image: "3 kb",
},
],
};
serverData.models.partner.records[0].__last_update = "2017-02-08 10:00:00";
patchDate(2017, 1, 6, 11, 0, 0);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<sheet>
<group>
<field name="foo" />
<field name="user"/>
<field name="related" widget="image"/>
</group>
</sheet>
</form>`,
async mockRPC(route, { args }, performRpc) {
if (route === "/web/dataset/call_kw/partner/read") {
const res = await performRpc(...arguments);
// The mockRPC doesn't implement related fields
res[0].related = "3 kb";
return res;
}
},
});
const initialUnique = Number(getUnique(target.querySelector(".o_field_image img")));
assert.ok(
DateTime.fromMillis(initialUnique).hasSame(DateTime.fromISO("2017-02-06"), "days")
);
await editInput(target, ".o_field_widget[name='foo'] input", "grrr");
// the unique should be the same
assert.strictEqual(
initialUnique,
Number(getUnique(target.querySelector(".o_field_image img")))
);
patchDate(2017, 1, 9, 11, 0, 0);
await editInput(
target,
"input[type=file]",
new File(
[Uint8Array.from([...atob(MY_IMAGE)].map((c) => c.charCodeAt(0)))],
"fake_file.png",
{ type: "png" }
)
);
assert.strictEqual(
target.querySelector(".o_field_image img").dataset.src,
`data:image/png;base64,${MY_IMAGE}`
);
patchDate(2017, 1, 9, 12, 0, 0);
await clickSave(target);
const unique = Number(getUnique(target.querySelector(".o_field_image img")));
assert.ok(DateTime.fromMillis(unique).hasSame(DateTime.fromISO("2017-02-09"), "days"));
}
);
});

View file

@ -0,0 +1,226 @@
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 },
];
}
class User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
defineModels([Partner, PartnerType, User]);
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):not(.o-kanban-button-new)").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):not(.o-kanban-button-new)");
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

@ -1,282 +0,0 @@
/** @odoo-module **/
import { KanbanController } from "@web/views/kanban/kanban_controller";
import {
click,
editInput,
getFixture,
nextTick,
patchWithCleanup,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
const FR_FLAG_URL = "/base/static/img/country_flags/fr.png";
const EN_FLAG_URL = "/base/static/img/country_flags/gb.png";
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char", searchable: true },
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
trim: true,
},
p: {
string: "one2many field",
type: "one2many",
relation: "partner",
searchable: true,
},
timmy: {
string: "pokemon",
type: "many2many",
relation: "partner_type",
searchable: true,
},
},
records: [
{
id: 1,
foo: FR_FLAG_URL,
timmy: [],
},
],
onchanges: {},
},
partner_type: {
fields: {
name: { string: "Partner Type", type: "char", searchable: true },
color: { string: "Color index", type: "integer", searchable: true },
},
records: [
{ id: 12, display_name: "gold", color: 2 },
{ id: 14, display_name: "silver", color: 5 },
],
},
},
};
setupViewRegistries();
});
/**
* Same tests than for Image fields, but for Char fields with image_url widget.
*/
QUnit.module("ImageUrlField");
QUnit.test("image fields are correctly rendered", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
</form>`,
resId: 1,
});
assert.hasClass(
target.querySelector('div[name="foo"]'),
"o_field_image_url",
"the widget should have the correct class"
);
assert.containsOnce(target, 'div[name="foo"] > img', "the widget should contain an image");
assert.strictEqual(
target.querySelector('div[name="foo"] > img').dataset.src,
FR_FLAG_URL,
"the image should have the correct src"
);
assert.hasClass(
target.querySelector('div[name="foo"] > img'),
"img-fluid",
"the image should have the correct class"
);
assert.hasAttrValue(
target.querySelector('div[name="foo"] > img'),
"width",
"90",
"the image should correctly set its attributes"
);
assert.strictEqual(
target.querySelector('div[name="foo"] > img').style.maxWidth,
"90px",
"the image should correctly set its attributes"
);
});
QUnit.test("ImageUrlField in subviews are loaded correctly", async function (assert) {
serverData.models.partner_type.fields.image = { name: "image", type: "char" };
serverData.models.partner_type.records[0].image = EN_FLAG_URL;
serverData.models.partner.records[0].timmy = [12];
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
<field name="timmy" widget="many2many" mode="kanban">
<kanban>
<field name="display_name"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<span><t t-esc="record.display_name.value"/></span>
</div>
</t>
</templates>
</kanban>
<form>
<field name="image" widget="image_url"/>
</form>
</field>
</form>`,
resId: 1,
});
assert.ok(
document.querySelector(`img[data-src="${FR_FLAG_URL}"]`),
"The view's image is in the DOM"
);
assert.containsOnce(
target,
".o_kanban_record .oe_kanban_global_click",
"There should be one record in the many2many"
);
// Actual flow: click on an element of the m2m to get its form view
await click(target.querySelector(".oe_kanban_global_click"));
assert.containsOnce(document.body, ".modal", "The modal should have opened");
assert.ok(
document.querySelector(`img[data-src="${EN_FLAG_URL}"]`),
"The dialog's image is in the DOM"
);
});
QUnit.test("image fields in x2many list are loaded correctly", async function (assert) {
serverData.models.partner_type.fields.image = { name: "image", type: "char" };
serverData.models.partner_type.records[0].image = EN_FLAG_URL;
serverData.models.partner.records[0].timmy = [12];
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="timmy" widget="many2many">
<tree>
<field name="image" widget="image_url"/>
</tree>
</field>
</form>`,
resId: 1,
});
assert.containsOnce(target, "tr.o_data_row", "There should be one record in the many2many");
assert.ok(
document.querySelector(`img[data-src="${EN_FLAG_URL}"]`),
"The list's image is in the DOM"
);
});
QUnit.test("image url fields in kanban don't stop opening record", async function (assert) {
patchWithCleanup(KanbanController.prototype, {
openRecord() {
assert.step("open record");
},
});
await makeView({
type: "kanban",
serverData,
resModel: "partner",
arch: /* xml */ `
<kanban>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="foo" widget="image_url"/>
</div>
</t>
</templates>
</kanban>`,
});
await click(target.querySelector(".o_kanban_record"));
assert.verifySteps(["open record"]);
});
QUnit.test("image fields with empty value", async function (assert) {
serverData.models.partner.records[0].foo = false;
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
</form>`,
resId: 1,
});
assert.hasClass(
target.querySelector('div[name="foo"]'),
"o_field_image_url",
"the widget should have the correct class"
);
assert.containsNone(
target,
'div[name="foo"] > img',
"the widget should not contain an image"
);
});
QUnit.test("onchange update image fields", async function (assert) {
const srcTest = "/my/test/src";
serverData.models.partner.onchanges = {
display_name(record) {
record.foo = srcTest;
},
};
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="display_name"/>
<field name="foo" widget="image_url" options="{'size': [90, 90]}"/>
</form>`,
resId: 1,
});
assert.strictEqual(
target.querySelector('div[name="foo"] > img').dataset.src,
FR_FLAG_URL,
"the image should have the correct src"
);
await editInput(target, '[name="display_name"] input', "test");
await nextTick();
assert.strictEqual(
target.querySelector('div[name="foo"] > img').dataset.src,
srcTest,
"the image should have the onchange src"
);
});
});

View file

@ -0,0 +1,297 @@
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("with 'min'/'max' option", async () => {
Product._records = [{ id: 1, price: 10 }];
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="price" options="{'type': 'number', 'min': 3, 'max': 10}"/></form>`,
});
expect(".o_field_widget input").toHaveAttribute("min", "3");
expect(".o_field_widget input").toHaveAttribute("max", "10");
});
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 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

@ -1,386 +0,0 @@
/** @odoo-module **/
import { localization } from "@web/core/l10n/localization";
import { defaultLocalization } from "@web/../tests/helpers/mock_services";
import {
click,
clickSave,
editInput,
findElement,
getFixture,
nextTick,
patchWithCleanup,
triggerEvent,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
int_field: {
string: "int_field",
type: "integer",
},
},
records: [
{ id: 1, int_field: 10 },
{ id: 2, int_field: false },
{ id: 3, int_field: 8069 },
],
},
},
};
setupViewRegistries();
});
QUnit.module("IntegerField");
QUnit.test("should be 0 when unset", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 2,
arch: '<form><field name="int_field"/></form>',
});
assert.doesNotHaveClass(
target.querySelector(".o_field_widget"),
"o_field_empty",
"Non-set integer field should be recognized as 0."
);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"0",
"Non-set integer field should be recognized as 0."
);
});
QUnit.test("basic form view flow", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="int_field"/></form>',
});
assert.strictEqual(
target.querySelector(".o_field_widget[name=int_field] input").value,
"10",
"The value should be rendered correctly in edit mode."
);
await editInput(target, ".o_field_widget[name=int_field] input", "30");
assert.strictEqual(
target.querySelector(".o_field_widget[name=int_field] input").value,
"30",
"The value should be correctly displayed in the input."
);
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"30",
"The new value should be saved and displayed properly."
);
});
QUnit.test("rounded when using formula in form view", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="int_field"/></form>',
});
await editInput(target, ".o_field_widget[name=int_field] input", "=100/3");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"33",
"The new value should be calculated properly."
);
});
QUnit.test("with input type 'number' option", async function (assert) {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `<form><field name="int_field" options="{'type': 'number'}"/></form>`,
});
assert.ok(
target.querySelector(".o_field_widget input").hasAttribute("type"),
"Integer field with option type must have a type attribute."
);
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"type",
"number",
'Integer field with option type must have a type attribute equals to "number".'
);
await editInput(target, ".o_field_widget[name=int_field] input", "1234567890");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"1234567890",
"Integer value must be not formatted if input type is number."
);
});
QUnit.test("with 'step' option", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `<form><field name="int_field" options="{'type': 'number', 'step': 3}"/></form>`,
});
assert.ok(
target.querySelector(".o_field_widget input").hasAttribute("step"),
"Integer field with option type must have a step attribute."
);
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"step",
"3",
'Integer field with option type must have a step attribute equals to "3".'
);
});
QUnit.test("without input type option", async function (assert) {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="int_field"/></form>',
});
assert.hasAttrValue(
target.querySelector(".o_field_widget input"),
"type",
"text",
"Integer field without option type must have a text type (default type)."
);
await editInput(target, ".o_field_widget[name=int_field] input", "1234567890");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"1,234,567,890",
"Integer value must be formatted if input type isn't number."
);
});
QUnit.test("with disable formatting option", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 3,
arch: `<form><field name="int_field" options="{'format': 'false'}"/></form>`,
});
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"8069",
"Integer value must not be formatted"
);
});
QUnit.test("IntegerField is formatted by default", async function (assert) {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 3,
arch: '<form><field name="int_field"/></form>',
});
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"8,069",
"Integer value must be formatted by default"
);
});
QUnit.test("basic flow in editable list view", async function (assert) {
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: '<tree editable="bottom"><field name="int_field"/></tree>',
});
var zeroValues = Array.from(target.querySelectorAll("td")).filter(
(el) => el.textContent === "0"
);
assert.strictEqual(
zeroValues.length,
1,
"Unset integer values should not be rendered as zeros."
);
// switch to edit mode
var cell = target.querySelector("tr.o_data_row td:not(.o_list_record_selector)");
await click(cell);
assert.containsOnce(
target,
'.o_field_widget[name="int_field"] input',
"The view should have 1 input for editable integer."
);
await editInput(target, ".o_field_widget[name=int_field] input", "-28");
assert.strictEqual(
target.querySelector('.o_field_widget[name="int_field"] input').value,
"-28",
"The value should be displayed properly in the input."
);
await click(target.querySelector(".o_list_button_save"));
assert.strictEqual(
target.querySelector("td:not(.o_list_record_selector)").textContent,
"-28",
"The new value should be saved and displayed properly."
);
});
QUnit.test("IntegerField field with placeholder", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `<form><field name="int_field" placeholder="Placeholder"/></form>`,
});
const input = target.querySelector(".o_field_widget[name='int_field'] input");
input.value = "";
await triggerEvent(input, null, "input");
assert.strictEqual(
target.querySelector(".o_field_widget[name='int_field'] input").placeholder,
"Placeholder"
);
});
QUnit.test(
"no need to focus out of the input to save the record after correcting an invalid input",
async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="int_field"/></form>',
});
const input = findElement(target, ".o_field_widget[name=int_field] input");
assert.strictEqual(input.value, "10");
input.value = "a";
triggerEvent(input, null, "input", {});
assert.strictEqual(input.value, "a");
await clickSave(target);
assert.containsOnce(target, ".o_form_status_indicator span i.fa-warning");
input.value = "1";
triggerEvent(input, null, "input", {});
await nextTick();
assert.containsNone(target, ".o_form_status_indicator span i.fa-warning");
assert.containsOnce(target, ".o_form_button_save");
}
);
QUnit.test(
"make a valid integer field invalid, then reset the original value to make it valid again",
async function (assert) {
// This test is introduced to fix a bug:
// Have a valid value, change it to an invalid value, blur, then change it back to the same valid value.
// The field was considered not dirty, so the onChange code wasn't executed, and the model still thought the value was invalid.
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="int_field"/></form>',
});
const fieldSelector = ".o_field_widget[name=int_field]";
const inputSelector = fieldSelector + " input";
assert.strictEqual(target.querySelector(inputSelector).value, "10");
await editInput(target.querySelector(inputSelector), null, "a");
assert.strictEqual(target.querySelector(inputSelector).value, "a");
assert.hasClass(target.querySelector(fieldSelector), "o_field_invalid");
await editInput(target.querySelector(inputSelector), null, "10");
assert.strictEqual(target.querySelector(inputSelector).value, "10");
assert.doesNotHaveClass(target.querySelector(fieldSelector), "o_field_invalid");
}
);
QUnit.test("value is formatted on Enter", async function (assert) {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
await makeView({
type: "form",
serverData,
resModel: "partner",
arch: '<form><field name="int_field"/></form>',
});
target.querySelector(".o_field_widget input").value = 1000;
await triggerEvent(target, ".o_field_widget input", "input");
assert.strictEqual(target.querySelector(".o_field_widget input").value, "1000");
await triggerEvent(target, ".o_field_widget input", "keydown", { key: "Enter" });
assert.strictEqual(target.querySelector(".o_field_widget input").value, "1,000");
});
QUnit.test("value is formatted on Enter (even if same value)", async function (assert) {
patchWithCleanup(localization, { ...defaultLocalization, grouping: [3, 0] });
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 3,
arch: '<form><field name="int_field"/></form>',
});
assert.strictEqual(target.querySelector(".o_field_widget input").value, "8,069");
target.querySelector(".o_field_widget input").value = 8069;
await triggerEvent(target, ".o_field_widget input", "input");
assert.strictEqual(target.querySelector(".o_field_widget input").value, "8069");
await triggerEvent(target, ".o_field_widget input", "keydown", { key: "Enter" });
assert.strictEqual(target.querySelector(".o_field_widget input").value, "8,069");
});
});

View file

@ -0,0 +1,105 @@
import { expect, test, describe } from "@odoo/hoot";
import {
defineModels,
fields,
models,
mountView,
preloadBundle,
preventResizeObserverError,
} from "@web/../tests/web_test_helpers";
const INVALID_LOCATOR = ".invalid_locator";
class IrUiView extends models.Model {
_name = "ir.ui.view";
_rec_name = "name";
name = fields.Char({ required: true });
arch = fields.Text({});
invalid_locators = fields.Json();
_records = [
{
id: 1,
name: "Child View",
arch: `
<data>
<xpath expr="//field[@name='name']" position="after"/>
<xpath expr="//group" position="inside"/>
<xpath expr="//field[@name='inherit_id']" position="replace"/>
<xpath expr="//field[@name='non_existent_field']" position="after"/>
<xpath expr="//nonexistent_tag" position="inside"/>
<xpath expr="//field[@name='arch_invalid']" position="after"/>
<field name="invalid" position="replace"/>
</data>
`,
invalid_locators: false,
},
];
}
defineModels([IrUiView]);
// Preload necessary bundles and prevent ResizeObserver errors
preloadBundle("web.ace_lib");
preventResizeObserverError();
const mountChildView = async () =>
await mountView({
resModel: "ir.ui.view",
resId: 1,
type: "form",
arch: `
<form>
<field name="invalid_locators"/>
<field name="arch" widget="code_ir_ui_view" options='{"mode": "xml"}'/>
</form>
`,
});
describe("Highlight invalid locators in inherited ir.ui.view", () => {
test("with no invalid locators", async () => {
await mountChildView();
expect(INVALID_LOCATOR).toHaveCount(0);
});
test("with invalid locators", async () => {
const invalid_locators = [
{
tag: "xpath",
attrib: {
expr: "//field[@name='non_existent_field']",
position: "after",
},
sourceline: 6,
},
{
tag: "xpath",
attrib: {
expr: "//nonexistent_tag",
position: "inside",
},
sourceline: 7,
},
{
tag: "xpath",
attrib: {
expr: "//field[@name='arch_invalid']",
position: "after",
},
sourceline: 8,
},
{
tag: "field",
attrib: {
name: "invalid",
position: "replace",
},
sourceline: 9,
},
];
IrUiView._records[0].invalid_locators = invalid_locators;
await mountChildView();
expect(INVALID_LOCATOR).toHaveCount(invalid_locators.length);
});
});

View file

@ -0,0 +1,153 @@
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,
},
]),
},
];
}
class User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
defineModels([Partner, User]);
// 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

@ -1,185 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { fakeCookieService } from "@web/../tests/helpers/mock_services";
import { click, getFixture, nextTick, triggerEvent } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let target;
let serverData;
QUnit.module("Fields", (hooks) => {
const graph_values = [
{ value: 300, label: "5-11 Dec" },
{ value: 500, label: "12-18 Dec" },
{ value: 100, label: "19-25 Dec" },
];
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
int_field: {
string: "int_field",
type: "integer",
sortable: true,
searchable: true,
},
selection: {
string: "Selection",
type: "selection",
searchable: true,
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
},
graph_data: { string: "Graph Data", type: "text" },
graph_type: {
string: "Graph Type",
type: "selection",
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,
},
]),
},
],
},
},
};
setupViewRegistries();
registry.category("services").add("cookie", fakeCookieService);
});
async function reloadKanbanView(target) {
await click(target, "input.o_searchview_input");
await triggerEvent(target, "input.o_searchview_input", "keydown", { key: "Enter" });
}
// 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 nextTick();
};
QUnit.module("JournalDashboardGraphField");
QUnit.test("JournalDashboardGraphField is rendered correctly", async function (assert) {
await makeView({
serverData,
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="graph_type"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>
</div>
</t>
</templates>
</kanban>`,
domain: [["id", "in", [1, 2]]],
});
assert.containsN(
target,
".o_dashboard_graph canvas",
2,
"there should be two graphs rendered"
);
assert.containsOnce(
target,
".o_kanban_record:nth-child(1) .o_graph_barchart",
"graph of first record should be a barchart"
);
assert.containsOnce(
target,
".o_kanban_record:nth-child(2) .o_graph_linechart",
"graph of second record should be a linechart"
);
await reloadKanbanView(target);
assert.containsN(
target,
".o_dashboard_graph canvas",
2,
"there should be two graphs rendered"
);
});
QUnit.test(
"rendering of a JournalDashboardGraphField in an updated grouped kanban view",
async function (assert) {
const kanban = await makeView({
serverData,
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="graph_type"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="graph_data" t-att-graph_type="record.graph_type.raw_value" widget="dashboard_graph"/>
</div>
</t>
</templates>
</kanban>`,
domain: [["id", "in", [1, 2]]],
});
assert.containsN(
target,
".o_dashboard_graph canvas",
2,
"there should be two graph rendered"
);
await reload(kanban, { groupBy: ["selection"], domain: [["int_field", "=", 10]] });
assert.containsOnce(
target,
".o_dashboard_graph canvas",
"there should be one graph rendered"
);
}
);
});

View file

@ -0,0 +1,21 @@
import { expect, test } from "@odoo/hoot";
import { defineModels, fields, models, mountView } from "@web/../tests/web_test_helpers";
class Product extends models.Model {
json_field = fields.Json();
_records = [{ id: 1, json_field: "['coupon', 'promotion']" }];
}
defineModels([Product]);
test("basic rendering", async () => {
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: '<form><field name="json_field"/></form>',
});
expect(".o_field_json").toHaveCount(1);
expect(".o_field_json span").toHaveText(`"['coupon', 'promotion']"`);
});

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

@ -1,221 +0,0 @@
/** @odoo-module **/
import { click, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
trim: true,
},
selection: {
string: "Selection",
type: "selection",
searchable: true,
selection: [
["normal", "Normal"],
["blocked", "Blocked"],
["done", "Done"],
],
},
},
records: [
{
foo: "yop",
selection: "blocked",
},
{
foo: "blip",
selection: "normal",
},
{
foo: "abc",
selection: "done",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("LabelSelectionField");
QUnit.test("LabelSelectionField in form view", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<sheet>
<group>
<field name="selection" widget="label_selection"
options="{'classes': {'normal': 'secondary', 'blocked': 'warning','done': 'success'}}"/>
</group>
</sheet>
</form>`,
resId: 1,
});
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-warning",
"should have a warning status label since selection is the second, blocked state"
);
assert.containsNone(
target,
".o_field_widget .badge.text-bg-secondary",
"should not have a default status since selection is the second, blocked state"
);
assert.containsNone(
target,
".o_field_widget .badge.text-bg-success",
"should not have a success status since selection is the second, blocked state"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-warning").textContent,
"Blocked",
"the label should say 'Blocked' since this is the label value for that state"
);
});
QUnit.test("LabelSelectionField in editable list view", async function (assert) {
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="bottom">
<field name="foo"/>
<field name="selection" widget="label_selection"
options="{'classes': {'normal': 'secondary', 'blocked': 'warning','done': 'success'}}"/>
</tree>`,
});
assert.strictEqual(
target.querySelectorAll(".o_field_widget .badge:not(:empty)").length,
3,
"should have three visible status labels"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-warning",
"should have one warning status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-warning").textContent,
"Blocked",
"the warning label should read 'Blocked'"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-secondary",
"should have one default status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-secondary").textContent,
"Normal",
"the default label should read 'Normal'"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-success",
"should have one success status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-success").textContent,
"Done",
"the success label should read 'Done'"
);
// switch to edit mode and check the result
await click(target.querySelector("tbody td:not(.o_list_record_selector)"));
assert.strictEqual(
target.querySelectorAll(".o_field_widget .badge:not(:empty)").length,
3,
"should have three visible status labels"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-warning",
"should have one warning status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-warning").textContent,
"Blocked",
"the warning label should read 'Blocked'"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-secondary",
"should have one default status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-secondary").textContent,
"Normal",
"the default label should read 'Normal'"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-success",
"should have one success status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-success").textContent,
"Done",
"the success label should read 'Done'"
);
// save and check the result
await click(target.querySelector(".o_list_button_save"));
assert.strictEqual(
target.querySelectorAll(".o_field_widget .badge:not(:empty)").length,
3,
"should have three visible status labels"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-warning",
"should have one warning status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-warning").textContent,
"Blocked",
"the warning label should read 'Blocked'"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-secondary",
"should have one default status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-secondary").textContent,
"Normal",
"the default label should read 'Normal'"
);
assert.containsOnce(
target,
".o_field_widget .badge.text-bg-success",
"should have one success status label"
);
assert.strictEqual(
target.querySelector(".o_field_widget .badge.text-bg-success").textContent,
"Done",
"the success label should read 'Done'"
);
});
});

View file

@ -0,0 +1,248 @@
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
await contains(".o_file_input_trigger").click();
await setInputFiles(new File(["fake_file"], "fake_file.tiff", { type: "text/plain" }));
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(11);
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
await contains(".o_file_input_trigger").click();
await setInputFiles([
new File(["good_file"], "good_file.txt", { type: "text/plain" }),
new File(["bad_file"], "bad_file.txt", { type: "text/plain" }),
]);
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_content").toHaveText("Uploading error. Error on file: bad_file.txt");
expect(".o_notification_bar").toHaveClass("bg-danger");
});
test("widget many2many_binary image MIME type preview", async () => {
expect.assertions(9);
const IMAGE_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z9DwHwAGBQKA3H7sNwAAAABJRU5ErkJggg==";
const imageData = Uint8Array.from([...atob(IMAGE_B64)].map((c) => c.charCodeAt(0)));
mockService("http", {
post(route, { ufile }) {
expect(route).toBe("/web/binary/upload_attachment");
expect(ufile[0].name).toBe("fake_image.png", {
message: "file is correctly uploaded to the server",
});
const ids = MockServer.env["ir.attachment"].create(
ufile.map(({ name }) => ({ name, mimetype: "image/png" }))
);
return JSON.stringify(MockServer.env["ir.attachment"].read(ids));
},
});
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 a png image in the input
await contains(".o_file_input_trigger").click();
await setInputFiles(new File([imageData], "fake_image.png", { type: "image/png" }));
await animationFrame();
expect(".o_attachment:nth-child(2) .caption a:eq(0)").toHaveText("fake_image.png", {
message: 'value of attachment should be "fake_image.png"',
});
expect(".o_attachment:nth-child(2) .caption.small a").toHaveText("PNG", {
message: "file extension should be correct",
});
expect(".o_attachment:nth-child(2) .o_preview_image.o_hover").toHaveAttribute(
"src",
`data:image/png;base64,${IMAGE_B64}`,
{ message: "preview should display the image preview" }
);
});

View file

@ -1,293 +0,0 @@
/** @odoo-module **/
import { click, clickSave, getFixture, nextTick } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
const serviceRegistry = registry.category("services");
let target;
let serverData;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
turtle: {
fields: {
picture_ids: {
string: "Pictures",
type: "many2many",
relation: "ir.attachment",
},
},
records: [
{
id: 1,
picture_ids: [17],
},
],
},
"ir.attachment": {
fields: {
name: { string: "Name", type: "char" },
mimetype: { string: "Mimetype", type: "char" },
},
records: [
{
id: 17,
name: "Marley&Me.jpg",
mimetype: "jpg",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("Many2ManyBinaryField");
QUnit.test("widget many2many_binary", async function (assert) {
assert.expect(24);
const fakeHTTPService = {
start() {
return {
post: (route, params) => {
assert.strictEqual(route, "/web/binary/upload_attachment");
assert.strictEqual(
params.ufile[0].name,
"fake_file.tiff",
"file is correctly uploaded to the server"
);
const file = {
id: 10,
name: params.ufile[0].name,
mimetype: "text/plain",
};
serverData.models["ir.attachment"].records.push(file);
return JSON.stringify([file]);
},
};
},
};
serviceRegistry.add("http", fakeHTTPService);
serverData.views = {
"ir.attachment,false,list": '<tree string="Pictures"><field name="name"/></tree>',
};
await makeView({
serverData,
type: "form",
resModel: "turtle",
arch: `
<form>
<group>
<field name="picture_ids" widget="many2many_binary" options="{'accepted_file_extensions': 'image/*'}"/>
</group>
</form>`,
resId: 1,
mockRPC(route, args) {
if (args.method !== "get_views") {
assert.step(route);
}
if (route === "/web/dataset/call_kw/ir.attachment/read") {
assert.deepEqual(args.args[1], ["name", "mimetype"]);
}
},
});
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload",
"there should be the attachment widget"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attachments",
"there should be one attachment"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attach",
"there should be an Add button (edit)"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attachment .o_attachment_delete",
"there should be a Delete button (edit)"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attach",
"there should be an Add button"
);
assert.strictEqual(
target.querySelector("div.o_field_widget .oe_fileupload .o_attach").textContent.trim(),
"Pictures",
"the button should be correctly named"
);
assert.strictEqual(
target.querySelector("input.o_input_file").getAttribute("accept"),
"image/*",
'there should be an attribute "accept" on the input'
);
assert.verifySteps([
"/web/dataset/call_kw/turtle/read",
"/web/dataset/call_kw/ir.attachment/read",
]);
// Set and trigger the change of a file for the input
const fileInput = target.querySelector('input[type="file"]');
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(["fake_file"], "fake_file.tiff", { type: "text/plain" }));
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
assert.strictEqual(
target.querySelector(".o_attachment:nth-child(2) .caption a").textContent,
"fake_file.tiff",
'value of attachment should be "fake_file.tiff"'
);
assert.strictEqual(
target.querySelector(".o_attachment:nth-child(2) .caption.small a").textContent,
"tiff",
"file extension should be correct"
);
assert.strictEqual(
target.querySelector(".o_attachment:nth-child(2) .o_image.o_hover").dataset.mimetype,
"text/plain",
"preview displays the right mimetype"
);
// delete the attachment
await click(
target.querySelector(
"div.o_field_widget .oe_fileupload .o_attachment .o_attachment_delete"
)
);
await clickSave(target);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attachments",
"there should be only one attachment left"
);
assert.verifySteps([
"/web/dataset/call_kw/ir.attachment/read",
"/web/dataset/call_kw/turtle/write",
"/web/dataset/call_kw/turtle/read",
"/web/dataset/call_kw/ir.attachment/read",
]);
});
QUnit.test("widget many2many_binary displays notification on error", async function (assert) {
assert.expect(12);
const fakeHTTPService = {
start() {
return {
post: (route, params) => {
assert.strictEqual(route, "/web/binary/upload_attachment");
assert.deepEqual(
[params.ufile[0].name, params.ufile[1].name],
["good_file.txt", "bad_file.txt"],
"files are correctly sent to the server"
);
const files = [
{
id: 10,
name: params.ufile[0].name,
mimetype: "text/plain",
},
{
id: 11,
name: params.ufile[1].name,
mimetype: "text/plain",
error: `Error on file: ${params.ufile[1].name}`,
},
];
serverData.models["ir.attachment"].records.push(files[0]);
return JSON.stringify(files);
},
};
},
};
serviceRegistry.add("http", fakeHTTPService);
serverData.views = {
"ir.attachment,false,list": '<tree string="Pictures"><field name="name"/></tree>',
};
await makeView({
serverData,
type: "form",
resModel: "turtle",
arch: `
<form>
<group>
<field name="picture_ids" widget="many2many_binary" options="{'accepted_file_extensions': 'image/*'}"/>
</group>
</form>`,
resId: 1,
});
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload",
"there should be the attachment widget"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attachments",
"there should be one attachment"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attach",
"there should be an Add button (edit)"
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attachment .o_attachment_delete",
"there should be a Delete button (edit)"
);
// Set and trigger the import of 2 files in the input
const fileInput = target.querySelector('input[type="file"]');
const dataTransfer = new DataTransfer();
dataTransfer.items.add(new File(["good_file"], "good_file.txt", { type: "text/plain" }));
dataTransfer.items.add(new File(["bad_file"], "bad_file.txt", { type: "text/plain" }));
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await nextTick();
assert.strictEqual(
target.querySelector(".o_attachment:nth-child(2) .caption a").textContent,
"good_file.txt",
'value of attachment should be "good_file.txt"'
);
assert.containsOnce(
target,
"div.o_field_widget .oe_fileupload .o_attachments",
"there should be only one attachment uploaded"
);
assert.containsOnce(target, ".o_notification");
assert.strictEqual(
target.querySelector(".o_notification_title").textContent,
"Uploading error"
);
assert.strictEqual(
target.querySelector(".o_notification_content").textContent,
"Error on file: bad_file.txt"
);
assert.hasClass(target.querySelector(".o_notification"), "border-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"]);
});

View file

@ -1,411 +0,0 @@
/** @odoo-module **/
import { click, clickSave, editInput, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
int_field: { string: "int_field", type: "integer", sortable: true },
timmy: { string: "pokemon", type: "many2many", relation: "partner_type" },
p: {
string: "one2many field",
type: "one2many",
relation: "partner",
relation_field: "trululu",
},
trululu: { string: "Trululu", type: "many2one", relation: "partner" },
},
records: [{ id: 1, int_field: 10, p: [1] }],
onchanges: {},
},
partner_type: {
records: [
{ id: 12, display_name: "gold" },
{ id: 14, display_name: "silver" },
],
},
},
};
setupViewRegistries();
});
QUnit.module("Many2ManyCheckBoxesField");
QUnit.test("Many2ManyCheckBoxesField", async function (assert) {
serverData.models.partner.records[0].timmy = [12];
const commands = [[[6, false, [12, 14]]], [[6, false, [14]]]];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
mockRPC(route, args) {
if (args.method === "write") {
assert.step("write");
assert.deepEqual(args.args[1].timmy, commands.shift());
}
},
});
assert.containsN(target, "div.o_field_widget div.form-check", 2);
let checkboxes = target.querySelectorAll("div.o_field_widget div.form-check input");
assert.ok(checkboxes[0].checked);
assert.notOk(checkboxes[1].checked);
assert.containsNone(target, "div.o_field_widget div.form-check input:disabled");
// add a m2m value by clicking on input
checkboxes = target.querySelectorAll("div.o_field_widget div.form-check input");
await click(checkboxes[1]);
await clickSave(target);
assert.containsN(target, "div.o_field_widget div.form-check input:checked", 2);
// remove a m2m value by clinking on label
await click(target.querySelector("div.o_field_widget div.form-check > label"));
await clickSave(target);
checkboxes = target.querySelectorAll("div.o_field_widget div.form-check input");
assert.notOk(checkboxes[0].checked);
assert.ok(checkboxes[1].checked);
assert.verifySteps(["write", "write"]);
});
QUnit.test("Many2ManyCheckBoxesField (readonly)", async function (assert) {
serverData.models.partner.records[0].timmy = [12];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" attrs="{'readonly': true}" />
</group>
</form>`,
});
assert.containsN(
target,
"div.o_field_widget div.form-check",
2,
"should have fetched and displayed the 2 values of the many2many"
);
assert.containsN(
target,
"div.o_field_widget div.form-check input:disabled",
2,
"the checkboxes should be disabled"
);
await click(target.querySelectorAll("div.o_field_widget div.form-check > label")[1]);
assert.ok(
target.querySelector("div.o_field_widget div.form-check input").checked,
"first checkbox should be checked"
);
assert.notOk(
target.querySelectorAll("div.o_field_widget div.form-check input")[1].checked,
"second checkbox should not be checked"
);
});
QUnit.test(
"Many2ManyCheckBoxesField: start non empty, then remove twice",
async function (assert) {
serverData.models.partner.records[0].timmy = [12, 14];
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<group>
<field name="timmy" widget="many2many_checkboxes" />
</group>
</form>`,
});
await click(target.querySelectorAll("div.o_field_widget div.form-check input")[0]);
await click(target.querySelectorAll("div.o_field_widget div.form-check input")[1]);
await clickSave(target);
assert.notOk(
target.querySelectorAll("div.o_field_widget div.form-check input")[0].checked,
"first checkbox should not be checked"
);
assert.notOk(
target.querySelectorAll("div.o_field_widget div.form-check input")[1].checked,
"second checkbox should not be checked"
);
}
);
QUnit.test(
"Many2ManyCheckBoxesField: values are updated when domain changes",
async function (assert) {
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="int_field" />
<field name="timmy" widget="many2many_checkboxes" domain="[['id', '>', int_field]]" />
</form>`,
});
assert.strictEqual(
target.querySelector(".o_field_widget[name='int_field'] input").value,
"10"
);
assert.containsN(target, ".o_field_widget[name='timmy'] .form-check", 2);
assert.strictEqual(
target.querySelector(".o_field_widget[name='timmy']").textContent,
"goldsilver"
);
await editInput(target, ".o_field_widget[name='int_field'] input", 13);
assert.containsOnce(target, ".o_field_widget[name='timmy'] .form-check");
assert.strictEqual(
target.querySelector(".o_field_widget[name='timmy']").textContent,
"silver"
);
}
);
QUnit.test("Many2ManyCheckBoxesField with 40+ values", async function (assert) {
// 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.
assert.expect(3);
const records = [];
for (let id = 1; id <= 90; id++) {
records.push({
id,
display_name: `type ${id}`,
});
}
serverData.models.partner_type.records = records;
serverData.models.partner.records[0].timmy = records.map((r) => r.id);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes" />
</form>`,
mockRPC(route, { args, method }) {
if (method === "write") {
const expectedIds = records.map((r) => r.id);
expectedIds.pop();
assert.deepEqual(args[1].timmy, [[6, false, expectedIds]]);
}
},
});
assert.containsN(
target,
".o_field_widget[name='timmy'] input[type='checkbox']:checked",
90
);
// toggle the last value
let checkboxes = target.querySelectorAll(
".o_field_widget[name='timmy'] input[type='checkbox']"
);
await click(checkboxes[checkboxes.length - 1]);
await clickSave(target);
checkboxes = target.querySelectorAll(
".o_field_widget[name='timmy'] input[type='checkbox']"
);
assert.notOk(checkboxes[checkboxes.length - 1].checked);
});
QUnit.test("Many2ManyCheckBoxesField with 100+ values", async function (assert) {
// 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.
assert.expect(7);
const records = [];
for (let id = 1; id < 150; id++) {
records.push({
id,
display_name: `type ${id}`,
});
}
serverData.models.partner_type.records = records;
serverData.models.partner.records[0].timmy = records.map((r) => r.id);
await makeView({
type: "form",
resModel: "partner",
resId: 1,
serverData,
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes" />
</form>`,
async mockRPC(route, { args, method }) {
if (method === "write") {
const expectedIds = records.map((r) => r.id);
expectedIds.shift();
assert.deepEqual(args[1].timmy, [[6, false, expectedIds]]);
assert.step("write");
}
if (method === "name_search") {
assert.step("name_search");
}
},
});
assert.containsN(
target,
".o_field_widget[name='timmy'] input[type='checkbox']",
100,
"should only display 100 checkboxes"
);
assert.ok(
target.querySelector(".o_field_widget[name='timmy'] input[type='checkbox']").checked
);
// toggle the first value
await click(target.querySelector(".o_field_widget[name='timmy'] input[type='checkbox']"));
await clickSave(target);
assert.notOk(
target.querySelector(".o_field_widget[name='timmy'] input[type='checkbox']").checked
);
assert.verifySteps(["name_search", "write"]);
});
QUnit.test("Many2ManyCheckBoxesField in a one2many", async function (assert) {
assert.expect(3);
serverData.models.partner_type.records.push({ id: 15, display_name: "bronze" });
serverData.models.partner.records[0].timmy = [14, 15];
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="p">
<tree><field name="id"/></tree>
<form>
<field name="timmy" widget="many2many_checkboxes"/>
</form>
</field>
</form>`,
mockRPC(route, args) {
if (args.method === "write") {
assert.deepEqual(args.args[1], {
p: [[1, 1, { timmy: [[6, false, [15, 12]]] }]],
});
}
},
resId: 1,
});
await click(target.querySelector(".o_data_cell"));
// edit the timmy field by (un)checking boxes on the widget
const firstCheckbox = target.querySelector(".modal .form-check-input");
await click(firstCheckbox);
assert.ok(firstCheckbox.checked, "the checkbox should be ticked");
const secondCheckbox = target.querySelectorAll(".modal .form-check-input")[1];
await click(secondCheckbox);
assert.notOk(secondCheckbox.checked, "the checkbox should be unticked");
await click(target.querySelector(".modal .o_form_button_save"));
await clickSave(target);
});
QUnit.test("Many2ManyCheckBoxesField with default values", async function (assert) {
assert.expect(7);
serverData.models.partner.fields.timmy.default = [3];
serverData.models.partner.fields.timmy.type = "many2many";
serverData.models.partner_type.records.push({ id: 3, display_name: "bronze" });
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="timmy" widget="many2many_checkboxes"/>
</form>`,
mockRPC: function (route, args) {
if (args.method === "create") {
assert.deepEqual(
args.args[0].timmy,
[[6, false, [12]]],
"correct values should have been sent to create"
);
}
},
});
assert.notOk(
target.querySelectorAll(".o_form_view .form-check input")[0].checked,
"first checkbox should not be checked"
);
assert.notOk(
target.querySelectorAll(".o_form_view .form-check input")[1].checked,
"second checkbox should not be checked"
);
assert.ok(
target.querySelectorAll(".o_form_view .form-check input")[2].checked,
"third checkbox should be checked"
);
await click(target.querySelector(".o_form_view .form-check input:checked"));
await click(target.querySelector(".o_form_view .form-check input"));
await click(target.querySelector(".o_form_view .form-check input"));
await click(target.querySelector(".o_form_view .form-check input"));
assert.ok(
target.querySelectorAll(".o_form_view .form-check input")[0].checked,
"first checkbox should be checked"
);
assert.notOk(
target.querySelectorAll(".o_form_view .form-check input")[1].checked,
"second checkbox should not be checked"
);
assert.notOk(
target.querySelectorAll(".o_form_view .form-check input")[2].checked,
"third checkbox should not be checked"
);
await clickSave(target);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,430 @@
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", () => 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);
// Edit first row
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>
`,
});
// Edit second row
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 of .o_tag) 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 of .o_tag) 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_quick_assign", { visible: false }).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

@ -1,456 +0,0 @@
/** @odoo-module **/
import { click, clickSave, getFixture, selectDropdownItem } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { triggerHotkey } from "../../helpers/utils";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
display_name: { string: "Displayed name", type: "char" },
},
records: [
{ id: 1, display_name: "first record" },
{ id: 2, display_name: "second record" },
{ id: 4, display_name: "aaa" },
],
onchanges: {},
},
turtle: {
fields: {
display_name: { string: "Displayed name", type: "char" },
partner_ids: { string: "Partner", type: "many2many", relation: "partner" },
},
records: [
{ id: 1, display_name: "leonardo", partner_ids: [] },
{ id: 2, display_name: "donatello", partner_ids: [2, 4] },
{ id: 3, display_name: "raphael" },
],
onchanges: {},
},
},
};
setupViewRegistries();
});
QUnit.module("Many2ManyTagsAvatarField");
QUnit.test("widget many2many_tags_avatar", async function (assert) {
await makeView({
type: "form",
resModel: "turtle",
serverData,
arch: `
<form>
<sheet>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</sheet>
</form>`,
resId: 2,
});
assert.containsN(
target,
".o_field_many2many_tags_avatar.o_field_widget .badge",
2,
"should have 2 records"
);
assert.strictEqual(
target.querySelector(".o_field_many2many_tags_avatar.o_field_widget .badge img").dataset
.src,
"/web/image/partner/2/avatar_128",
"should have correct avatar image"
);
});
QUnit.test("widget many2many_tags_avatar in list view", async function (assert) {
const records = [];
for (let id = 5; id <= 15; id++) {
records.push({
id,
display_name: `record ${id}`,
});
}
serverData.models.partner.records = serverData.models.partner.records.concat(records);
serverData.models.turtle.records.push({
id: 4,
display_name: "crime master gogo",
partner_ids: [1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
});
serverData.models.turtle.records[0].partner_ids = [1];
serverData.models.turtle.records[1].partner_ids = [1, 2, 4, 5, 6, 7];
serverData.models.turtle.records[2].partner_ids = [1, 2, 4, 5, 7];
await makeView({
type: "list",
resModel: "turtle",
serverData,
arch: `
<tree editable="bottom">
<field name="partner_ids" widget="many2many_tags_avatar"/>
</tree>`,
});
assert.strictEqual(
target.querySelector(".o_data_row .o_field_many2many_tags_avatar img.o_m2m_avatar")
.dataset.src,
"/web/image/partner/1/avatar_128",
"should have correct avatar image"
);
assert.strictEqual(
target
.querySelector(
".o_data_row .o_many2many_tags_avatar_cell .o_field_many2many_tags_avatar"
)
.textContent.trim(),
"first record",
"should display like many2one avatar if there is only one record"
);
assert.containsN(
target,
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_tag:not(.o_m2m_avatar_empty)",
4,
"should have 4 records"
);
assert.containsN(
target,
".o_data_row:nth-child(3) .o_field_many2many_tags_avatar .o_tag:not(.o_m2m_avatar_empty)",
5,
"should have 5 records"
);
assert.containsOnce(
target,
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_m2m_avatar_empty",
"should have o_m2m_avatar_empty span"
);
assert.strictEqual(
target
.querySelector(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
)
.textContent.trim(),
"+2",
"should have +2 in o_m2m_avatar_empty"
);
assert.strictEqual(
target.querySelector(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar img.o_m2m_avatar"
).dataset.src,
"/web/image/partner/1/avatar_128",
"should have correct avatar image"
);
assert.strictEqual(
target.querySelector(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_tag:nth-child(2) img.o_m2m_avatar"
).dataset.src,
"/web/image/partner/2/avatar_128",
"should have correct avatar image"
);
assert.strictEqual(
target.querySelector(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_tag:nth-child(3) img.o_m2m_avatar"
).dataset.src,
"/web/image/partner/4/avatar_128",
"should have correct avatar image"
);
assert.strictEqual(
target.querySelector(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_tag:nth-child(4) img.o_m2m_avatar"
).dataset.src,
"/web/image/partner/5/avatar_128",
"should have correct avatar image"
);
assert.containsNone(
target,
".o_data_row:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty",
"should have o_m2m_avatar_empty span"
);
assert.containsN(
target,
".o_data_row:nth-child(4) .o_field_many2many_tags_avatar .o_tag:not(.o_m2m_avatar_empty)",
4,
"should have 4 records"
);
assert.containsOnce(
target,
".o_data_row:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty",
"should have o_m2m_avatar_empty span"
);
assert.strictEqual(
target
.querySelector(
".o_data_row:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
)
.textContent.trim(),
"+9",
"should have +9 in o_m2m_avatar_empty"
);
// check data-tooltip attribute (used by the tooltip service)
const tag = target.querySelector(
".o_data_row:nth-child(2) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
);
assert.strictEqual(
tag.dataset["tooltipTemplate"],
"web.TagsList.Tooltip",
"uses the proper tooltip template"
);
const tooltipInfo = JSON.parse(tag.dataset["tooltipInfo"]);
assert.strictEqual(
tooltipInfo.tags.map((tag) => tag.text).join(" "),
"record 6 record 7",
"shows a tooltip on hover"
);
await click(target.querySelector(".o_data_row .o_many2many_tags_avatar_cell"));
assert.containsN(
target,
".o_data_row.o_selected_row .o_many2many_tags_avatar_cell .badge",
1,
"should have 1 many2many badges in edit mode"
);
await selectDropdownItem(target, "partner_ids", "second record");
await click(target.querySelector(".o_list_button_save"));
assert.containsN(
target,
".o_data_row:first-child .o_field_many2many_tags_avatar .o_tag",
2,
"should have 2 records"
);
// Select the first row and enter edit mode on the x2many field.
await click(target, ".o_data_row:nth-child(1) .o_list_record_selector input");
await click(target, ".o_data_row:nth-child(1) .o_data_cell");
// Only the first row should have tags with delete buttons.
assert.containsN(target, ".o_data_row:nth-child(1) .o_field_tags span .o_delete", 2);
assert.containsNone(target, ".o_data_row:nth-child(2) .o_field_tags span .o_delete");
assert.containsNone(target, ".o_data_row:nth-child(3) .o_field_tags span .o_delete");
assert.containsNone(target, ".o_data_row:nth-child(4) .o_field_tags span .o_delete");
});
QUnit.test(
"widget many2many_tags_avatar list view - don't crash on keyboard navigation",
async function (assert) {
await makeView({
type: "list",
resModel: "turtle",
serverData,
arch: /*xml*/ `
<tree editable="bottom">
<field name="partner_ids" widget="many2many_tags_avatar"/>
</tree>
`,
});
// Select the 2nd row and enter edit mode on the x2many field.
await click(target, ".o_data_row:nth-child(2) .o_list_record_selector input");
await click(target, ".o_data_row:nth-child(2) .o_data_cell");
// Pressing left arrow should focus on the right-most (second) tag.
await triggerHotkey("arrowleft");
assert.strictEqual(
target.querySelector(".o_data_row:nth-child(2) .o_field_tags span:nth-child(2)"),
document.activeElement
);
// Pressing left arrow again should not crash and should focus on the first tag.
await triggerHotkey("arrowleft");
assert.strictEqual(
target.querySelector(".o_data_row:nth-child(2) .o_field_tags span:nth-child(1)"),
document.activeElement
);
}
);
QUnit.test("widget many2many_tags_avatar in kanban view", async function (assert) {
assert.expect(13);
const records = [];
for (let id = 5; id <= 15; id++) {
records.push({
id,
display_name: `record ${id}`,
});
}
serverData.models.partner.records = serverData.models.partner.records.concat(records);
serverData.models.turtle.records.push({
id: 4,
display_name: "crime master gogo",
partner_ids: [1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14],
});
serverData.models.turtle.records[0].partner_ids = [1];
serverData.models.turtle.records[1].partner_ids = [1, 2, 4];
serverData.models.turtle.records[2].partner_ids = [1, 2, 4, 5];
serverData.views = {
"turtle,false,form": '<form><field name="display_name"/></form>',
};
await makeView({
type: "kanban",
resModel: "turtle",
serverData,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="display_name"/>
<div class="oe_kanban_footer">
<div class="o_kanban_record_bottom">
<div class="oe_kanban_bottom_right">
<field name="partner_ids" widget="many2many_tags_avatar"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>`,
selectRecord(recordId) {
assert.strictEqual(
recordId,
1,
"should call its selectRecord prop with the clicked record"
);
},
});
assert.strictEqual(
target.querySelector(
".o_kanban_record:first-child .o_field_many2many_tags_avatar img.o_m2m_avatar"
).dataset.src,
"/web/image/partner/1/avatar_128",
"should have correct avatar image"
);
assert.containsN(
target,
".o_kanban_record:nth-child(2) .o_field_many2many_tags_avatar .o_tag",
3,
"should have 3 records"
);
assert.containsN(
target,
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_tag",
2,
"should have 2 records"
);
assert.strictEqual(
target.querySelector(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar img.o_m2m_avatar"
).dataset.src,
"/web/image/partner/1/avatar_128",
"should have correct avatar image"
);
assert.strictEqual(
target.querySelectorAll(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar img.o_m2m_avatar"
)[1].dataset.src,
"/web/image/partner/2/avatar_128",
"should have correct avatar image"
);
assert.containsOnce(
target,
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty",
"should have o_m2m_avatar_empty span"
);
assert.strictEqual(
target
.querySelector(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
)
.textContent.trim(),
"+2",
"should have +2 in o_m2m_avatar_empty"
);
assert.containsN(
target,
".o_kanban_record:nth-child(4) .o_field_many2many_tags_avatar .o_tag",
2,
"should have 2 records"
);
assert.containsOnce(
target,
".o_kanban_record:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty",
"should have o_m2m_avatar_empty span"
);
assert.strictEqual(
target
.querySelector(
".o_kanban_record:nth-child(4) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
)
.textContent.trim(),
"9+",
"should have 9+ in o_m2m_avatar_empty"
);
// check data-tooltip attribute (used by the tooltip service)
const tag = target.querySelector(
".o_kanban_record:nth-child(3) .o_field_many2many_tags_avatar .o_m2m_avatar_empty"
);
assert.strictEqual(
tag.dataset["tooltipTemplate"],
"web.TagsList.Tooltip",
"uses the proper tooltip template"
);
const tooltipInfo = JSON.parse(tag.dataset["tooltipInfo"]);
assert.strictEqual(
tooltipInfo.tags.map((tag) => tag.text).join(" "),
"aaa record 5",
"shows a tooltip on hover"
);
await click(
target.querySelector(".o_kanban_record .o_field_many2many_tags_avatar img.o_m2m_avatar")
);
});
QUnit.test("widget many2many_tags_avatar delete tag", async function (assert) {
await makeView({
type: "form",
resModel: "turtle",
resId: 2,
serverData,
arch: `
<form>
<sheet>
<field name="partner_ids" widget="many2many_tags_avatar"/>
</sheet>
</form>`,
});
assert.containsN(
target,
".o_field_many2many_tags_avatar.o_field_widget .badge",
2,
"should have 2 records"
);
await click(
target.querySelector(".o_field_many2many_tags_avatar.o_field_widget .badge .o_delete")
);
assert.containsOnce(
target,
".o_field_many2many_tags_avatar.o_field_widget .badge",
"should have 1 record"
);
await clickSave(target);
assert.containsOnce(
target,
".o_field_many2many_tags_avatar.o_field_widget .badge",
"should have 1 record"
);
});
});

View file

@ -0,0 +1,433 @@
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,
stepAllNetworkCalls,
} 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(2);
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 .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("readonly many2one_avatar in form view with no_open set to true", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `<form><field name="user_id" widget="many2one_avatar" readonly="1" options="{'no_open': 1}"/></form>`,
});
expect("[name='user_id'] a").toHaveCount(0);
});
test("readonly many2one_avatar in list view with no_open set to false", async () => {
await mountView({
type: "list",
resModel: "partner",
arch: `<list><field name="user_id" widget="many2one_avatar" options="{'no_open': 0}"/></list>`,
});
expect("[name='user_id'] a").toHaveCount(3);
});
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 () => {
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>`,
});
stepAllNetworkCalls();
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();
expect.verifySteps(["web_name_search"]);
// select first input
await contains(".o-overlay-container .o-autocomplete--dropdown-item").click();
expect.verifySteps(["web_save"]);
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

@ -1,342 +0,0 @@
/** @odoo-module **/
import {
click,
clickSave,
editInput,
getFixture,
getNodesTextContent,
patchWithCleanup,
selectDropdownItem,
triggerEvent,
clickDiscard,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
int_field: { string: "int_field", type: "integer" },
user_id: { string: "User", type: "many2one", relation: "user" },
},
records: [
{ id: 1, user_id: 17 },
{ id: 2, user_id: 19 },
{ id: 3, user_id: 17 },
{ id: 4, user_id: false },
],
},
user: {
fields: {
name: { string: "Name", type: "char" },
partner_ids: {
type: "one2many",
relation: "partner",
relation_field: "user_id",
},
},
records: [
{
id: 17,
name: "Aline",
},
{
id: 19,
name: "Christine",
},
],
},
},
};
setupViewRegistries();
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
});
});
QUnit.module("Many2OneAvatar");
QUnit.test("basic form view flow", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="user_id" widget="many2one_avatar"/>
</form>`,
});
assert.strictEqual(
target.querySelector(".o_field_widget[name=user_id] input").value,
"Aline"
);
assert.containsOnce(
target,
'.o_m2o_avatar > img[data-src="/web/image/user/17/avatar_128"]'
);
assert.containsOnce(target, '.o_field_many2one_avatar > div[data-tooltip="Aline"]');
assert.containsOnce(target, ".o_input_dropdown");
assert.strictEqual(target.querySelector(".o_input_dropdown input").value, "Aline");
assert.containsOnce(target, ".o_external_button");
assert.containsOnce(
target,
'.o_m2o_avatar > img[data-src="/web/image/user/17/avatar_128"]'
);
await selectDropdownItem(target, "user_id", "Christine");
assert.containsOnce(
target,
'.o_m2o_avatar > img[data-src="/web/image/user/19/avatar_128"]'
);
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget[name=user_id] input").value,
"Christine"
);
assert.containsOnce(
target,
'.o_m2o_avatar > img[data-src="/web/image/user/19/avatar_128"]'
);
await editInput(target, '.o_field_widget[name="user_id"] input', "");
assert.containsNone(target, ".o_m2o_avatar > img");
assert.containsOnce(target, ".o_m2o_avatar > .o_m2o_avatar_empty");
await clickSave(target);
assert.containsNone(target, ".o_m2o_avatar > img");
assert.containsOnce(target, ".o_m2o_avatar > .o_m2o_avatar_empty");
});
QUnit.test("onchange in form view flow", async function (assert) {
serverData.models.partner.onchanges = {
int_field: function (obj) {
if (obj.int_field === 1) {
obj.user_id = [19, "Christine"];
} else if (obj.int_field === 2) {
obj.user_id = false;
} else {
obj.user_id = [17, "Aline"]; // default value
}
},
};
await makeView({
type: "form",
serverData,
resModel: "partner",
arch: `
<form>
<field name="int_field"/>
<field name="user_id" widget="many2one_avatar" readonly="1"/>
</form>`,
});
assert.strictEqual(
target.querySelector(".o_field_widget[name=user_id]").textContent.trim(),
"Aline"
);
assert.containsOnce(
target,
'.o_m2o_avatar > img[data-src="/web/image/user/17/avatar_128"]'
);
await editInput(target, "div[name=int_field] input", 1);
assert.strictEqual(
target.querySelector(".o_field_widget[name=user_id]").textContent.trim(),
"Christine"
);
assert.containsOnce(
target,
'.o_m2o_avatar > img[data-src="/web/image/user/19/avatar_128"]'
);
await editInput(target, "div[name=int_field] input", 2);
assert.strictEqual(
target.querySelector(".o_field_widget[name=user_id]").textContent.trim(),
""
);
assert.containsNone(target, ".o_m2o_avatar > img");
});
QUnit.test("basic list view flow", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: '<tree><field name="user_id" widget="many2one_avatar"/></tree>',
});
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_data_cell[name='user_id'] span span")),
["Aline", "Christine", "Aline", ""]
);
const imgs = target.querySelectorAll(".o_m2o_avatar > img");
assert.strictEqual(imgs[0].dataset.src, "/web/image/user/17/avatar_128");
assert.strictEqual(imgs[1].dataset.src, "/web/image/user/19/avatar_128");
assert.strictEqual(imgs[2].dataset.src, "/web/image/user/17/avatar_128");
});
QUnit.test("basic flow in editable list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
arch: '<tree editable="top"><field name="user_id" widget="many2one_avatar"/></tree>',
});
assert.deepEqual(
getNodesTextContent(target.querySelectorAll(".o_data_cell[name='user_id'] span span")),
["Aline", "Christine", "Aline", ""]
);
const imgs = target.querySelectorAll(".o_m2o_avatar > img");
assert.strictEqual(imgs[0].dataset.src, "/web/image/user/17/avatar_128");
assert.strictEqual(imgs[1].dataset.src, "/web/image/user/19/avatar_128");
assert.strictEqual(imgs[2].dataset.src, "/web/image/user/17/avatar_128");
await click(target.querySelectorAll(".o_data_row .o_data_cell")[0]);
assert.strictEqual(
target.querySelector(".o_m2o_avatar > img").dataset.src,
"/web/image/user/17/avatar_128"
);
});
QUnit.test("Many2OneAvatar with placeholder", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch:
'<form><field name="user_id" widget="many2one_avatar" placeholder="Placeholder"/></form>',
});
assert.strictEqual(
target.querySelector(".o_field_widget[name='user_id'] input").placeholder,
"Placeholder"
);
});
QUnit.test("click on many2one_avatar in a list view (multi_edit='1')", async function (assert) {
const listView = registry.category("views").get("list");
patchWithCleanup(listView.Controller.prototype, {
openRecord() {
assert.step("openRecord");
},
});
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree multi_edit="1">
<field name="user_id" widget="many2one_avatar"/>
</tree>`,
});
await click(target.querySelectorAll(".o_data_row")[0], ".o_list_record_selector input");
await click(target.querySelector(".o_data_row .o_data_cell [name='user_id'] span span"));
assert.hasClass(target.querySelector(".o_data_row"), "o_selected_row");
assert.verifySteps([]);
});
QUnit.test("click on many2one_avatar in an editable list view", async function (assert) {
const listView = registry.category("views").get("list");
patchWithCleanup(listView.Controller.prototype, {
openRecord() {
assert.step("openRecord");
},
});
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="top">
<field name="user_id" widget="many2one_avatar"/>
</tree>`,
});
await click(target.querySelectorAll(".o_data_row")[0], ".o_list_record_selector input");
await click(target.querySelector(".o_data_row .o_data_cell [name='user_id'] span span"));
assert.hasClass(target.querySelector(".o_data_row"), "o_selected_row");
assert.verifySteps([]);
});
QUnit.test("click on many2one_avatar in an editable list view", async function (assert) {
const listView = registry.category("views").get("list");
patchWithCleanup(listView.Controller.prototype, {
openRecord() {
assert.step("openRecord");
},
});
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree>
<field name="user_id" widget="many2one_avatar"/>
</tree>`,
});
await click(target.querySelector(".o_data_row .o_data_cell [name='user_id'] span span"));
assert.containsNone(target, ".o_selected_row");
assert.verifySteps(["openRecord"]);
});
QUnit.test("cancelling create dialog should clear value in the field", async function (assert) {
serverData.views = {
"user,false,form": `
<form>
<field name="name" />
</form>`,
};
await makeView({
type: "list",
resModel: "partner",
serverData,
arch: `
<tree editable="top">
<field name="user_id" widget="many2one_avatar"/>
</tree>`,
});
await click(target.querySelectorAll(".o_data_cell")[0]);
const input = target.querySelector(".o_field_widget[name=user_id] input");
input.value = "yy";
await triggerEvent(input, null, "input");
await click(target, ".o_field_widget[name=user_id] input");
await selectDropdownItem(target, "user_id", "Create and edit...");
await clickDiscard(target.querySelector(".modal"));
assert.strictEqual(target.querySelector(".o_field_widget[name=user_id] input").value, "");
assert.containsOnce(target, ".o_field_widget[name=user_id] span.o_m2o_avatar_empty");
});
});

View file

@ -0,0 +1,249 @@
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", "operator", "limit");
for (const record of this) {
if (record.barcode === kwargs.name) {
result.push([record.id, record.name]);
}
}
return result;
}
_views = {
kanban: `
<kanban>
<templates>
<t t-name="card">
<field name="id"/>
<field name="name"/>
<field name="barcode"/>
</t>
</templates>
</kanban>`,
search: "<search/>",
};
}
class SaleOrderLine extends models.Model {
id = fields.Integer();
product_id = fields.Many2one({
relation: "product.product",
});
}
class User extends models.Model {
_name = "res.users";
has_group() {
return true;
}
}
defineModels([Product, SaleOrderLine, User]);
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 on desktop", 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"]);
});
test.tags("mobile");
test("barcode button with multiple results on mobile", 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, { message: "has scanner barcode button" });
await contains(".o_barcode").click();
expect(".modal-dialog.modal-lg").toHaveCount(1, {
message: "there should be one modal opened in full screen",
});
expect(".modal-dialog.modal-lg .o_kanban_record:not(.o_kanban_ghost)").toHaveCount(2, {
message: "there should be 2 records displayed",
});
await contains(".o_kanban_record:nth-child(1)").click();
await clickSave();
expect.verifySteps(["vibrate:100"]);
});
test.tags("mobile");
test("many2one with barcode show all records", async () => {
// The product selected (mock) for the barcode scanner
const selectedRecordTest = Product._records[1];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => selectedRecordTest.barcode,
});
await mountView({
type: "form",
resModel: "sale.order.line",
arch: `<form><field name="product_id" options="{'can_scan_barcode': True}"/></form>`,
});
// Select one product
await contains(".o_barcode").click();
// Click on the input to show all records
await contains(".o_input_dropdown > input").click();
expect(".modal-dialog.modal-lg").toHaveCount(1, {
message: "there should be one modal opened in full screen",
});
expect(".modal-dialog.modal-lg .o_kanban_record:not(.o_kanban_ghost)").toHaveCount(3, {
message: "there should be 3 records displayed",
});
expect.verifySteps(["vibrate:100"]);
});

View file

@ -1,210 +0,0 @@
/** @odoo-module **/
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { browser } from "@web/core/browser/browser";
import { click, clickSave, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import * as BarcodeScanner from "@web/webclient/barcode/barcode_scanner";
let serverData;
let target;
const CREATE = "create";
const NAME_SEARCH = "name_search";
const PRODUCT_PRODUCT = "product.product";
const SALE_ORDER_LINE = "sale_order_line";
const PRODUCT_FIELD_NAME = "product_id";
// MockRPC to allow the search in barcode too
async function barcodeMockRPC(route, args, performRPC) {
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
const result = await performRPC(route, args);
const records = serverData.models[PRODUCT_PRODUCT].records
.filter((record) => record.barcode === args.kwargs.name)
.map((record) => [record.id, record.name]);
return records.concat(result);
}
}
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
[PRODUCT_PRODUCT]: {
fields: {
id: { type: "integer" },
name: {},
barcode: {},
},
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",
},
],
},
[SALE_ORDER_LINE]: {
fields: {
id: { type: "integer" },
[PRODUCT_FIELD_NAME]: {
string: PRODUCT_FIELD_NAME,
type: "many2one",
relation: PRODUCT_PRODUCT,
},
},
},
},
};
setupViewRegistries();
patchWithCleanup(AutoComplete, {
delay: 0,
});
// simulate a environment with a camera/webcam
patchWithCleanup(
browser,
Object.assign({}, browser, {
setTimeout: (fn) => fn(),
navigator: {
userAgent: "Chrome/0.0.0 (Linux; Android 13; Odoo TestSuite)",
mediaDevices: {
getUserMedia: () => [],
},
},
})
);
});
QUnit.module("Many2OneField Barcode (Desktop)");
QUnit.test(
"Many2OneBarcode component should display the barcode icon",
async function (assert) {
assert.expect(1);
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" widget="many2one_barcode"/>
</form>
`,
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
}
);
QUnit.test("barcode button with single results", async function (assert) {
assert.expect(2);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[0];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => selectedRecordTest.barcode,
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>
`,
async mockRPC(route, args, performRPC) {
if (args.method === CREATE && args.model === SALE_ORDER_LINE) {
const selectedId = args.args[0][PRODUCT_FIELD_NAME];
assert.equal(
selectedId,
selectedRecordTest.id,
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
);
return performRPC(route, args, performRPC);
}
return barcodeMockRPC(route, args, performRPC);
},
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
await click(target, ".o_barcode");
await clickSave(target);
});
QUnit.test("barcode button with multiple results", async function (assert) {
assert.expect(4);
// The product selected (mock) for the barcode scanner
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[1];
patchWithCleanup(BarcodeScanner, {
scanBarcode: async () => "mask",
});
await makeView({
type: "form",
resModel: SALE_ORDER_LINE,
serverData,
arch: `
<form>
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
</form>`,
async mockRPC(route, args, performRPC) {
if (args.method === CREATE && args.model === SALE_ORDER_LINE) {
const selectedId = args.args[0][PRODUCT_FIELD_NAME];
assert.equal(
selectedId,
selectedRecordTest.id,
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
);
return performRPC(route, args, performRPC);
}
return barcodeMockRPC(route, args, performRPC);
},
});
const scanButton = target.querySelector(".o_barcode");
assert.containsOnce(target, scanButton, "has scanner barcode button");
await click(target, ".o_barcode");
const autocompleteDropdown = target.querySelector(".o-autocomplete--dropdown-menu");
assert.containsOnce(
target,
autocompleteDropdown,
"there should be one autocomplete dropdown opened"
);
assert.containsN(
autocompleteDropdown,
".o-autocomplete--dropdown-item.ui-menu-item:not(.o_m2o_dropdown_option)",
2,
"there should be 2 records displayed"
);
await click(autocompleteDropdown, ".o-autocomplete--dropdown-item:nth-child(1)");
await clickSave(target);
});
});

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
"web_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", "web_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

@ -1,69 +0,0 @@
/** @odoo-module **/
import { getFixture, getNodesTextContent } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
res_id: {
string: "Ressource Id",
type: "many2one_reference",
},
},
records: [
{ id: 1, res_id: 10 },
{ id: 2, res_id: false },
],
},
},
};
setupViewRegistries();
});
QUnit.module("Many2OneReferenceField");
QUnit.test("Many2OneReferenceField in form view", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 1,
arch: '<form><field name="res_id"/></form>',
});
assert.strictEqual(target.querySelector(".o_field_widget input").value, "10");
});
QUnit.test("Many2OneReferenceField in list view", async function (assert) {
await makeView({
type: "list",
serverData,
resModel: "partner",
resId: 1,
arch: '<list><field name="res_id"/></list>',
});
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell")), ["10", ""]);
});
QUnit.test("should be 0 when unset", async function (assert) {
await makeView({
type: "form",
serverData,
resModel: "partner",
resId: 2,
arch: '<form><field name="res_id"/></form>',
});
assert.strictEqual(target.querySelector(".o_field_widget input").value, "");
});
});

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"],
],
});
inverse_rate = fields.Float();
_records = [
{ id: 1, name: "USD", symbol: "$", position: "before", inverse_rate: 1 },
{ id: 2, name: "EUR", symbol: "€", position: "after", inverse_rate: 0.5 },
{
id: 3,
name: "VEF",
symbol: "Bs.F",
position: "after",
inverse_rate: 0.3,
},
];
}
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("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");
});
test("with 'hide_trailing_zeros' option", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 5,
arch: `
<form>
<field name="float_field" widget="monetary" options="{'hide_trailing_zeros': true}"/>
<field name="currency_id" invisible="1"/>
</form>`,
});
expect(".o_field_widget input").toHaveValue("9.1");
expect(".o_field_widget .o_input span:eq(0)").toHaveText("$");
});

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);
});

View file

@ -1,361 +0,0 @@
/** @odoo-module **/
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { registry } from "@web/core/registry";
import { getFixture, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { localization } from "@web/core/l10n/localization";
import { useNumpadDecimal } from "@web/views/fields/numpad_decimal_hook";
import { makeTestEnv } from "../../helpers/mock_env";
const { Component, mount, useState, xml } = owl;
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
int_field: {
string: "int_field",
type: "integer",
sortable: true,
searchable: true,
},
qux: { string: "Qux", type: "float", digits: [16, 1], searchable: true },
currency_id: {
string: "Currency",
type: "many2one",
relation: "currency",
searchable: true,
},
float_factor_field: {
string: "Float Factor",
type: "float_factor",
},
percentage: {
string: "Percentage",
type: "percentage",
},
monetary: { string: "Monetary", type: "monetary" },
progressbar: {
type: "integer",
},
progressmax: {
type: "float",
},
},
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,
progressmax: 5.41,
},
],
},
currency: {
fields: {
digits: { string: "Digits" },
symbol: { string: "Currency Sumbol", type: "char", searchable: true },
position: { string: "Currency Position", type: "char", searchable: true },
},
records: [
{
id: 1,
display_name: "$",
symbol: "$",
position: "before",
},
],
},
},
};
setupViewRegistries();
patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." });
});
QUnit.module("Numeric fields");
QUnit.test(
"Numeric fields: fields with keydown on numpad decimal key",
async function (assert) {
registry.category("services").remove("localization");
registry
.category("services")
.add("localization", makeFakeLocalizationService({ decimalPoint: "🇧🇪" }));
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_factor_field" options="{'factor': 0.5}"/>
<field name="qux"/>
<field name="int_field"/>
<field name="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="percentage"/>
<field name="progressbar" widget="progressbar" options="{'editable': true, 'max_value': 'qux', 'edit_max_value': true}"/>
</form>`,
resId: 1,
});
// Get all inputs
const floatFactorField = target.querySelector(".o_field_float_factor input");
const floatInput = target.querySelector(".o_field_float input");
const integerInput = target.querySelector(".o_field_integer input");
const monetaryInput = target.querySelector(".o_field_monetary input");
const percentageInput = target.querySelector(".o_field_percentage input");
const progressbarInput = target.querySelector(".o_field_progressbar input");
// Dispatch numpad "dot" and numpad "comma" keydown events to all inputs and check
// Numpad "comma" is specific to some countries (Brazil...)
floatFactorField.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "." })
);
floatFactorField.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "," })
);
await nextTick();
assert.strictEqual(floatFactorField.value, "5🇧🇪00🇧🇪🇧🇪");
floatInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "." })
);
floatInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "," })
);
await nextTick();
assert.strictEqual(floatInput.value, "0🇧🇪4🇧🇪🇧🇪");
integerInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "." })
);
integerInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "," })
);
await nextTick();
assert.strictEqual(integerInput.value, "10🇧🇪🇧🇪");
monetaryInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "." })
);
monetaryInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "," })
);
await nextTick();
assert.strictEqual(monetaryInput.value, "9🇧🇪99🇧🇪🇧🇪");
percentageInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "." })
);
percentageInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "," })
);
await nextTick();
assert.strictEqual(percentageInput.value, "99🇧🇪🇧🇪");
progressbarInput.focus();
await nextTick();
// When the input is focused, we get the length of the input value to be
// able to set the cursor position at the end of the value.
const length = progressbarInput.value.length;
// Make sure that the cursor position is at the end of the value.
progressbarInput.setSelectionRange(length, length);
progressbarInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "." })
);
progressbarInput.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadDecimal", key: "," })
);
await nextTick();
assert.strictEqual(progressbarInput.value, "0🇧🇪44🇧🇪🇧🇪");
}
);
QUnit.test(
"Numeric fields: NumpadDecimal key is different from the decimalPoint",
async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: /*xml*/ `
<form>
<field name="float_factor_field" options="{'factor': 0.5}"/>
<field name="qux"/>
<field name="int_field"/>
<field name="monetary"/>
<field name="currency_id" invisible="1"/>
<field name="percentage"/>
<field name="progressbar" widget="progressbar" options="{'editable': true, 'max_value': 'qux', 'edit_max_value': true}"/>
</form>`,
resId: 1,
});
// Get all inputs
const floatFactorField = target.querySelector(".o_field_float_factor input");
const floatInput = target.querySelector(".o_field_float input");
const integerInput = target.querySelector(".o_field_integer input");
const monetaryInput = target.querySelector(".o_field_monetary input");
const percentageInput = target.querySelector(".o_field_percentage input");
const progressbarInput = target.querySelector(".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;
el.focus();
await nextTick();
el.setSelectionRange(...selectionRange);
const numpadDecimalEvent = new KeyboardEvent("keydown", {
code: "NumpadDecimal",
key: ".",
});
numpadDecimalEvent.preventDefault = () => assert.step("preventDefault");
el.dispatchEvent(numpadDecimalEvent);
await nextTick();
// dispatch an extra keydown event and assert that it's not default prevented
const extraEvent = new KeyboardEvent("keydown", { code: "Digit1", key: "1" });
extraEvent.preventDefault = () => {
throw new Error("should not be default prevented");
};
el.dispatchEvent(extraEvent);
await nextTick();
// Selection range should be at 1 + the specified selection start.
assert.strictEqual(el.selectionStart, selectionRange[0] + 1);
assert.strictEqual(el.selectionEnd, selectionRange[0] + 1);
await nextTick();
assert.verifySteps(
["preventDefault"],
"NumpadDecimal event should be default prevented"
);
assert.strictEqual(el.value, expectedValue, msg);
}
await testInputElementOnNumpadDecimal({
el: floatFactorField,
selectionRange: [1, 3],
expectedValue: "5,0",
msg: "Float factor field from 5,00 to 5,0",
});
await testInputElementOnNumpadDecimal({
el: floatInput,
selectionRange: [0, 2],
expectedValue: ",4",
msg: "Float field from 0,4 to ,4",
});
await testInputElementOnNumpadDecimal({
el: integerInput,
selectionRange: [1, 2],
expectedValue: "1,",
msg: "Integer field from 10 to 1,",
});
await testInputElementOnNumpadDecimal({
el: monetaryInput,
selectionRange: [0, 3],
expectedValue: ",9",
msg: "Monetary field from 9,99 to ,9",
});
await testInputElementOnNumpadDecimal({
el: percentageInput,
selectionRange: [1, 1],
expectedValue: "9,9",
msg: "Percentage field from 99 to 9,9",
});
await testInputElementOnNumpadDecimal({
el: progressbarInput,
selectionRange: [1, 3],
expectedValue: "0,4",
msg: "Progressbar field 2 from 0,44 to 0,4",
});
}
);
QUnit.test(
"useNumpadDecimal should synchronize handlers on input elements",
async function (assert) {
/**
* 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) {
inputEl.focus();
const numpadDecimalEvent = new KeyboardEvent("keydown", {
code: "NumpadDecimal",
key: ".",
});
numpadDecimalEvent.preventDefault = () => assert.step("preventDefault");
inputEl.dispatchEvent(numpadDecimalEvent);
await nextTick();
// dispatch an extra keydown event and assert that it's not default prevented
const extraEvent = new KeyboardEvent("keydown", { code: "Digit1", key: "1" });
extraEvent.preventDefault = () => {
throw new Error("should not be default prevented");
};
inputEl.dispatchEvent(extraEvent);
await nextTick();
assert.verifySteps(["preventDefault"]);
}
}
class MyComponent extends Component {
setup() {
useNumpadDecimal();
this.state = useState({ showOtherInput: false });
}
}
MyComponent.template = xml`
<main t-ref="numpadDecimal">
<input type="text" placeholder="input 1" />
<input t-if="state.showOtherInput" type="text" placeholder="input 2" />
</main>
`;
const comp = await mount(MyComponent, target, { env: await makeTestEnv() });
// Initially, only one input should be rendered.
assert.containsOnce(target, "main > input");
await testInputElements(target.querySelectorAll("main > input"));
// We show the second input by manually updating the state.
comp.state.showOtherInput = true;
await nextTick();
// The second input should also be able to handle numpad decimal.
assert.containsN(target, "main > input", 2);
await testInputElements(target.querySelectorAll("main > input"));
}
);
});

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

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

View file

@ -0,0 +1,109 @@
import {
clickSave,
defineModels,
fields,
mockService,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { test, expect } from "@odoo/hoot";
import { click, setInputFiles, queryOne, waitFor } from "@odoo/hoot-dom";
import { browser } from "@web/core/browser/browser";
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");
});
test("PdfViewerField: upload file and download it", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
});
mockService("action", {
doAction({ type }) {
expect.step(type);
return super.doAction(...arguments);
},
});
patchWithCleanup(browser, {
open: (_url, type) => {
expect.step(`browser_open:${type}`);
},
});
expect("iframe.o_pdfview_iframe").toHaveCount(1);
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");
await clickSave();
await click(".fa-download");
expect.verifySteps(["ir.actions.act_url", "browser_open:_blank"]);
});

View file

@ -1,104 +0,0 @@
/** @odoo-module **/
import { clickSave, editInput, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
const getIframe = () => target.querySelector(".o_field_widget iframe.o_pdfview_iframe");
const getIframeProtocol = () => getIframe().dataset.src.match(/\?file=(\w+)%3A/)[1];
const getIframeViewerParams = () =>
decodeURIComponent(getIframe().dataset.src.match(/%2Fweb%2Fcontent%3F(.*)#page/)[1]);
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
document: { string: "Binary", type: "binary" },
},
records: [
{
document: "coucou==\n",
},
],
},
},
};
setupViewRegistries();
});
QUnit.module("PdfViewerField");
QUnit.test("PdfViewerField without data", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
});
assert.hasClass(target.querySelector(".o_field_widget"), "o_field_pdf_viewer");
assert.containsOnce(
target,
".o_select_file_button:not(.o_hidden)",
"there should be a visible 'Upload' button"
);
assert.containsNone(target, ".o_pdfview_iframe", "there should be no iframe");
assert.containsOnce(target, 'input[type="file"]', "there should be one input");
});
QUnit.test("PdfViewerField: basic rendering", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
resId: 1,
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
});
assert.hasClass(target.querySelector(".o_field_widget"), "o_field_pdf_viewer");
assert.containsOnce(target, ".o_select_file_button", "there should be an 'Upload' button");
assert.containsOnce(
target,
".o_field_widget iframe.o_pdfview_iframe",
"there should be an iframe"
);
assert.strictEqual(getIframeProtocol(), "http");
assert.strictEqual(getIframeViewerParams(), "model=partner&field=document&id=1");
});
QUnit.test("PdfViewerField: upload rendering", async function (assert) {
assert.expect(5);
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: '<form><field name="document" widget="pdf_viewer"/></form>',
async mockRPC(_route, { method, args }) {
if (method === "create") {
assert.deepEqual(args[0], { document: btoa("test") });
}
},
});
assert.containsNone(target, ".o_pdfview_iframe", "there is no PDF Viewer");
const file = new File(["test"], "test.pdf", { type: "application/pdf" });
await editInput(target, ".o_field_pdf_viewer input[type=file]", file);
assert.containsOnce(target, ".o_pdfview_iframe", "there is a PDF Viewer");
assert.strictEqual(getIframeProtocol(), "blob");
await clickSave(target);
assert.strictEqual(getIframeProtocol(), "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

@ -1,302 +0,0 @@
/** @odoo-module **/
import { getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
foo: {
string: "Foo",
type: "char",
default: "My little Foo Value",
searchable: true,
trim: true,
},
int_field: {
string: "int_field",
type: "integer",
sortable: true,
searchable: true,
},
float_field: {
string: "Float_field",
type: "float",
digits: [0, 1],
},
},
records: [
{ id: 1, foo: "yop", int_field: 10 },
{ id: 2, foo: "gnap", int_field: 80 },
{ id: 3, foo: "dop", float_field: 65.6},
],
onchanges: {},
},
},
};
setupViewRegistries();
});
QUnit.module("PercentPieField");
QUnit.test("PercentPieField in form view with value < 50%", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="int_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 1,
});
assert.containsOnce(
target,
".o_field_percent_pie.o_field_widget .o_pie",
"should have a pie chart"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"10%",
"should have 10% as pie value since int_field=10"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_mask").style
.transform,
"rotate(180deg)",
"left mask should be covering the whole left side of the pie"
);
assert.strictEqual(
target.querySelectorAll(".o_field_percent_pie.o_field_widget .o_pie .o_mask")[1].style
.transform,
"rotate(36deg)",
"right mask should be rotated from 360*(10/100) = 36 degrees"
);
assert.containsOnce(
target,
".o_field_percent_pie.o_field_widget .o_pie",
"should have a pie chart"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"10%",
"should have 10% as pie value since int_field=10"
);
assert.ok(
_.str.include(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_mask").style
.transform,
"rotate(180deg)"
),
"left mask should be covering the whole left side of the pie"
);
assert.ok(
_.str.include(
target.querySelectorAll(".o_field_percent_pie.o_field_widget .o_pie .o_mask")[1]
.style.transform,
"rotate(36deg)"
),
"right mask should be rotated from 360*(10/100) = 36 degrees"
);
assert.containsOnce(
target,
".o_field_percent_pie.o_field_widget .o_pie",
"should have a pie chart"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"10%",
"should have 10% as pie value since int_field=10"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_mask").style
.transform,
"rotate(180deg)",
"left mask should be covering the whole left side of the pie"
);
assert.strictEqual(
target.querySelectorAll(".o_field_percent_pie.o_field_widget .o_pie .o_mask")[1].style
.transform,
"rotate(36deg)",
"right mask should be rotated from 360*(10/100) = 36 degrees"
);
});
QUnit.test("PercentPieField in form view with value > 50%", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="int_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 2,
});
assert.containsOnce(
target,
".o_field_percent_pie.o_field_widget .o_pie",
"should have a pie chart"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"80%",
"should have 80% as pie value since int_field=80"
);
assert.ok(
_.str.include(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_mask").style
.transform,
"rotate(288deg)"
),
"left mask should be rotated from 360*(80/100) = 288 degrees"
);
assert.hasClass(
target.querySelectorAll(".o_field_percent_pie.o_field_widget .o_pie .o_mask")[1],
"o_full",
"right mask should be hidden since the value > 50%"
);
assert.containsOnce(
target,
".o_field_percent_pie.o_field_widget .o_pie",
"should have a pie chart"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"80%",
"should have 80% as pie value since int_field=80"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_mask").style
.transform,
"rotate(288deg)",
"left mask should be rotated from 360*(80/100) = 288 degrees"
);
assert.hasClass(
target.querySelectorAll(".o_field_percent_pie.o_field_widget .o_pie .o_mask")[1],
"o_full",
"right mask should be hidden since the value > 50%"
);
assert.containsOnce(
target,
".o_field_percent_pie.o_field_widget .o_pie",
"should have a pie chart"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"80%",
"should have 80% as pie value since int_field=80"
);
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_mask").style
.transform,
"rotate(288deg)",
"left mask should be rotated from 360*(80/100) = 288 degrees"
);
assert.hasClass(
target.querySelectorAll(".o_field_percent_pie.o_field_widget .o_pie .o_mask")[1],
"o_full",
"right mask should be hidden since the value > 50%"
);
});
QUnit.test("PercentPieField in form view with float value", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<sheet>
<group>
<field name="float_field" widget="percentpie"/>
</group>
</sheet>
</form>`,
resId: 3,
});
assert.strictEqual(
target.querySelector(".o_field_percent_pie.o_field_widget .o_pie .o_pie_value")
.textContent,
"66%",
"should have 66% as pie value since float_field=65.6"
);
});
// TODO: This test would pass without any issue since all the classes and
// custom style attributes are correctly set on the widget in list
// view, but since the scss itself for this widget currently only
// applies inside the form view, the widget is unusable. This test can
// be uncommented when we refactor the scss files so that this widget
// stylesheet applies in both form and list view.
// QUnit.test('percentpie widget in editable list view', async function(assert) {
// assert.expect(10);
//
// var list = await createView({
// View: ListView,
// model: 'partner',
// data: this.data,
// arch: '<tree editable="bottom">' +
// '<field name="foo"/>' +
// '<field name="int_field" widget="percentpie"/>' +
// '</tree>',
// });
//
// assert.containsN(list, '.o_field_percent_pie .o_pie', 5,
// "should have five pie charts");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_pie_value').textContent,
// '10%', "should have 10% as pie value since int_field=10");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_mask').attr('style'),
// 'rotate(180deg)', "left mask should be covering the whole left side of the pie");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_mask').last().attr('style'),
// 'rotate(36deg)', "right mask should be rotated from 360*(10/100) = 36 degrees");
//
// // switch to edit mode and check the result
// testUtils.dom.click( target.querySelector('tbody td:not(.o_list_record_selector)'));
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_pie_value').textContent,
// '10%', "should have 10% as pie value since int_field=10");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_mask').attr('style'),
// 'rotate(180deg)', "left mask should be covering the whole right side of the pie");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_mask').last().attr('style'),
// 'rotate(36deg)', "right mask should be rotated from 360*(10/100) = 36 degrees");
//
// // save
// testUtils.dom.click( list.$buttons.find('.o_list_button_save'));
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_pie_value').textContent,
// '10%', "should have 10% as pie value since int_field=10");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_mask').attr('style'),
// 'rotate(180deg)', "left mask should be covering the whole right side of the pie");
// assert.strictEqual(target.querySelector('.o_field_percent_pie:first .o_pie .o_mask').last().attr('style'),
// 'rotate(36deg)', "right mask should be rotated from 360*(10/100) = 36 degrees");
//
// list.destroy();
// });
});

View file

@ -0,0 +1,61 @@
import {
clickSave,
defineModels,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
import { expect, test } from "@odoo/hoot";
import { 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("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

@ -1,108 +0,0 @@
/** @odoo-module **/
import { clickSave, editInput, getFixture, triggerEvent } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
partner: {
fields: {
float_field: {
string: "Float_field",
type: "float",
digits: [0, 1],
},
},
records: [{ float_field: 0.44444 }],
},
},
};
setupViewRegistries();
});
QUnit.module("PercentageField");
QUnit.test("PercentageField in form view", async function (assert) {
assert.expect(5);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" widget="percentage"/>
</form>`,
mockRPC(route, { args, method }) {
if (method === "write") {
assert.strictEqual(
args[1].float_field,
0.24,
"the correct float value should be saved"
);
}
},
resId: 1,
});
assert.strictEqual(
target.querySelector(".o_field_widget[name=float_field] input").value,
"44.4",
"The input should be rendered without the percentage symbol."
);
assert.strictEqual(
target.querySelector(".o_field_widget[name=float_field] span").textContent,
"%",
"The input should be followed by a span containing the percentage symbol."
);
const field = target.querySelector("[name='float_field'] input");
await editInput(target, "[name='float_field'] input", "24");
assert.strictEqual(field.value, "24", "The value should not be formated yet.");
await clickSave(target);
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
"24",
"The new value should be formatted properly."
);
});
QUnit.test("percentage field with placeholder", async function (assert) {
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="float_field" widget="percentage" placeholder="Placeholder"/>
</form>`,
});
const input = target.querySelector(".o_field_widget[name='float_field'] input");
input.value = "";
await triggerEvent(input, null, "input");
assert.strictEqual(
target.querySelector(".o_field_widget[name='float_field'] input").placeholder,
"Placeholder"
);
});
QUnit.test("PercentageField in form view without rounding error", async function (assert) {
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: `
<form>
<field name="float_field" widget="percentage"/>
</form>`,
});
await editInput(target, "[name='float_field'] input", "28");
assert.strictEqual(target.querySelector("[name='float_field'] input").value, "28");
});
});

View file

@ -0,0 +1,221 @@
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",
readonly: true,
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("placeholder_field shows as placeholder", async () => {
Partner._fields.char = fields.Char({
default: "My Placeholder",
});
await mountView({
type: "form",
resModel: "partner",
arch: `<form>
<field name="foo" widget="phone" options="{'placeholder_field' : 'char'}"/>
<field name="char"/>
</form>`,
});
expect(`.o_field_phone input`).toHaveAttribute("placeholder", "My 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",
readonly: true,
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");
});

Some files were not shown because too many files have changed in this diff Show more