19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -46,7 +46,7 @@ test("BinaryField is correctly rendered (readonly)", async () => {
onRpc("/web/content", async (request) => {
expect.step("/web/content");
const body = await request.text();
const body = await request.formData();
expect(body).toBeInstanceOf(FormData);
expect(body.get("field")).toBe("document", {
message: "we should download the field document",
@ -103,7 +103,7 @@ test("BinaryField is correctly rendered", async () => {
onRpc("/web/content", async (request) => {
expect.step("/web/content");
const body = await request.text();
const body = await request.formData();
expect(body).toBeInstanceOf(FormData);
expect(body.get("field")).toBe("document", {
message: "we should download the field document",
@ -469,10 +469,10 @@ test("should accept file with allowed MIME type and reject others", async () =>
test("doesn't crash if value is not a string", async () => {
class Dummy extends models.Model {
document = fields.Binary()
document = fields.Binary();
_applyComputesAndValidate() {}
}
defineModels([Dummy])
defineModels([Dummy]);
Dummy._records.push({ id: 1, document: {} });
await mountView({
type: "form",

View file

@ -7,6 +7,7 @@ import {
models,
mountView,
onRpc,
stepAllNetworkCalls,
} from "@web/../tests/web_test_helpers";
class Color extends models.Model {
@ -27,7 +28,7 @@ class Color extends models.Model {
form: /* xml */ `
<form>
<group>
<field name="hex_color" widget="color" />
<field name="hex_color" widget="color"/>
</group>
</form>`,
list: /* xml */ `
@ -137,3 +138,70 @@ test("color field change via anoter field's onchange", async () => {
expect(".o_field_color input").toHaveValue("#fefefe");
expect(".o_field_color div").toHaveStyle({ backgroundColor: "rgb(254, 254, 254)" });
});
test.tags("desktop");
test(`color field in form view => no automatic save by default`, async () => {
stepAllNetworkCalls();
await mountView({
resModel: "color",
type: "form",
});
await contains(`input[type=color]`, { visible: false }).edit("#fefefe");
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"onchange",
]);
});
test.tags("desktop");
test(`color field in list view => automatic save by default`, async () => {
stepAllNetworkCalls();
await mountView({
resModel: "color",
type: "list",
arch: `
<list editable="bottom">
<field name="text"/>
<field name="hex_color" widget="color"/>
</list>`,
});
await contains(`.o_data_row:eq(0) input[type=color]`, { visible: false }).edit("#fefefe");
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_search_read",
"has_group",
"web_save",
]);
});
test.tags("desktop");
test(`color field in list view => no save if autosave is false`, async () => {
stepAllNetworkCalls();
await mountView({
resModel: "color",
type: "list",
arch: `
<list editable="bottom">
<field name="text"/>
<field name="hex_color" widget="color" options="{'autosave': 0}"/>
</list>`,
});
await contains(`.o_data_row:eq(0) input[type=color]`, { visible: false }).edit("#fefefe");
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_search_read",
"has_group",
]);
});

View file

@ -1,6 +1,19 @@
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 {
Deferred,
animationFrame,
click,
edit,
expect,
mockDate,
mockTimeZone,
press,
queryAllTexts,
queryFirst,
queryOne,
scroll,
test,
waitFor,
} from "@odoo/hoot";
import {
assertDateTimePicker,
getPickerCell,
@ -61,6 +74,45 @@ test("toggle datepicker", async () => {
expect(".o_datetime_picker").toHaveCount(0);
});
test("datepicker is automatically closed after selecting a value", async () => {
Partner._onChanges.date = () => {};
const def = new Deferred();
onRpc("onchange", () => def);
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 contains(getPickerCell(22)).click();
await animationFrame();
// The picker shouldn't be reopened, even if the onChange RPC is slow.
expect(".o_datetime_picker").toHaveCount(0);
def.resolve();
});
test("Ensure only one datepicker is open", async () => {
Partner._fields.date_start = fields.Date();
await mountView({
type: "form",
resModel: "res.partner",
arch: `
<form>
<field name="date_start"/>
<field name="date"/>
</form>`,
resId: 1,
});
await queryFirst("[data-field='date_start']").click();
await queryFirst("[data-field='date']").click();
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
});
test.tags("desktop");
test("open datepicker on Control+Enter", async () => {
defineParams({
@ -340,7 +392,7 @@ test("multi edition of date field in list view: clear date in input", async () =
await contains(".o_field_date button").click();
await fieldInput("date").clear();
expect(".modal").toHaveCount(1);
expect(await waitFor(".modal")).toHaveCount(1);
await contains(".modal .modal-footer .btn-primary").click();
expect(".o_data_row:first-child .o_data_cell").toHaveText("");

View file

@ -29,7 +29,7 @@ import {
import { _makeUser, user } from "@web/core/user";
function getPickerCell(expr) {
return queryAll(`.o_datetime_picker .o_date_item_cell:contains(/^${expr}$/)`);
return queryAll(`.o_datetime_picker .o_date_item_cell:text(${expr})`);
}
class Partner extends models.Model {
@ -1081,7 +1081,7 @@ test("list daterange: start date input width matches its span counterpart", asyn
const initialWidth = queryFirst(".o_field_daterange span").offsetWidth;
await contains(".o_field_daterange span:first").click();
await animationFrame();
expect(".o_field_daterange input").toHaveProperty("offsetWidth", initialWidth);
expect(".o_field_daterange input").toHaveProperty("offsetWidth", initialWidth + 1);
});
test("always range: related end date, both start date and end date empty", async () => {

View file

@ -330,12 +330,8 @@ test("multi edition of DatetimeField in list view: edit date in input", async ()
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();
await contains(".modal:only .modal-footer .btn-primary").click();
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");
@ -369,10 +365,7 @@ test("multi edition of DatetimeField in list view: clear date in input", async (
await edit("", { confirm: "Enter" });
await animationFrame();
expect(".modal").toHaveCount(1);
await click(".modal .modal-footer .btn-primary");
await animationFrame();
await contains(".modal:only .modal-footer .btn-primary").click();
expect(".o_data_row:first-child .o_data_cell:first").toHaveText("");
expect(".o_data_row:nth-child(2) .o_data_cell:first").toHaveText("");
@ -641,7 +634,7 @@ test("placeholder_field shows as placeholder (datetime)", async () => {
</form>`,
});
await contains("div[name='datetime'] button").click();
expect("div[name='datetime'] input").toHaveAttribute("placeholder", "Apr 1, 2025, 9:11AM", {
expect("div[name='datetime'] input").toHaveAttribute("placeholder", /Apr 1, 2025, 9:11\sAM/, {
message: "placeholder_field should be the placeholder",
});
});

View file

@ -4,6 +4,7 @@ import {
edit,
manuallyDispatchProgrammaticEvent,
queryAll,
queryAllProperties,
queryFirst,
setInputFiles,
waitFor,
@ -18,6 +19,7 @@ import {
onRpc,
pagerNext,
contains,
webModels,
} from "@web/../tests/web_test_helpers";
import { getOrigin } from "@web/core/utils/urls";
@ -349,14 +351,7 @@ test("ImageField preview is updated when an image is uploaded", async () => {
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" }
);
await waitFor(`div[name=document] img[data-src="data:image/png;base64,${MY_IMAGE}"]`);
});
test("clicking save manually after uploading new image should change the unique of the image src", async () => {
@ -878,3 +873,24 @@ test("convert image to webp", async () => {
);
await setFiles(imageFile);
});
test.tags("desktop");
test("ImageField with width attribute in list", async () => {
const { ResCompany, ResPartner, ResUsers } = webModels;
defineModels([ResCompany, ResPartner, ResUsers]);
await mountView({
type: "list",
resModel: "partner",
arch: /* xml */ `
<list>
<field name="document" widget="image" width="30"/>
<field name="foo"/>
</list>
`,
});
expect(".o_data_row").toHaveCount(3);
expect(".o_field_widget[name=document] img").toHaveCount(3);
expect(queryAllProperties(".o_list_table th[data-name=document]", "offsetWidth")).toEqual([39]);
});

View file

@ -0,0 +1,218 @@
import { expect, test } from "@odoo/hoot";
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 });
json_checkboxes_field = fields.Json({ string: "Json Checkboxes Field" });
_records = [
{
id: 1,
int_field: 10,
json_checkboxes_field: {
key1: { checked: true, label: "First Key" },
key2: { checked: false, label: "Second Key" },
},
},
];
}
defineModels([Partner]);
test("JsonCheckBoxesField", async () => {
const commands = [
{
key1: { checked: true, label: "First Key" },
key2: { checked: true, label: "Second Key" },
},
{
key1: { checked: false, label: "First Key" },
key2: { checked: true, label: "Second Key" },
},
];
onRpc("web_save", (args) => {
expect.step("web_save");
expect(args.args[1].json_checkboxes_field).toEqual(commands.shift());
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_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);
// check a 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);
// uncheck a value by clicking 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("JsonCheckBoxesField (readonly field)", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_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("JsonCheckBoxesField (some readonly)", async () => {
Partner._records[0].json_checkboxes_field = {
key1: { checked: true, label: "First Key" },
key2: { checked: false, readonly: true, label: "Second Key" },
};
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_checkboxes" />
</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:eq(0):enabled").toHaveCount(1, {
message: "first checkbox should be enabled",
});
expect("div.o_field_widget div.form-check input:eq(1):disabled").toHaveCount(1, {
message: "second checkbox 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("JsonCheckBoxesField (question circle)", async () => {
Partner._records[0].json_checkboxes_field = {
key1: { checked: true, label: "First Key" },
key2: { checked: false, label: "Second Key", question_circle: "Some info about this" },
};
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_checkboxes" />
</group>
</form>`,
});
expect("div.o_field_widget div.form-check:eq(0) ~ i.fa-question-circle").toHaveCount(0, {
message: "first checkbox should not have a question circle",
});
expect(
"div.o_field_widget div.form-check:eq(1) ~ i.fa-question-circle[title='Some info about this']"
).toHaveCount(1, {
message: "second checkbox should have a question circle",
});
});
test("JsonCheckBoxesField (implicit inline mode)", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_checkboxes" />
</group>
</form>`,
});
expect("div.o_field_widget .d-inline-block div.form-check").toHaveCount(2, {
message: "should show the checkboxes in inlined mode",
});
});
test("JsonCheckBoxesField (explicit inline mode)", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_checkboxes" options="{'stacked': 0}" />
</group>
</form>`,
});
expect("div.o_field_widget .d-inline-block div.form-check").toHaveCount(2, {
message: "should show the checkboxes in inlined mode",
});
});
test("JsonCheckBoxesField (stacked mode)", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<group>
<field name="json_checkboxes_field" widget="json_checkboxes" options="{'stacked': 1}" />
</group>
</form>`,
});
expect("div.o_field_widget .d-block div.form-check").toHaveCount(2, {
message: "should show the checkboxes in stacked mode",
});
});

View file

@ -1097,6 +1097,44 @@ test('many2many field with link option (kanban, create="0")', async () => {
expect(".o-kanban-button-new").toHaveCount(0);
});
test("readonly many2many field: edit record", async () => {
Partner._records[0].timmy = [1, 2];
onRpc("web_save", ({ args }) => {
expect.step(`save ${args[1].name}`);
});
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="color"/>
<field name="timmy" readonly="1">
<list>
<field name="name"/>
</list>
<form>
<field name="name"/>
</form>
</field>
</form>`,
resId: 1,
});
expect(".o_field_widget[name=timmy]").toHaveClass("o_readonly_modifier");
expect(".o_field_x2many_list_row_add").toHaveCount(0);
expect(".o_list_record_remove").toHaveCount(0);
expect(queryAllTexts(".o_data_cell")).toEqual(["gold", "silver"]);
await contains(".o_data_row:first .o_data_cell").click();
expect(".o_dialog .o_form_renderer").toHaveClass("o_form_editable");
await contains(".o_dialog .o_field_widget[name=name] input").edit("new name");
await contains(".o_dialog .o_form_button_save").click();
expect(queryAllTexts(".o_data_cell")).toEqual(["new name", "silver"]);
expect.verifySteps(["save new name"]);
});
test("many2many list: list of id as default value", async () => {
Partner._fields.turtles = fields.Many2many({
relation: "turtle",

View file

@ -4037,7 +4037,7 @@ test("many2one search with formatted name", async () => {
{
id: 1,
display_name: "Paul Eric",
__formatted_display_name: "Test: **Paul** --Eric-- `good guy`\n\tMore text",
__formatted_display_name: "Research & Development Test: **Paul** --Eric-- `good guy`\n\tMore text",
},
]);
await mountView({
@ -4053,7 +4053,7 @@ test("many2one search with formatted name", async () => {
expect(
".o_field_many2one[name='trululu'] .dropdown-menu a.dropdown-item:eq(0)"
).toHaveInnerHTML(
`Test: <b>Paul</b> <span class="text-muted">Eric</span> <span class="o_tag position-relative d-inline-flex align-items-center mw-100 o_badge badge rounded-pill lh-1 o_tag_color_0">good guy</span><br/><span style="margin-left: 2em"></span>More text`
`Research & Development Test: <b>Paul</b> <span class="text-muted">Eric</span> <span class="o_tag position-relative d-inline-flex align-items-center mw-100 o_badge badge rounded-pill lh-1 o_tag_color_0">good guy</span><br/><span style="margin-left: 2em"></span>More text`
);
await contains(
".o_field_many2one[name='trululu'] .dropdown-menu a.dropdown-item:eq(0)"
@ -4110,6 +4110,37 @@ test("search typeahead", async () => {
]);
});
test.tags("desktop");
test("skip name search optimization", async () => {
class Parent extends Component {
static template = xml`<Many2XAutocomplete
value="test"
resModel="'partner'"
activeActions="{}"
fieldString.translate="Field"
getDomain.bind="getDomain"
update.bind="update"
preventMemoization="true"
/>`;
static components = { Many2XAutocomplete };
static props = ["*"];
getDomain() {
return [];
}
update() {}
}
await mountWithCleanup(Parent);
onRpc("web_name_search", () => expect.step("web_name_search"));
await contains(".o_input_dropdown input").edit("wxy", { confirm: false });
await runAllTimers();
expect.verifySteps(["web_name_search"]);
expect(`.o-autocomplete.dropdown li:not(.o_m2o_dropdown_option) a`).toHaveCount(0);
await contains(".o_input_dropdown input").edit("wxyz", { confirm: false });
expect(`.o-autocomplete.dropdown li:not(.o_m2o_dropdown_option) a`).toHaveCount(0);
await runAllTimers();
expect.verifySteps(["web_name_search"]);
});
test("highlight search in many2one", async () => {
await mountView({
type: "form",

View file

@ -955,6 +955,40 @@ test("delete all records in last page (in field o2m inline list view)", async ()
expect(".o_x2m_control_panel .o_pager").toHaveText("1-2 / 3");
});
test("delete all records then repopulate", async () => {
Partner._records[0].turtles = [1];
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="turtles">
<list editable="bottom" default_order="turtle_int">
<field name="turtle_int" widget="handle"/>
<field name="turtle_foo"/>
</list>
</field>
</form>`,
resId: 1,
});
expect(".o_data_row").toHaveCount(1);
await contains(".o_list_record_remove").click();
expect(".o_data_row").toHaveCount(0);
await contains(".o_field_x2many_list_row_add a").click();
await contains(".o_field_one2many .o_list_renderer tbody input").edit("value 1", {
confirm: "blur",
});
expect(".o_data_row").toHaveCount(1);
await contains(".o_field_x2many_list_row_add a").click();
await contains(".o_field_one2many .o_list_renderer tbody input").edit("value 2", {
confirm: "blur",
});
expect(".o_data_row").toHaveCount(2);
await contains("tbody tr:eq(1) .o_handle_cell").dragAndDrop("tbody tr");
expect(".o_data_row").toHaveCount(2);
expect(queryAllTexts(".o_data_cell.o_list_char")).toEqual(["value 2", "value 1"]);
});
test.tags("desktop");
test("nested x2manys with inline form, but not list", async () => {
Turtle._views = { list: `<list><field name="turtle_foo"/></list>` };
@ -3662,6 +3696,56 @@ test("one2many kanban: conditional create/delete actions", async () => {
});
});
test("one2many kanban: conditional write action", async () => {
Partner._records[0].p = [2, 4];
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="bar"/>
<field name="p" options="{'write': [('bar', '=', True)]}">
<kanban>
<templates>
<t t-name="card">
<field name="name"/>
<field name="bar" widget="boolean_toggle"/>
</t>
</templates>
</kanban>
<form>
<field name="name"/>
<field name="foo"/>
</form>
</field>
</form>`,
resId: 1,
});
expect(".o_kanban_record:first span").toHaveText("second record");
expect(".o_field_widget[name=bar]:first input").toBeChecked();
// bar is initially true -> edit action is available
expect(".o_kanban_record:first .o_field_widget[name=bar] input").toBeEnabled();
expect(".o-kanban-button-new").toHaveCount(1); // can create
await contains(".o_kanban_record:first").click();
expect(".o_dialog .o_form_renderer").toHaveClass("o_form_editable");
await contains(".o_dialog .o_field_widget[name=name] input").edit("second record edited");
await contains(".modal .o_form_button_save").click();
expect(".o_kanban_record:first span").toHaveText("second record edited");
// set bar false -> edit action is no longer available
await contains('.o_field_widget[name="bar"] input').click();
expect(".o_kanban_record:first .o_field_widget[name=bar] input").not.toBeEnabled();
expect(".o-kanban-button-new").toHaveCount(1); // can still create
await contains(".o_kanban_record:first").click();
expect(".o_dialog .o_form_renderer").toHaveClass("o_form_readonly");
expect(".o_dialog .o_form_button_save").toHaveCount(0);
await contains(".modal .o_form_button_cancel").click();
expect(".o_dialog").toHaveCount(0);
});
test.tags("desktop");
test("editable one2many list, pager is updated on desktop", async () => {
Turtle._records.push({ id: 4, turtle_foo: "stephen hawking" });
@ -12248,6 +12332,70 @@ test("new record, receive more create commands than limit", async () => {
expect(".o_x2m_control_panel .o_pager").toHaveCount(0);
});
test("existing record: receive more create commands than limit", async () => {
Partner._records = [
{ id: 1, name: "Initial Record 1", p: [1, 2, 3, 4] },
{ id: 2, name: "Initial Record 2" },
{ id: 3, name: "Initial Record 3" },
{ id: 4, name: "Initial Record 4" },
]
Partner._onChanges = {
int_field: function (obj) {
if (obj.int_field === 16) {
obj.p = [
[0, 0, { display_name: "Record 1" }],
[0, 0, { display_name: "Record 2" }],
[0, 0, { display_name: "Record 3" }],
[0, 0, { display_name: "Record 4" }],
];
}
},
};
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: `
<form>
<field name="int_field"/>
<group>
<field name="p">
<list limit="2">
<field name="display_name"/>
</list>
</field>
</group>
</form>`,
});
expect(queryAllTexts(".o_data_cell.o_list_char")).toEqual([
"Initial Record 1",
"Initial Record 2",
]);
await contains("[name=int_field] input").edit("16", { confirm: "blur" });
expect(queryAllTexts(".o_data_cell.o_list_char")).toEqual([
"Initial Record 1",
"Initial Record 2",
"Record 1",
"Record 2",
"Record 3",
"Record 4",
]);
await contains(".o_data_row :text('Record 3') ~ .o_list_record_remove").click();
expect(queryAllTexts(".o_data_cell.o_list_char")).toEqual([
"Initial Record 1",
"Initial Record 2",
"Record 1",
"Record 2",
"Record 4",
"Initial Record 3",
]);
});
test("active actions are passed to o2m field", async () => {
Partner._records[0].turtles = [1, 2, 3];
@ -13490,3 +13638,85 @@ test("edit o2m with default_order on a field not in view (2)", async () => {
await contains(".modal-footer .o_form_button_save").click();
expect(queryAllTexts(".o_data_cell.o_list_char")).toEqual(["blip", "kawa2", "yop"]);
});
test("one2many list with aggregates in first column", async () => {
Partner._records[0].turtles = [1, 2, 3];
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="turtles">
<list>
<field name="turtle_int" sum="My sum"/>
<field name="display_name"/>
</list>
</field>
</form>`,
resId: 1,
});
expect(queryAllTexts(".o_data_cell")).toEqual([
"0",
"leonardo",
"9",
"donatello",
"21",
"raphael",
]);
expect(`tfoot td:first`).toHaveText("30");
});
test.tags("desktop");
test("one2many list with monetary aggregates and different currencies", async () => {
class Currency extends models.Model {
_name = "res.currency";
name = fields.Char();
symbol = fields.Char();
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 },
];
}
defineModels([Currency]);
Turtle._fields.amount = fields.Monetary({ currency_field: "currency", default: 100 });
Turtle._fields.currency = fields.Many2one({ relation: "res.currency", default: 1 });
Turtle._records[2].currency = 2;
Partner._records[0].turtles = [1, 2, 3];
await mountView({
type: "form",
resModel: "partner",
arch: `
<form>
<field name="turtles">
<list>
<field name="amount" sum="My sum"/>
<field name="currency"/>
</list>
</field>
</form>`,
resId: 1,
});
expect(queryAllTexts(".o_data_cell.o_list_number")).toEqual([
"$ 100.00",
"$ 100.00",
"100.00 €",
]);
expect(`tfoot`).toHaveText("$ 250.00?");
await contains("tfoot span sup").hover();
expect(".o_multi_currency_popover").toHaveCount(1);
expect(".o_multi_currency_popover").toHaveText("500.00 € at $ 0.50");
});

View file

@ -219,3 +219,21 @@ test("New record, fill in phone field, then click on call icon and save", async
expect(".o_field_widget[name=foo] input").toHaveValue("+12345678900");
expect(`.o_form_status_indicator_buttons`).toHaveClass("invisible");
});
test.tags("mobile");
test("PhoneField in form view shows only icon on mobile screens", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<sheet>
<group>
<field name="foo" widget="phone"/>
</group>
</sheet>
</form>`,
resId: 1,
});
expect(".o_field_phone .o_phone_form_link small").not.toBeVisible();
});

View file

@ -3,19 +3,23 @@ import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { WebClient } from "@web/webclient/webclient";
import { expect, getFixture, test } from "@odoo/hoot";
import {
animationFrame,
click,
edit,
expect,
getFixture,
mockDate,
press,
queryAll,
queryAllTexts,
queryAllValues,
queryAttribute,
queryFirst,
runAllTimers,
test,
waitFor,
} from "@odoo/hoot-dom";
import { animationFrame, mockDate, runAllTimers } from "@odoo/hoot-mock";
} from "@odoo/hoot";
import { editTime, getPickerCell } from "@web/../tests/core/datetime/datetime_test_helpers";
import {
clickCancel,
@ -56,7 +60,7 @@ async function changeType(propertyType) {
"tags",
"many2one",
"many2many",
"separator"
"separator",
];
const propertyTypeIndex = TYPES.indexOf(propertyType);
await click(".o_field_property_definition_type input");
@ -269,13 +273,11 @@ class ResCurrency extends models.Model {
name = fields.Char();
symbol = fields.Char();
_records = Object.entries(serverState.currencies).map(
([id, { name, symbol }]) => ({
id: Number(id) + 1,
name,
symbol,
})
);
_records = Object.entries(serverState.currencies).map(([id, { name, symbol }]) => ({
id: Number(id) + 1,
name,
symbol,
}));
}
defineModels([Partner, ResCompany, User, ResCurrency]);
@ -1123,7 +1125,7 @@ test("properties: many2many", async () => {
* modal should correspond to the selected model and should be updated dynamically.
*/
test.tags("desktop");
test("properties: many2one 'Search more...'", async () => {
test("properties: many2one 'Search more...' + internal link save keeps data", async () => {
onRpc(({ method, model }) => {
if (["has_access", "has_group"].includes(method)) {
return true;
@ -1137,6 +1139,8 @@ test("properties: many2one 'Search more...'", async () => {
{ model: "partner", display_name: "Partner" },
{ model: "res.users", display_name: "User" },
];
} else if (method === "get_formview_id") {
return false;
}
});
@ -1164,6 +1168,14 @@ test("properties: many2one 'Search more...'", async () => {
<field name="id"/>
<field name="display_name"/>
</list>`;
User._views[["form", false]] = /* xml */ `
<form>
<sheet>
<group>
<field name="display_name"/>
</group>
</sheet>
</form>`;
User._views[["list", false]] = /* xml */ `
<list>
<field name="id"/>
@ -1237,6 +1249,21 @@ test("properties: many2one 'Search more...'", async () => {
await animationFrame();
// Checking the model loaded
expect.verifySteps(["res.users"]);
// Select the first value
await click(".o_list_table tbody tr:first-child td[name='display_name']");
await animationFrame();
// Click on external button
await click(".o_properties_external_button");
await animationFrame();
// Click on Save & close button
await click(".modal .o_form_button_save");
await animationFrame();
// Check that value does not disappear
expect(".o_field_property_definition_value input").toHaveValue("Alice");
});
test("properties: date(time) property manipulations", async () => {
@ -1468,9 +1495,7 @@ test("properties: kanban view", async () => {
expect(".o_kanban_record:nth-child(2) .o_card_property_field:nth-child(1)").toHaveText(
"char value\nsuffix"
);
expect(".o_kanban_record:nth-child(2) .o_card_property_field:nth-child(2)").toHaveText(
"C"
);
expect(".o_kanban_record:nth-child(2) .o_card_property_field:nth-child(2)").toHaveText("C");
// check first card
expect(".o_kanban_record:nth-child(1) .o_card_property_field").toHaveCount(2);
@ -2231,10 +2256,7 @@ test("properties: open section by default", async () => {
await click("div[property-name='property_1'] .o_field_property_group_label");
await animationFrame();
expect(getGroups()).toEqual([
[["SEPARATOR 1", "property_1"]],
[["SEPARATOR 3", "property_3"]],
]);
expect(getGroups()).toEqual([[["SEPARATOR 1", "property_1"]], [["SEPARATOR 3", "property_3"]]]);
});
test.tags("desktop");
@ -2276,12 +2298,14 @@ test("properties: save separator folded state", async () => {
assertFolded([true, false, true, false]);
await clickSave();
expect.verifySteps([[
["property_1", true],
["property_2", false],
["property_3", true],
["property_4", false],
]]);
expect.verifySteps([
[
["property_1", true],
["property_2", false],
["property_3", true],
["property_4", false],
],
]);
});
/**
@ -2941,7 +2965,12 @@ test("properties: monetary without currency_field", async () => {
await click(".o_field_property_definition_type input");
await animationFrame();
expect(`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary) > div.text-muted`).toHaveAttribute("data-tooltip", "Not possible to create monetary field because there is no currency on current model.");
expect(
`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary) > div.text-muted`
).toHaveAttribute(
"data-tooltip",
"Not possible to create monetary field because there is no currency on current model."
);
});
test("properties: monetary with currency_id", async () => {
@ -2975,16 +3004,22 @@ test("properties: monetary with currency_id", async () => {
await click(".o_field_property_definition_type input");
await animationFrame();
expect(`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary) > div:not(.text-muted)`).toHaveCount(1);
expect(
`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary) > div:not(.text-muted)`
).toHaveCount(1);
await contains(`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary)`).click();
await contains(
`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary)`
).click();
expect(`.o_field_property_definition_currency_field select`).toHaveText("Currency");
expect(`.o_field_property_definition_currency_field select`).toHaveValue("currency_id");
expect(".o_field_property_definition_value .o_input > span:eq(0)").toHaveText("$");
expect(`.o_field_property_definition_value input`).toHaveValue("0.00");
await closePopover();
expect(".o_property_field:nth-child(2) .o_property_field_value .o_input > span:eq(0)").toHaveText("$");
expect(
".o_property_field:nth-child(2) .o_property_field_value .o_input > span:eq(0)"
).toHaveText("$");
expect(`.o_property_field:nth-child(2) .o_property_field_value input`).toHaveValue("0.00");
});
@ -3021,18 +3056,28 @@ test("properties: monetary with multiple currency field", async () => {
await click(".o_field_property_definition_type input");
await animationFrame();
expect(`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary) > div:not(.text-muted)`).toHaveCount(1);
expect(
`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary) > div:not(.text-muted)`
).toHaveCount(1);
await contains(`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary)`).click();
expect(`.o_field_property_definition_currency_field select`).toHaveText("Currency\nAnother currency");
await contains(
`.o_field_property_definition_type_menu .o-dropdown-item:contains(Monetary)`
).click();
expect(`.o_field_property_definition_currency_field select`).toHaveText(
"Currency\nAnother currency"
);
expect(`.o_field_property_definition_currency_field select`).toHaveValue("currency_id");
await contains(".o_field_property_definition_currency_field select").select("another_currency_id");
await contains(".o_field_property_definition_currency_field select").select(
"another_currency_id"
);
expect(`.o_field_property_definition_currency_field select`).toHaveValue("another_currency_id");
expect(".o_field_property_definition_value .o_input > span:eq(1)").toHaveText("€");
expect(`.o_field_property_definition_value input`).toHaveValue("0.00");
await closePopover();
expect(".o_property_field:nth-child(2) .o_property_field_value .o_input > span:eq(1)").toHaveText("€");
expect(
".o_property_field:nth-child(2) .o_property_field_value .o_input > span:eq(1)"
).toHaveText("€");
expect(`.o_property_field:nth-child(2) .o_property_field_value input`).toHaveValue("0.00");
});

View file

@ -1,8 +1,15 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { beforeEach, expect, test, waitFor } from "@odoo/hoot";
import { click, edit, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame, mockDate } from "@odoo/hoot-mock";
import { getPickerCell } from "@web/../tests/core/datetime/datetime_test_helpers";
import { defineModels, fields, models, mountView, onRpc, contains } from "@web/../tests/web_test_helpers";
import {
defineModels,
fields,
models,
mountView,
onRpc,
contains,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
date = fields.Date({ string: "A date", searchable: true });
@ -39,8 +46,8 @@ test("RemainingDaysField on a date field in list view", async () => {
expect(cells[2]).toHaveText("Yesterday");
expect(cells[3]).toHaveText("In 2 days");
expect(cells[4]).toHaveText("3 days ago");
expect(cells[5]).toHaveText("02/08/2018");
expect(cells[6]).toHaveText("06/08/2017");
expect(cells[5]).toHaveText("Feb 8, 2018");
expect(cells[6]).toHaveText("Jun 8");
expect(cells[7]).toHaveText("");
expect(queryOne(".o_field_widget > div", { root: cells[0] })).toHaveAttribute(
@ -111,12 +118,11 @@ test("RemainingDaysField on a date field in multi edit list view", async () => {
await contains(".o_field_remaining_days button").click();
await edit("10/10/2017", { confirm: "enter" });
await animationFrame();
expect(".modal").toHaveCount(1);
await waitFor(".modal");
expect(".modal .o_field_widget").toHaveText("In 2 days", {
message: "should have 'In 2 days' value to change",
});
await click(".modal .modal-footer .btn-primary");
await click(".modal:only .modal-footer .btn-primary");
await animationFrame();
expect(".o_data_row:eq(0) .o_data_cell:first").toHaveText("In 2 days", {
@ -286,8 +292,8 @@ test("RemainingDaysField on a datetime field in list view in UTC", async () => {
"Yesterday",
"In 2 days",
"3 days ago",
"02/08/2018",
"06/08/2017",
"Feb 8, 2018",
"Jun 8",
"",
]);

View file

@ -86,6 +86,32 @@ test("SelectionField in a list view", async () => {
expect(td.children).toHaveCount(1, { message: "select tag should be only child of td" });
});
test.tags("desktop");
test("SelectionField in a list view with multi_edit", async () => {
Partner._records.forEach((r) => (r.color = "red"));
onRpc("has_group", () => true);
await mountView({
type: "list",
resModel: "partner",
arch: '<list string="Colors" multi_edit="1"><field name="color"/></list>',
});
// select two records and edit them
await click(".o_data_row:eq(0) .o_list_record_selector input:first");
await animationFrame();
await click(".o_data_row:eq(1) .o_list_record_selector input:first");
await animationFrame();
await contains(".o_field_cell[name='color']").click();
await editSelectMenu(".o_field_widget[name='color'] input", { value: "" });
await contains(".o_dialog footer button").click();
expect(queryAllTexts(".o_field_cell")).toEqual(["", "", "Red"]);
await contains(".o_field_cell[name='color']").click();
await editSelectMenu(".o_field_widget[name='color'] input", { value: "Black" });
await contains(".o_dialog footer button").click();
expect(queryAllTexts(".o_field_cell")).toEqual(["Black", "Black", "Red"]);
});
test("SelectionField, edition and on many2one field", async () => {
Partner._onChanges.product_id = () => {};
Partner._records[0].product_id = 37;

View file

@ -1,6 +1,6 @@
import { NameAndSignature } from "@web/core/signature/name_and_signature";
import { expect, test } from "@odoo/hoot";
import { expect, queryOne, test } from "@odoo/hoot";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { click, drag, edit, queryFirst, waitFor } from "@odoo/hoot-dom";
import {
@ -342,3 +342,22 @@ test("signature field should render initials", async () => {
});
expect.verifySteps(["V.B."]);
});
test("error loading url", async () => {
Partner._records = [{
id: 1,
sign: "1 kb",
}]
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="sign" widget="signature" />
</form>`,
});
const img = queryOne(".o_field_widget img");
img.dispatchEvent(new Event("error"));
await waitFor(".o_notification:has(.bg-danger):contains(Could not display the selected image)");
});

View file

@ -395,8 +395,8 @@ test('StateSelectionField edited by the smart actions "Set kanban state as <stat
expect(".o_status_red").toHaveCount(1);
await press(["control", "k"]);
await animationFrame();
expect(`.o_command:contains("Set kanban state as Normal\nALT + D")`).toHaveCount(1);
const doneItem = `.o_command:contains("Set kanban state as Done\nALT + G")`;
expect(`.o_command:contains("Set kanban state as Normal ALT + D")`).toHaveCount(1);
const doneItem = `.o_command:contains("Set kanban state as Done ALT + G")`;
expect(doneItem).toHaveCount(1);
await click(doneItem);
@ -405,9 +405,9 @@ test('StateSelectionField edited by the smart actions "Set kanban state as <stat
await press(["control", "k"]);
await animationFrame();
expect(`.o_command:contains("Set kanban state as Normal\nALT + D")`).toHaveCount(1);
expect(`.o_command:contains("Set kanban state as Blocked\nALT + F")`).toHaveCount(1);
expect(`.o_command:contains("Set kanban state as Done\nALT + G")`).toHaveCount(0);
expect(`.o_command:contains("Set kanban state as Normal ALT + D")`).toHaveCount(1);
expect(`.o_command:contains("Set kanban state as Blocked ALT + F")`).toHaveCount(1);
expect(`.o_command:contains("Set kanban state as Done ALT + G")`).toHaveCount(0);
});
test("StateSelectionField uses legend_* fields", async () => {
@ -565,7 +565,8 @@ test("StateSelectionField - hotkey handling when there are more than 3 options a
await press(["control", "k"]);
await animationFrame();
expect(".o_command#o_command_2").toHaveText("Set kanban state as Done\nALT + G", {
expect(".o_command#o_command_2").toHaveText("Set kanban state as Done ALT + G", {
inline: true,
message: "hotkey and command are present",
});
expect(".o_command#o_command_4").toHaveText("Set kanban state as Martine", {

View file

@ -1,6 +1,7 @@
import { expect, test } from "@odoo/hoot";
import { expect, resize, test } from "@odoo/hoot";
import {
click,
Deferred,
edit,
press,
queryAll,
@ -23,6 +24,8 @@ import {
mountWithCleanup,
onRpc,
serverState,
pagerNext,
pagerPrevious,
} from "@web/../tests/web_test_helpers";
import { EventBus } from "@odoo/owl";
import { WebClient } from "@web/webclient/webclient";
@ -1066,3 +1069,193 @@ test('"status" with no stages does not crash command palette', async () => {
expect(commands).not.toInclude("Move to next Stage");
});
test.tags("desktop");
test("cache: update current status if it changed", async () => {
class Stage extends models.Model {
name = fields.Char();
_records = [
{ id: 1, name: "Stage 1" },
{ id: 2, name: "Stage 2" },
];
}
Partner._fields.stage_id = fields.Many2one({ relation: "stage" });
Partner._records = [
{
id: 1,
name: "first record",
stage_id: 1,
},
{
id: 2,
name: "second record",
stage_id: 2,
},
{
id: 3,
name: "third record",
stage_id: 2,
},
];
defineModels([Stage]);
Partner._views = {
kanban: `
<kanban default_group_by="stage_id">
<templates>
<t t-name="card">
<field name="display_name"/>
</t>
</templates>
</kanban>`,
form: `
<form>
<header>
<field name="stage_id" widget="statusbar" />
</header>
</form>`,
search: `<search></search>`,
};
onRpc("has_group", () => true);
let def;
onRpc("web_read", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction({
id: 1,
name: "Partners",
res_model: "partner",
type: "ir.actions.act_window",
cache: true,
views: [
[false, "kanban"],
[false, "form"],
],
});
// populate the cache by visiting the 3 records
await contains(".o_kanban_record").click();
expect(".o_last_breadcrumb_item").toHaveText("first record");
await pagerNext();
expect(".o_last_breadcrumb_item").toHaveText("second record");
await pagerNext();
expect(".o_last_breadcrumb_item").toHaveText("third record");
// go back to kanban and drag the first record of stage 2 on top of stage 1 column
await contains(".o_breadcrumb .o_back_button").click();
const dragActions = await contains(".o_kanban_record:contains(second record)").drag();
await dragActions.moveTo(".o_kanban_record:contains(first record)");
await dragActions.drop();
expect(queryAllTexts(".o_kanban_record")).toEqual([
"second record",
"first record",
"third record",
]);
// re-open last record and use to pager to reach the record we just moved
await contains(".o_kanban_record:contains(third record)").click();
await pagerPrevious();
def = new Deferred();
await pagerPrevious();
// retrieved from the cache => former value
expect(".o_last_breadcrumb_item").toHaveText("second record");
expect('.o_statusbar_status button[data-value="2"]').toHaveClass("o_arrow_button_current");
def.resolve();
await animationFrame();
// updated when the rpc returns
expect(".o_last_breadcrumb_item").toHaveText("second record");
expect('.o_statusbar_status button[data-value="1"]').toHaveClass("o_arrow_button_current");
});
test("[adjust] statusbar with a lot of stages, click to change stage", async () => {
// force the window width and define long stage names s.t. at most 3 stages can be displayed
resize({ width: 800 });
class Stage extends models.Model {
name = fields.Char();
_records = Array.from(Array(6).keys()).map((i) => {
const id = i + 1;
return { id, name: `Stage with very long name ${id}` };
});
}
defineModels([Stage]);
Partner._fields.stage_id = { type: "many2one", relation: "stage" };
Partner._records[0].stage_id = 3;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<field name="stage_id" widget="statusbar" options="{'clickable': 1}" />
</header>
</form>
`,
});
// initial rendering: there should be a dropdown before and a dropdown after
expect(".o_statusbar_status button:visible.dropdown-toggle").toHaveCount(2);
expect(queryAllTexts(".o_statusbar_status button:visible:not(.dropdown-toggle)")).toEqual([
"Stage with very long name 4",
"Stage with very long name 3",
"Stage with very long name 2",
]);
expect(".o_statusbar_status button[data-value='3']").toHaveClass("o_arrow_button_current");
await contains(".o_statusbar_status .o_last").click();
expect(queryAllTexts(".o-dropdown-item")).toEqual(["Stage with very long name 1"]);
await contains(".o_statusbar_status .o_first").click();
expect(queryAllTexts(".o-dropdown-item")).toEqual([
"Stage with very long name 5",
"Stage with very long name 6",
]);
// choose the next value: there should still be one dropdown before and one after
await contains(".o_statusbar_status button[data-value='4']").click();
expect(".o_statusbar_status button:visible.dropdown-toggle").toHaveCount(2);
expect(queryAllTexts(".o_statusbar_status button:visible:not(.dropdown-toggle)")).toEqual([
"Stage with very long name 5",
"Stage with very long name 4",
"Stage with very long name 3",
]);
expect(".o_statusbar_status button[data-value='4']").toHaveClass("o_arrow_button_current");
await contains(".o_statusbar_status .o_last").click();
expect(queryAllTexts(".o-dropdown-item")).toEqual([
"Stage with very long name 1",
"Stage with very long name 2",
]);
await contains(".o_statusbar_status .o_first").click();
expect(queryAllTexts(".o-dropdown-item")).toEqual(["Stage with very long name 6"]);
// choose the next value: there should only be a dropdown before
await contains(".o_statusbar_status button[data-value='5']").click();
expect(".o_statusbar_status button:visible.dropdown-toggle").toHaveCount(1);
expect(queryAllTexts(".o_statusbar_status button:visible:not(.dropdown-toggle)")).toEqual([
"Stage with very long name 6",
"Stage with very long name 5",
"Stage with very long name 4",
]);
expect(".o_statusbar_status button[data-value='5']").toHaveClass("o_arrow_button_current");
await contains(".o_statusbar_status .o_last").click();
expect(queryAllTexts(".o-dropdown-item")).toEqual([
"Stage with very long name 1",
"Stage with very long name 2",
"Stage with very long name 3",
]);
// select the first item from the dropdown before => there should only be a dropdown after
await contains(".o-dropdown-item:first").click();
expect(".o_statusbar_status button:visible.dropdown-toggle").toHaveCount(1);
expect(queryAllTexts(".o_statusbar_status button:visible:not(.dropdown-toggle)")).toEqual([
"Stage with very long name 3",
"Stage with very long name 2",
"Stage with very long name 1",
]);
expect(".o_statusbar_status button[data-value='1']").toHaveClass("o_arrow_button_current");
await contains(".o_statusbar_status .o_first").click();
expect(queryAllTexts(".o-dropdown-item")).toEqual([
"Stage with very long name 4",
"Stage with very long name 5",
"Stage with very long name 6",
]);
});

View file

@ -61,6 +61,17 @@ test("basic rendering char field", async () => {
expect(".o_field_text textarea").toHaveValue("Description\nas\ntext");
});
test("char field with widget='text' trims trailing spaces", async () => {
Product._fields.name = fields.Char({ trim: true });
await mountView({
type: "form",
resModel: "product",
arch: '<form><field name="name" widget="text"/></form>',
});
await fieldTextArea("name").edit("test ");
expect(".o_field_text textarea").toHaveValue("test");
});
test("render following an onchange", async () => {
Product._fields.name = fields.Char({
onChange: (record) => {

View file

@ -6,8 +6,10 @@ import {
fields,
models,
mountView,
patchWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { TimezoneMismatchField } from "@web/views/fields/timezone_mismatch/timezone_mismatch_field";
class Localization extends models.Model {
country = fields.Selection({
@ -67,3 +69,37 @@ test("in a form view", async () => {
);
expect(".o_tz_warning").toHaveCount(1);
});
test("timezone_mismatch_field mismatch property", () => {
const testCases = [
{userOffset: "-1030", browserOffset: 630, expectedMismatch: false},
{userOffset: "+0000", browserOffset: 0, expectedMismatch: false},
{userOffset: "+0345", browserOffset: -225, expectedMismatch: false},
{userOffset: "+0500", browserOffset: -300, expectedMismatch: false},
{userOffset: "+0200", browserOffset: 120, expectedMismatch: true},
{userOffset: "+1200", browserOffset: 0, expectedMismatch: true},
];
for (const testCase of testCases) {
patchWithCleanup(Date.prototype, {
getTimezoneOffset: () => testCase.browserOffset,
});
patchWithCleanup(TimezoneMismatchField.prototype, {
props: {
name: "tz",
tzOffsetField: "tz_offset",
record: {
data: {
tz: "Test/Test",
tz_offset: testCase.userOffset,
},
},
},
});
const mockField = Object.create(TimezoneMismatchField.prototype);
expect(mockField.mismatch).toBe(testCase.expectedMismatch);
}
});

View file

@ -1,5 +1,6 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { queryAllAttributes, queryAllTexts, queryFirst } from "@odoo/hoot-dom";
import { queryAllAttributes, queryAllTexts, queryFirst, click, middleClick } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
contains,
defineModels,
@ -193,3 +194,29 @@ test("with non falsy, but non url value", async () => {
});
expect(".o_field_widget[name=url] a").toHaveAttribute("href", "http://odoo://hello");
});
test.tags("desktop");
test("in form x2many field, click/middleclick on the link should not open the record in modal", async () => {
Product._fields.p = fields.One2many({
string: "one2many_field",
relation: "product",
});
Product._records = [
{ id: 1, url: "https://www.example.com/1", p: [2] },
{ id: 2, url: "http://www.example.com/2", p: [] },
];
Product._views.list = `<list><field name="url" widget="url"/></list>`;
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: `<form><field name="p" widget="one2many"/></form>`,
});
await click(".o_field_widget[name=url] a");
await animationFrame();
expect(".modal.o_technical_modal").toHaveCount(0);
await middleClick(".o_field_widget[name=url] a");
await animationFrame();
expect(".modal.o_technical_modal").toHaveCount(0);
});