vanilla 18.0

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

View file

@ -0,0 +1,214 @@
import { describe, expect, test } from "@odoo/hoot";
import { FAKE_FIELDS } from "./calendar_test_helpers";
import { CalendarArchParser } from "@web/views/calendar/calendar_arch_parser";
describe.current.tags("headless");
const parser = new CalendarArchParser();
const DEFAULT_ARCH_RESULTS = {
canCreate: true,
canDelete: true,
canEdit: true,
eventLimit: 5,
fieldMapping: {
date_start: "start_date",
},
fieldNames: ["start_date"],
filtersInfo: {},
formViewId: false,
hasEditDialog: false,
quickCreate: true,
quickCreateViewId: null,
isDateHidden: false,
isTimeHidden: false,
popoverFieldNodes: {},
scale: "week",
scales: ["day", "week", "month", "year"],
showUnusualDays: false,
};
function parseArch(arch) {
return parser.parse(arch, { fake: { fields: FAKE_FIELDS } }, "fake");
}
function parseWith(attrs) {
const str = Object.entries(attrs)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
return parseArch(`<calendar date_start="start_date" ${str}/>`);
}
test(`throw if date_start is not set`, () => {
expect(() => parseArch(`<calendar/>`)).toThrow(
`Calendar view has not defined "date_start" attribute.`
);
});
test(`defaults`, () => {
expect(parseArch(`<calendar date_start="start_date"/>`)).toEqual(DEFAULT_ARCH_RESULTS);
});
test("canCreate", () => {
expect(parseWith({ create: "" }).canCreate).toBe(true);
expect(parseWith({ create: "true" }).canCreate).toBe(true);
expect(parseWith({ create: "True" }).canCreate).toBe(true);
expect(parseWith({ create: "1" }).canCreate).toBe(true);
expect(parseWith({ create: "false" }).canCreate).toBe(false);
expect(parseWith({ create: "False" }).canCreate).toBe(false);
expect(parseWith({ create: "0" }).canCreate).toBe(false);
});
test("canDelete", () => {
expect(parseWith({ delete: "" }).canDelete).toBe(true);
expect(parseWith({ delete: "true" }).canDelete).toBe(true);
expect(parseWith({ delete: "True" }).canDelete).toBe(true);
expect(parseWith({ delete: "1" }).canDelete).toBe(true);
expect(parseWith({ delete: "false" }).canDelete).toBe(false);
expect(parseWith({ delete: "False" }).canDelete).toBe(false);
expect(parseWith({ delete: "0" }).canDelete).toBe(false);
});
test("canEdit", () => {
expect(parseWith({ edit: "" }).canEdit).toBe(true);
expect(parseWith({ edit: "true" }).canEdit).toBe(true);
expect(parseWith({ edit: "True" }).canEdit).toBe(true);
expect(parseWith({ edit: "1" }).canEdit).toBe(true);
expect(parseWith({ edit: "false" }).canEdit).toBe(false);
expect(parseWith({ edit: "False" }).canEdit).toBe(false);
expect(parseWith({ edit: "0" }).canEdit).toBe(false);
});
test("eventLimit", () => {
expect(parseWith({ event_limit: "2" }).eventLimit).toBe(2);
expect(parseWith({ event_limit: "5" }).eventLimit).toBe(5);
expect(() => parseWith({ event_limit: "five" })).toThrow();
expect(() => parseWith({ event_limit: "" })).toThrow();
});
test("hasEditDialog", () => {
expect(parseWith({ event_open_popup: "" }).hasEditDialog).toBe(false);
expect(parseWith({ event_open_popup: "true" }).hasEditDialog).toBe(true);
expect(parseWith({ event_open_popup: "True" }).hasEditDialog).toBe(true);
expect(parseWith({ event_open_popup: "1" }).hasEditDialog).toBe(true);
expect(parseWith({ event_open_popup: "false" }).hasEditDialog).toBe(false);
expect(parseWith({ event_open_popup: "False" }).hasEditDialog).toBe(false);
expect(parseWith({ event_open_popup: "0" }).hasEditDialog).toBe(false);
});
test("quickCreate", () => {
expect(parseWith({ quick_create: "" }).quickCreate).toBe(true);
expect(parseWith({ quick_create: "true" }).quickCreate).toBe(true);
expect(parseWith({ quick_create: "True" }).quickCreate).toBe(true);
expect(parseWith({ quick_create: "1" }).quickCreate).toBe(true);
expect(parseWith({ quick_create: "false" }).quickCreate).toBe(false);
expect(parseWith({ quick_create: "False" }).quickCreate).toBe(false);
expect(parseWith({ quick_create: "0" }).quickCreate).toBe(false);
expect(parseWith({ quick_create: "12" }).quickCreate).toBe(true);
});
test("quickCreateViewId", () => {
expect(parseWith({ quick_create: "0", quick_create_view_id: "12" })).toEqual({
...DEFAULT_ARCH_RESULTS,
quickCreate: false,
quickCreateViewId: null,
});
expect(parseWith({ quick_create: "1", quick_create_view_id: "12" })).toEqual({
...DEFAULT_ARCH_RESULTS,
quickCreate: true,
quickCreateViewId: 12,
});
expect(parseWith({ quick_create: "1" })).toEqual({
...DEFAULT_ARCH_RESULTS,
quickCreate: true,
quickCreateViewId: null,
});
});
test("isDateHidden", () => {
expect(parseWith({ hide_date: "" }).isDateHidden).toBe(false);
expect(parseWith({ hide_date: "true" }).isDateHidden).toBe(true);
expect(parseWith({ hide_date: "True" }).isDateHidden).toBe(true);
expect(parseWith({ hide_date: "1" }).isDateHidden).toBe(true);
expect(parseWith({ hide_date: "false" }).isDateHidden).toBe(false);
expect(parseWith({ hide_date: "False" }).isDateHidden).toBe(false);
expect(parseWith({ hide_date: "0" }).isDateHidden).toBe(false);
});
test("isTimeHidden", () => {
expect(parseWith({ hide_time: "" }).isTimeHidden).toBe(false);
expect(parseWith({ hide_time: "true" }).isTimeHidden).toBe(true);
expect(parseWith({ hide_time: "True" }).isTimeHidden).toBe(true);
expect(parseWith({ hide_time: "1" }).isTimeHidden).toBe(true);
expect(parseWith({ hide_time: "false" }).isTimeHidden).toBe(false);
expect(parseWith({ hide_time: "False" }).isTimeHidden).toBe(false);
expect(parseWith({ hide_time: "0" }).isTimeHidden).toBe(false);
});
test("scale", () => {
expect(parseWith({ mode: "day" }).scale).toBe("day");
expect(parseWith({ mode: "week" }).scale).toBe("week");
expect(parseWith({ mode: "month" }).scale).toBe("month");
expect(parseWith({ mode: "year" }).scale).toBe("year");
expect(() => parseWith({ mode: "" })).toThrow(`Calendar view cannot display mode: `);
expect(() => parseWith({ mode: "other" })).toThrow(`Calendar view cannot display mode: other`);
});
test("scales", () => {
expect(parseWith({ scales: "" }).scales).toEqual([]);
expect(parseWith({ scales: "day" }).scales).toEqual(["day"]);
expect(parseWith({ scales: "day,week" }).scales).toEqual(["day", "week"]);
expect(parseWith({ scales: "day,week,month" }).scales).toEqual(["day", "week", "month"]);
expect(parseWith({ scales: "day,week,month,year" }).scales).toEqual([
"day",
"week",
"month",
"year",
]);
expect(parseWith({ scales: "week" }).scales).toEqual(["week"]);
expect(parseWith({ scales: "week,month" }).scales).toEqual(["week", "month"]);
expect(parseWith({ scales: "week,month,year" }).scales).toEqual(["week", "month", "year"]);
expect(parseWith({ scales: "month" }).scales).toEqual(["month"]);
expect(parseWith({ scales: "month,year" }).scales).toEqual(["month", "year"]);
expect(parseWith({ scales: "year" }).scales).toEqual(["year"]);
expect(parseWith({ scales: "year,day,month,week" }).scales).toEqual([
"year",
"day",
"month",
"week",
]);
expect(() =>
parseArch(`<calendar date_start="start_date" scales="month" mode="day"/>`)
).toThrow();
});
test("showUnusualDays", () => {
expect(parseWith({ show_unusual_days: "" }).showUnusualDays).toBe(false);
expect(parseWith({ show_unusual_days: "true" }).showUnusualDays).toBe(true);
expect(parseWith({ show_unusual_days: "True" }).showUnusualDays).toBe(true);
expect(parseWith({ show_unusual_days: "1" }).showUnusualDays).toBe(true);
expect(parseWith({ show_unusual_days: "false" }).showUnusualDays).toBe(false);
expect(parseWith({ show_unusual_days: "False" }).showUnusualDays).toBe(false);
expect(parseWith({ show_unusual_days: "0" }).showUnusualDays).toBe(false);
});

View file

@ -0,0 +1,163 @@
import { describe, expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { DEFAULT_DATE, FAKE_MODEL } from "./calendar_test_helpers";
import { CalendarCommonPopover } from "@web/views/calendar/calendar_common/calendar_common_popover";
describe.current.tags("desktop");
const FAKE_RECORD = {
id: 5,
title: "Meeting",
isAllDay: false,
start: DEFAULT_DATE,
end: DEFAULT_DATE.plus({ hours: 3, minutes: 15 }),
colorIndex: 0,
isTimeHidden: false,
rawRecord: {
name: "Meeting",
},
};
const FAKE_PROPS = {
model: FAKE_MODEL,
record: FAKE_RECORD,
createRecord() {},
deleteRecord() {},
editRecord() {},
close() {},
};
async function start(props = {}) {
await mountWithCleanup(CalendarCommonPopover, {
props: { ...FAKE_PROPS, ...props },
});
}
test(`mount a CalendarCommonPopover`, async () => {
await start();
expect(`.popover-header`).toHaveCount(1);
expect(`.popover-header`).toHaveText("Meeting");
expect(`.list-group`).toHaveCount(2);
expect(`.list-group.o_cw_popover_fields_secondary`).toHaveCount(1);
expect(`.card-footer .o_cw_popover_edit`).toHaveCount(1);
expect(`.card-footer .o_cw_popover_delete`).toHaveCount(1);
});
test(`date duration: is all day and is same day`, async () => {
await start({
record: { ...FAKE_RECORD, isAllDay: true, isTimeHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("July 16, 2021");
});
test(`date duration: is all day and two days duration`, async () => {
await start({
record: {
...FAKE_RECORD,
end: DEFAULT_DATE.plus({ days: 1 }),
isAllDay: true,
isTimeHidden: true,
},
});
expect(`.list-group:eq(0)`).toHaveText("July 16-17, 2021 2 days");
});
test(`time duration: 1 hour diff`, async () => {
await start({
record: { ...FAKE_RECORD, end: DEFAULT_DATE.plus({ hours: 1 }) },
model: { ...FAKE_MODEL, isDateHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("08:00 - 09:00 (1 hour)");
});
test(`time duration: 2 hours diff`, async () => {
await start({
record: { ...FAKE_RECORD, end: DEFAULT_DATE.plus({ hours: 2 }) },
model: { ...FAKE_MODEL, isDateHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("08:00 - 10:00 (2 hours)");
});
test(`time duration: 1 minute diff`, async () => {
await start({
record: { ...FAKE_RECORD, end: DEFAULT_DATE.plus({ minutes: 1 }) },
model: { ...FAKE_MODEL, isDateHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("08:00 - 08:01 (1 minute)");
});
test(`time duration: 2 minutes diff`, async () => {
await start({
record: { ...FAKE_RECORD, end: DEFAULT_DATE.plus({ minutes: 2 }) },
model: { ...FAKE_MODEL, isDateHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("08:00 - 08:02 (2 minutes)");
});
test(`time duration: 3 hours and 15 minutes diff`, async () => {
await start({
model: { ...FAKE_MODEL, isDateHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("08:00 - 11:15 (3 hours, 15 minutes)");
});
test(`isDateHidden is true`, async () => {
await start({
model: { ...FAKE_MODEL, isDateHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("08:00 - 11:15 (3 hours, 15 minutes)");
});
test(`isDateHidden is false`, async () => {
await start({
model: { ...FAKE_MODEL, isDateHidden: false },
});
expect(`.list-group:eq(0)`).toHaveText("July 16, 2021\n08:00 - 11:15 (3 hours, 15 minutes)");
});
test(`isTimeHidden is true`, async () => {
await start({
record: { ...FAKE_RECORD, isTimeHidden: true },
});
expect(`.list-group:eq(0)`).toHaveText("July 16, 2021");
});
test(`isTimeHidden is false`, async () => {
await start({
record: { ...FAKE_RECORD, isTimeHidden: false },
});
expect(`.list-group:eq(0)`).toHaveText("July 16, 2021\n08:00 - 11:15 (3 hours, 15 minutes)");
});
test(`canDelete is true`, async () => {
await start({
model: { ...FAKE_MODEL, canDelete: true },
});
expect(`.o_cw_popover_delete`).toHaveCount(1);
});
test(`canDelete is false`, async () => {
await start({
model: { ...FAKE_MODEL, canDelete: false },
});
expect(`.o_cw_popover_delete`).toHaveCount(0);
});
test(`click on delete button`, async () => {
await start({
model: { ...FAKE_MODEL, canDelete: true },
deleteRecord: () => expect.step("delete"),
});
await click(`.o_cw_popover_delete`);
expect.verifySteps(["delete"]);
});
test(`click on edit button`, async () => {
await start({
editRecord: () => expect.step("edit"),
});
await click(`.o_cw_popover_edit`);
expect.verifySteps(["edit"]);
});

View file

@ -0,0 +1,162 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { queryAllTexts, queryFirst, queryRect } from "@odoo/hoot-dom";
import { runAllTimers } from "@odoo/hoot-mock";
import { mockService, mountWithCleanup, preloadBundle } from "@web/../tests/web_test_helpers";
import {
DEFAULT_DATE,
FAKE_MODEL,
clickAllDaySlot,
clickEvent,
selectTimeRange,
} from "./calendar_test_helpers";
import { CalendarCommonRenderer } from "@web/views/calendar/calendar_common/calendar_common_renderer";
const FAKE_PROPS = {
model: FAKE_MODEL,
createRecord() {},
deleteRecord() {},
editRecord() {},
displayName: "Plop",
};
async function start(props = {}, target) {
await mountWithCleanup(CalendarCommonRenderer, {
props: { ...FAKE_PROPS, ...props },
target,
});
}
preloadBundle("web.fullcalendar_lib");
beforeEach(() => {
luxon.Settings.defaultZone = "UTC+1";
});
test(`mount a CalendarCommonRenderer`, async () => {
await start();
expect(`.o_calendar_widget.fc`).toHaveCount(1);
});
test(`Day: mount a CalendarCommonRenderer`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "day" } });
expect(`.o_calendar_widget.fc .fc-timeGridDay-view`).toHaveCount(1);
});
test(`Week: mount a CalendarCommonRenderer`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "week" } });
expect(`.o_calendar_widget.fc .fc-timeGridWeek-view`).toHaveCount(1);
});
test(`Month: mount a CalendarCommonRenderer`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "month" } });
expect(`.o_calendar_widget.fc .fc-dayGridMonth-view`).toHaveCount(1);
});
test(`Day: check week number`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "day" } });
expect(`[aria-label^="Week "]`).toHaveCount(1);
expect(`[aria-label^="Week "]`).toHaveText(/(Week )?28/);
});
test(`Day: check date`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "day" } });
expect(`.fc-col-header-cell.fc-day`).toHaveCount(1);
expect(`.fc-col-header-cell.fc-day:eq(0) .o_cw_day_name`).toHaveText("Friday");
expect(`.fc-col-header-cell.fc-day:eq(0) .o_cw_day_number`).toHaveText("16");
});
test(`Day: click all day slot`, async () => {
await start({
model: { ...FAKE_MODEL, scale: "day" },
createRecord(record) {
expect.step("create");
expect(record.isAllDay).toBe(true);
expect(record.start.valueOf()).toBe(DEFAULT_DATE.startOf("day").valueOf());
},
});
await clickAllDaySlot("2021-07-16");
expect.verifySteps(["create"]);
});
test.tags("desktop");
test(`Day: select range`, async () => {
await start({
model: { ...FAKE_MODEL, scale: "day" },
createRecord(record) {
expect.step("create");
expect(record.isAllDay).toBe(false);
expect(record.start.valueOf()).toBe(luxon.DateTime.local(2021, 7, 16, 8, 0).valueOf());
expect(record.end.valueOf()).toBe(luxon.DateTime.local(2021, 7, 16, 10, 0).valueOf());
},
});
await selectTimeRange("2021-07-16 08:00:00", "2021-07-16 10:00:00");
expect.verifySteps(["create"]);
});
test(`Day: check event`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "day" } });
expect(`.o_event`).toHaveCount(1);
expect(`.o_event`).toHaveAttribute("data-event-id", "1");
});
test.tags("desktop");
test(`Day: click on event`, async () => {
mockService("popover", () => ({
add(target, component, { record }) {
expect.step("popover");
expect(record.id).toBe(1);
return () => {};
},
}));
await start({ model: { ...FAKE_MODEL, scale: "day" } });
await clickEvent(1);
await runAllTimers();
expect.verifySteps(["popover"]);
});
test(`Week: check week number`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "week" } });
expect(`.fc-scrollgrid-section-header .fc-timegrid-axis-cushion`).toHaveCount(1);
expect(`.fc-scrollgrid-section-header .fc-timegrid-axis-cushion`).toHaveText(/(Week )?28/);
});
test(`Week: check dates`, async () => {
await start({ model: { ...FAKE_MODEL, scale: "week" } });
expect(`.fc-col-header-cell.fc-day`).toHaveCount(7);
expect(queryAllTexts(`.fc-col-header-cell .o_cw_day_name`)).toEqual([
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
]);
expect(queryAllTexts`.fc-col-header-cell .o_cw_day_number`).toEqual([
"11",
"12",
"13",
"14",
"15",
"16",
"17",
]);
});
test(`Day: automatically scroll to 6am`, async () => {
await mountWithCleanup(`<div class="scrollable" style="height: 500px;"/>`);
await start({ model: { ...FAKE_MODEL, scale: "day" } }, queryFirst(`.scrollable`));
const containerDimensions = queryRect(`.fc-scrollgrid-section-liquid .fc-scroller`);
const dayStartDimensions = queryRect(`.fc-timegrid-slot[data-time="06:00:00"]:eq(0)`);
expect(Math.abs(dayStartDimensions.y - containerDimensions.y)).toBeLessThan(2);
});
test(`Week: automatically scroll to 6am`, async () => {
await mountWithCleanup(`<div class="scrollable" style="height: 500px;"/>`);
await start({ model: { ...FAKE_MODEL, scale: "week" } }, queryFirst(`.scrollable`));
const containerDimensions = queryRect(`.fc-scrollgrid-section-liquid .fc-scroller`);
const dayStartDimensions = queryRect(`.fc-timegrid-slot[data-time="06:00:00"]:eq(0)`);
expect(Math.abs(dayStartDimensions.y - containerDimensions.y)).toBeLessThan(2);
});

View file

@ -0,0 +1,210 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { mockDate } from "@odoo/hoot-mock";
import {
contains,
defineModels,
defineParams,
fields,
findComponent,
models,
mountView,
patchWithCleanup,
preloadBundle,
} from "@web/../tests/web_test_helpers";
import { CalendarController } from "@web/views/calendar/calendar_controller";
import { changeScale } from "./calendar_test_helpers";
describe.current.tags("desktop");
class Event extends models.Model {
name = fields.Char();
start = fields.Date();
has_access() {
return true;
}
}
defineModels([Event]);
preloadBundle("web.fullcalendar_lib");
beforeEach(() => {
mockDate("2021-08-14T08:00:00");
});
test(`Mount a CalendarDatePicker`, async () => {
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="day"/>`,
});
expect(`.o_datetime_picker`).toHaveCount(1);
expect(`.o_datetime_picker .o_selected`).toHaveCount(1);
expect(`.o_datetime_picker .o_selected`).toHaveText("14");
expect(`.o_datetime_picker_header .o_datetime_button`).toHaveText("August 2021");
expect(queryAllTexts`.o_datetime_picker .o_day_of_week_cell`).toEqual([
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]);
});
test(`Scale: init with day`, async () => {
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="day"/>`,
});
expect(`.o_datetime_picker .o_selected`).toHaveCount(1);
expect(`.o_datetime_picker .o_selected`).toHaveText("14");
});
test(`Scale: init with week`, async () => {
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="week"/>`,
});
expect(`.o_datetime_picker .o_selected`).toHaveCount(1);
expect(`.o_datetime_picker .o_selected`).toHaveText("14");
});
test(`Scale: init with month`, async () => {
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="month"/>`,
});
expect(`.o_datetime_picker .o_selected`).toHaveCount(1);
expect(`.o_datetime_picker .o_selected`).toHaveText("14");
});
test(`Scale: init with year`, async () => {
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="year"/>`,
});
expect(`.o_datetime_picker .o_selected`).toHaveCount(1);
expect(`.o_datetime_picker .o_selected`).toHaveText("14");
});
test(`First day: 0 = Sunday`, async () => {
// the week start depends on the locale
defineParams({
lang_parameters: { week_start: 0 },
});
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="day"/>`,
});
expect(queryAllTexts`.o_datetime_picker .o_day_of_week_cell`).toEqual([
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]);
});
test(`First day: 1 = Monday`, async () => {
// the week start depends on the locale
defineParams({
lang_parameters: { week_start: 1 },
});
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="day"/>`,
});
expect(queryAllTexts`.o_datetime_picker .o_day_of_week_cell`).toEqual([
"M",
"T",
"W",
"T",
"F",
"S",
"S",
]);
});
test(`Click on active day should change scale : day -> month`, async () => {
const view = await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="day"/>`,
});
const calendar = findComponent(view, (component) => component instanceof CalendarController);
await contains(`.o_datetime_picker .o_selected`).click();
expect(calendar.model.scale).toBe("month");
expect(calendar.model.date.valueOf()).toBe(luxon.DateTime.local(2021, 8, 14).valueOf());
});
test(`Click on active day should change scale : month -> week`, async () => {
const view = await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="month"/>`,
});
const calendar = findComponent(view, (component) => component instanceof CalendarController);
await contains(`.o_datetime_picker .o_selected`).click();
expect(calendar.model.scale).toBe("week");
expect(calendar.model.date.valueOf()).toBe(luxon.DateTime.local(2021, 8, 14).valueOf());
});
test(`Click on active day should change scale : week -> day`, async () => {
const view = await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="week"/>`,
});
const calendar = findComponent(view, (component) => component instanceof CalendarController);
await contains(`.o_datetime_picker .o_selected`).click();
expect(calendar.model.scale).toBe("day");
expect(calendar.model.date.valueOf()).toBe(luxon.DateTime.local(2021, 8, 14).valueOf());
});
test(`Scale: today is correctly highlighted`, async () => {
mockDate("2021-07-04T08:00:00");
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" mode="month"/>`,
});
expect(`.o_datetime_picker .o_today`).toHaveClass("o_selected");
expect(`.o_datetime_picker .o_today`).toHaveText("4");
});
test(`Scale: scale default is fetched from sessionStorage`, async () => {
patchWithCleanup(sessionStorage, {
setItem(key, value) {
if (key === "calendar-scale") {
expect.step(`scale_${value}`);
}
},
getItem(key) {
if (key === "calendar-scale") {
return "month";
}
},
});
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start"/>`,
});
expect(`.scale_button_selection`).toHaveText("Month");
await changeScale("year");
expect(`.scale_button_selection`).toHaveText("Year");
expect.verifySteps(["scale_year"]);
});

View file

@ -0,0 +1,137 @@
import { expect, test } from "@odoo/hoot";
import { click, queryAllTexts } from "@odoo/hoot-dom";
import { contains, mountWithCleanup, preloadBundle } from "@web/../tests/web_test_helpers";
import { FAKE_MODEL } from "./calendar_test_helpers";
import { CalendarFilterPanel } from "@web/views/calendar/filter_panel/calendar_filter_panel";
import { runAllTimers } from "@odoo/hoot-mock";
const FAKE_PROPS = {
model: FAKE_MODEL,
};
async function start(props = {}) {
await mountWithCleanup(CalendarFilterPanel, {
props: { ...FAKE_PROPS, ...props },
});
}
preloadBundle("web.fullcalendar_lib");
test(`render filter panel`, async () => {
await start({});
expect(`.o_calendar_filter`).toHaveCount(2);
expect(`.o_calendar_filter:eq(0) .o_cw_filter_label`).toHaveText("Attendees");
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item`).toHaveCount(4);
expect(`.o_calendar_filter:eq(1) .o_cw_filter_label`).toHaveText("Users");
expect(`.o_calendar_filter:eq(1) .o_calendar_filter_item`).toHaveCount(2);
});
test(`filters are correctly sorted`, async () => {
await start({});
expect(queryAllTexts`.o_calendar_filter:eq(0) .o_calendar_filter_item`).toEqual([
"Mitchell Admin",
"Brandon Freeman",
"Marc Demo",
"Everybody's calendar",
]);
expect(queryAllTexts`.o_calendar_filter:eq(1) .o_calendar_filter_item`).toEqual([
"Brandon Freeman",
"Marc Demo",
]);
});
test(`section can collapse`, async () => {
await start({});
expect(`.o_calendar_filter:eq(0) .o_cw_filter_collapse_icon`).toHaveCount(1);
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item`).toHaveCount(4);
await contains(`.o_calendar_filter:eq(0) .o_cw_filter_label`).click();
await runAllTimers();
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item`).toHaveCount(0);
await contains(`.o_calendar_filter:eq(0) .o_cw_filter_label`).click();
await runAllTimers();
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item`).toHaveCount(4);
});
test(`section cannot collapse`, async () => {
await start({});
expect(`.o_calendar_filter:eq(1) .o_cw_filter_label > i`).toHaveCount(0);
expect(`.o_calendar_filter:eq(1)`).not.toHaveClass("o_calendar_filter-collapsed");
expect(`.o_calendar_filter:eq(1) .o_calendar_filter_item`).toHaveCount(2);
await contains(`.o_calendar_filter:eq(1) .o_cw_filter_label`).click();
await runAllTimers();
expect(`.o_calendar_filter:eq(1)`).not.toHaveClass("o_calendar_filter-collapsed");
expect(`.o_calendar_filter:eq(1) .o_calendar_filter_item`).toHaveCount(2);
});
test(`filters can have avatar`, async () => {
await start({});
expect(`.o_calendar_filter:eq(0) .o_cw_filter_avatar`).toHaveCount(4);
expect(`.o_calendar_filter:eq(0) img.o_cw_filter_avatar`).toHaveCount(3);
expect(`.o_calendar_filter:eq(0) i.o_cw_filter_avatar`).toHaveCount(1);
expect(
`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(0) .o_cw_filter_avatar`
).toHaveAttribute("data-src", "/web/image/res.partner/3/avatar_128");
expect(
`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(1) .o_cw_filter_avatar`
).toHaveAttribute("data-src", "/web/image/res.partner/4/avatar_128");
expect(
`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(2) .o_cw_filter_avatar`
).toHaveAttribute("data-src", "/web/image/res.partner/6/avatar_128");
});
test(`filters cannot have avatar`, async () => {
await start({});
expect(`.o_calendar_filter:eq(1) .o_calendar_filter_item`).toHaveCount(2);
expect(`.o_calendar_filter:eq(1) .o_cw_filter_avatar`).toHaveCount(0);
});
test(`filter can have remove button`, async () => {
await start({});
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item`).toHaveCount(4);
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item .o_remove`).toHaveCount(2);
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(0) .o_remove`).toHaveCount(0);
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(1) .o_remove`).toHaveCount(1);
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(2) .o_remove`).toHaveCount(1);
expect(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(3) .o_remove`).toHaveCount(0);
});
test(`click on remove button`, async () => {
await start({
model: {
...FAKE_MODEL,
unlinkFilter(fieldName, recordId) {
expect.step(`${fieldName} ${recordId}`);
},
},
});
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(1) .o_remove`);
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(2) .o_remove`);
expect.verifySteps(["partner_ids 1", "partner_ids 2"]);
});
test(`click on filter`, async () => {
await start({
model: {
...FAKE_MODEL,
updateFilters(fieldName, args) {
expect.step(`${fieldName} ${Object.keys(args)[0]} ${Object.values(args)[0]}`);
},
},
});
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(0) input`);
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(1) input`);
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(2) input`);
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(3) input`);
await click(`.o_calendar_filter:eq(0) .o_calendar_filter_item:eq(3) input`);
expect.verifySteps([
"partner_ids 3 false",
"partner_ids 4 false",
"partner_ids 6 true",
"partner_ids all true",
"partner_ids all false",
]);
});

View file

@ -0,0 +1,117 @@
import { expect, test } from "@odoo/hoot";
import { waitFor } from "@odoo/hoot-dom";
import {
contains,
getService,
mountWithCleanup,
preloadBundle,
} from "@web/../tests/web_test_helpers";
import { FAKE_MODEL } from "./calendar_test_helpers";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { CalendarQuickCreate } from "@web/views/calendar/quick_create/calendar_quick_create";
const FAKE_PROPS = {
model: FAKE_MODEL,
record: {},
editRecord() {},
};
/**
* @param {{
* props?: object;
* dialogOptions?: import("@web/core/dialog/dialog_service").DialogServiceInterfaceAddOptions;
* }} [params]
*/
async function start(params = {}) {
await mountWithCleanup(MainComponentsContainer);
getService("dialog").add(
CalendarQuickCreate,
{ ...FAKE_PROPS, ...params.props },
params.dialogOptions
);
await waitFor(`.o_dialog`);
}
preloadBundle("web.fullcalendar_lib");
test.tags("desktop");
test(`mount a CalendarQuickCreate`, async () => {
await start();
expect(`.o-calendar-quick-create`).toHaveCount(1);
expect(`.o_dialog .modal-sm`).toHaveCount(1);
expect(`.modal-title`).toHaveText("New Event");
expect(`input[name="title"]`).toBeFocused();
expect(`.o-calendar-quick-create--create-btn`).toHaveCount(1);
expect(`.o-calendar-quick-create--edit-btn`).toHaveCount(1);
expect(`.o-calendar-quick-create--cancel-btn`).toHaveCount(1);
});
test(`click on create button`, async () => {
await start({
props: {
model: { ...FAKE_MODEL, createRecord: () => expect.step("create") },
},
dialogOptions: { onClose: () => expect.step("close") },
});
await contains(`.o-calendar-quick-create--create-btn`).click();
expect.verifySteps([]);
expect(`input[name=title]`).toHaveClass("o_field_invalid");
});
test(`click on create button (with name)`, async () => {
await start({
props: {
model: {
...FAKE_MODEL,
createRecord(record) {
expect.step("create");
expect(record.title).toBe("TEST");
},
},
},
dialogOptions: { onClose: () => expect.step("close") },
});
await contains(`.o-calendar-quick-create--input`).edit("TEST", { confirm: "blur" });
await contains(`.o-calendar-quick-create--create-btn`).click();
expect.verifySteps(["create", "close"]);
});
test(`click on edit button`, async () => {
await start({
props: { editRecord: () => expect.step("edit") },
dialogOptions: { onClose: () => expect.step("close") },
});
await contains(`.o-calendar-quick-create--edit-btn`).click();
expect.verifySteps(["edit", "close"]);
});
test(`click on edit button (with name)`, async () => {
await start({
props: {
editRecord(record) {
expect.step("edit");
expect(record.title).toBe("TEST");
},
},
dialogOptions: { onClose: () => expect.step("close") },
});
await contains(`.o-calendar-quick-create--input`).edit("TEST", { confirm: "blur" });
await contains(`.o-calendar-quick-create--edit-btn`).click();
expect.verifySteps(["edit", "close"]);
});
test(`click on cancel button`, async () => {
await start({
dialogOptions: { onClose: () => expect.step("close") },
});
await contains(`.o-calendar-quick-create--cancel-btn`).click();
expect.verifySteps(["close"]);
});
test(`check default title`, async () => {
await start({
props: { title: "Example Title" },
});
expect(`.o-calendar-quick-create--input`).toHaveValue("Example Title");
});

View file

@ -0,0 +1,746 @@
import { click, drag, hover, queryFirst, queryRect } from "@odoo/hoot-dom";
import { advanceFrame, advanceTime, animationFrame } from "@odoo/hoot-mock";
import { EventBus } from "@odoo/owl";
import { contains, getMockEnv, swipeLeft, swipeRight } from "@web/../tests/web_test_helpers";
import { createElement } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
export const DEFAULT_DATE = luxon.DateTime.local(2021, 7, 16, 8, 0, 0, 0);
export const FAKE_RECORDS = {
1: {
id: 1,
title: "1 day, all day in July",
start: DEFAULT_DATE,
isAllDay: true,
end: DEFAULT_DATE,
},
2: {
id: 2,
title: "3 days, all day in July",
start: DEFAULT_DATE.plus({ days: 2 }),
isAllDay: true,
end: DEFAULT_DATE.plus({ days: 4 }),
},
3: {
id: 3,
title: "1 day, all day in June",
start: DEFAULT_DATE.plus({ months: -1 }),
isAllDay: true,
end: DEFAULT_DATE.plus({ months: -1 }),
},
4: {
id: 4,
title: "3 days, all day in June",
start: DEFAULT_DATE.plus({ months: -1, days: 2 }),
isAllDay: true,
end: DEFAULT_DATE.plus({ months: -1, days: 4 }),
},
5: {
id: 5,
title: "Over June and July",
start: DEFAULT_DATE.startOf("month").plus({ days: -2 }),
isAllDay: true,
end: DEFAULT_DATE.startOf("month").plus({ days: 2 }),
},
};
export const FAKE_FILTER_SECTIONS = [
{
label: "Attendees",
fieldName: "partner_ids",
avatar: {
model: "res.partner",
field: "avatar_128",
},
hasAvatar: true,
write: {
model: "filter_partner",
field: "partner_id",
},
canCollapse: true,
canAddFilter: true,
filters: [
{
type: "user",
label: "Mitchell Admin",
active: true,
value: 3,
colorIndex: 3,
recordId: null,
canRemove: false,
hasAvatar: true,
},
{
type: "all",
label: "Everybody's calendar",
active: false,
value: "all",
colorIndex: null,
recordId: null,
canRemove: false,
hasAvatar: false,
},
{
type: "record",
label: "Brandon Freeman",
active: true,
value: 4,
colorIndex: 4,
recordId: 1,
canRemove: true,
hasAvatar: true,
},
{
type: "record",
label: "Marc Demo",
active: false,
value: 6,
colorIndex: 6,
recordId: 2,
canRemove: true,
hasAvatar: true,
},
],
},
{
label: "Users",
fieldName: "user_id",
avatar: {
model: null,
field: null,
},
hasAvatar: false,
write: {
model: null,
field: null,
},
canCollapse: false,
canAddFilter: false,
filters: [
{
type: "record",
label: "Brandon Freeman",
active: false,
value: 1,
colorIndex: false,
recordId: null,
canRemove: true,
hasAvatar: true,
},
{
type: "record",
label: "Marc Demo",
active: false,
value: 2,
colorIndex: false,
recordId: null,
canRemove: true,
hasAvatar: true,
},
],
},
];
export const FAKE_FIELDS = {
id: { string: "Id", type: "integer" },
user_id: { string: "User", type: "many2one", relation: "user", default: -1 },
partner_id: {
string: "Partner",
type: "many2one",
relation: "partner",
related: "user_id.partner_id",
default: 1,
},
name: { string: "Name", type: "char" },
start_date: { string: "Start Date", type: "date" },
stop_date: { string: "Stop Date", type: "date" },
start: { string: "Start Datetime", type: "datetime" },
stop: { string: "Stop Datetime", type: "datetime" },
delay: { string: "Delay", type: "float" },
allday: { string: "Is All Day", type: "boolean" },
partner_ids: {
string: "Attendees",
type: "one2many",
relation: "partner",
default: [[6, 0, [1]]],
},
type: { string: "Type", type: "integer" },
event_type_id: { string: "Event Type", type: "many2one", relation: "event_type" },
color: { string: "Color", type: "integer", related: "event_type_id.color" },
};
export const FAKE_MODEL = {
bus: new EventBus(),
canCreate: true,
canDelete: true,
canEdit: true,
date: DEFAULT_DATE,
fieldMapping: {
date_start: "start_date",
date_stop: "stop_date",
date_delay: "delay",
all_day: "allday",
color: "color",
},
fieldNames: ["start_date", "stop_date", "color", "delay", "allday", "user_id"],
fields: FAKE_FIELDS,
filterSections: FAKE_FILTER_SECTIONS,
firstDayOfWeek: 0,
isDateHidden: false,
isTimeHidden: false,
hasAllDaySlot: true,
hasEditDialog: false,
quickCreate: false,
popoverFieldNodes: {
name: Field.parseFieldNode(
createElement("field", { name: "name" }),
{ event: { fields: FAKE_FIELDS } },
"event",
"calendar"
),
},
activeFields: {
name: {
context: "{}",
invisible: false,
readonly: false,
required: false,
onChange: false,
},
},
rangeEnd: DEFAULT_DATE.endOf("month"),
rangeStart: DEFAULT_DATE.startOf("month"),
records: FAKE_RECORDS,
resModel: "event",
scale: "month",
scales: ["day", "week", "month", "year"],
unusualDays: [],
load() {},
createFilter() {},
createRecord() {},
unlinkFilter() {},
unlinkRecord() {},
updateFilter() {},
updateRecord() {},
};
// DOM Utils
//------------------------------------------------------------------------------
/**
* @param {HTMLElement} element
*/
function instantScrollTo(element) {
element.scrollIntoView({ behavior: "instant", block: "center" });
}
/**
* @param {string} date
* @returns {HTMLElement}
*/
export function findAllDaySlot(date) {
return queryFirst(`.fc-daygrid-body .fc-day[data-date="${date}"]`);
}
/**
* @param {string} date
* @returns {HTMLElement}
*/
export function findDateCell(date) {
return queryFirst(`.fc-day[data-date="${date}"]`);
}
/**
* @param {number} eventId
* @returns {HTMLElement}
*/
export function findEvent(eventId) {
return queryFirst(`.o_event[data-event-id="${eventId}"]`);
}
/**
* @param {string} date
* @returns {HTMLElement}
*/
export function findDateColumn(date) {
return queryFirst(`.fc-col-header-cell.fc-day[data-date="${date}"]`);
}
/**
* @param {string} time
* @returns {HTMLElement}
*/
export function findTimeRow(time) {
return queryFirst(`.fc-timegrid-slot[data-time="${time}"]:eq(1)`);
}
/**
* @param {string} sectionName
* @returns {HTMLElement}
*/
export function findFilterPanelSection(sectionName) {
return queryFirst(`.o_calendar_filter[data-name="${sectionName}"]`);
}
/**
* @param {string} sectionName
* @param {string} filterValue
* @returns {HTMLElement}
*/
export function findFilterPanelFilter(sectionName, filterValue) {
const root = findFilterPanelSection(sectionName);
return queryFirst(`.o_calendar_filter_item[data-value="${filterValue}"]`, { root });
}
/**
* @param {string} sectionName
* @returns {HTMLElement}
*/
export function findFilterPanelSectionFilter(sectionName) {
const root = findFilterPanelSection(sectionName);
return queryFirst(`.o_calendar_filter_items_checkall`, { root });
}
/**
* @param {string} date
* @returns {Promise<void>}
*/
export async function pickDate(date) {
const day = date.split("-")[2];
const iDay = parseInt(day, 10) - 1;
await click(`.o_datetime_picker .o_date_item_cell:not(.o_out_of_range):eq(${iDay})`);
await animationFrame();
}
/**
* @param {string} date
* @returns {Promise<void>}
*/
export async function clickAllDaySlot(date) {
const slot = findAllDaySlot(date);
instantScrollTo(slot);
await click(slot);
await animationFrame();
}
/**
* @param {string} date
* @returns {Promise<void>}
*/
export async function clickDate(date) {
const cell = findDateCell(date);
instantScrollTo(cell);
await click(cell);
await advanceTime(500);
}
/**
* @param {number} eventId
* @returns {Promise<void>}
*/
export async function clickEvent(eventId) {
const eventEl = findEvent(eventId);
instantScrollTo(eventEl);
await click(eventEl);
await advanceTime(500); // wait for the popover to open (debounced)
}
export function expandCalendarView() {
// Expends Calendar view and FC too
let tmpElement = queryFirst(".fc");
do {
tmpElement = tmpElement.parentElement;
tmpElement.classList.add("h-100");
} while (!tmpElement.classList.contains("o_view_controller"));
}
/**
* @param {string} startDateTime
* @param {string} endDateTime
* @returns {Promise<void>}
*/
export async function selectTimeRange(startDateTime, endDateTime) {
const [startDate, startTime] = startDateTime.split(" ");
const [endDate, endTime] = endDateTime.split(" ");
// Try to display both rows on the screen before drag'n'drop.
const startHour = Number(startTime.slice(0, 2));
const endHour = Number(endTime.slice(0, 2));
const midHour = Math.floor((startHour + endHour) / 2);
const midTime = `${String(midHour).padStart(2, "0")}:00:00`;
instantScrollTo(
queryFirst(`.fc-timegrid-slot[data-time="${midTime}"]:eq(1)`, { visible: false })
);
const startColumnRect = queryRect(`.fc-col-header-cell.fc-day[data-date="${startDate}"]`);
const startRow = queryFirst(`.fc-timegrid-slot[data-time="${startTime}"]:eq(1)`);
const endColumnRect = queryRect(`.fc-col-header-cell.fc-day[data-date="${endDate}"]`);
const endRow = queryFirst(`.fc-timegrid-slot[data-time="${endTime}"]:eq(1)`);
const optionStart = {
relative: true,
position: { y: 1, x: startColumnRect.left },
};
await hover(startRow, optionStart);
await animationFrame();
const { drop } = await drag(startRow, optionStart);
await animationFrame();
await drop(endRow, {
position: { y: -1, x: endColumnRect.left },
relative: true,
});
await animationFrame();
}
/**
* @param {string} startDate
* @param {string} endDate
* @returns {Promise<void>}
*/
export async function selectDateRange(startDate, endDate) {
const startCell = findDateCell(startDate);
const endCell = findDateCell(endDate);
instantScrollTo(startCell);
await hover(startCell);
await animationFrame();
const { moveTo, drop } = await drag(startCell);
await animationFrame();
await moveTo(endCell);
await animationFrame();
await drop();
await animationFrame();
}
/**
* @param {string} startDate
* @param {string} endDate
* @returns {Promise<void>}
*/
export async function selectAllDayRange(startDate, endDate) {
const start = findAllDaySlot(startDate);
const end = findAllDaySlot(endDate);
instantScrollTo(start);
await hover(start);
await animationFrame();
const { drop } = await drag(start);
await animationFrame();
await drop(end);
await animationFrame();
}
export async function closeCwPopOver() {
if (getMockEnv().isSmall) {
await contains(`.oi-arrow-left`).click();
} else {
await contains(`.o_cw_popover_close`).click();
}
}
/**
* @param {number} eventId
* @param {string} date
* @param {{ disableDrop: boolean }} [options]
* @returns {Promise<void>}
*/
export async function moveEventToDate(eventId, date, options) {
const eventEl = findEvent(eventId);
const cell = findDateCell(date);
instantScrollTo(eventEl);
await hover(eventEl);
await animationFrame();
const { drop, moveTo } = await drag(eventEl);
await animationFrame();
await moveTo(cell);
await animationFrame();
if (!options?.disableDrop) {
await drop();
}
await animationFrame();
await animationFrame();
}
/**
* @param {number} eventId
* @param {string} dateTime
* @returns {Promise<void>}
*/
export async function moveEventToTime(eventId, dateTime) {
const eventEl = findEvent(eventId);
const [date, time] = dateTime.split(" ");
instantScrollTo(eventEl);
const row = findTimeRow(time);
const rowRect = queryRect(row);
const column = findDateColumn(date);
const columnRect = queryRect(column);
const { drop, moveTo } = await drag(eventEl, {
position: { y: 1 },
relative: true,
});
if (getMockEnv().isSmall) {
await advanceTime(500);
}
await animationFrame();
await moveTo(row, {
position: {
y: rowRect.y + 1,
x: columnRect.x + columnRect.width / 2,
},
});
await animationFrame();
await drop();
await advanceFrame(5);
}
export async function selectHourOnPicker(selectedValue) {
await contains(`.o_time_picker_select:eq(0)`).select(selectedValue);
await contains(".o_datetime_picker .o_apply").click();
}
/**
* @param {number} eventId
* @param {string} date
* @returns {Promise<void>}
*/
export async function moveEventToAllDaySlot(eventId, date) {
const eventEl = findEvent(eventId);
const slot = findAllDaySlot(date);
instantScrollTo(eventEl);
const columnRect = queryRect(eventEl);
const slotRect = queryRect(slot);
const { drop, moveTo } = await drag(eventEl, {
position: { y: 1 },
relative: true,
});
if (getMockEnv().isSmall) {
await advanceTime(500);
}
await animationFrame();
await moveTo(slot, {
position: {
x: columnRect.x + columnRect.width / 2,
y: slotRect.y,
},
});
await animationFrame();
await drop();
await advanceFrame(5);
}
/**
* @param {number} eventId
* @param {string} dateTime
* @returns {Promise<void>}
*/
export async function resizeEventToTime(eventId, dateTime) {
const eventEl = findEvent(eventId);
instantScrollTo(eventEl);
await hover(`.fc-event-main:first`, { root: eventEl });
await animationFrame();
const resizer = queryFirst(`.fc-event-resizer-end`, { root: eventEl });
Object.assign(resizer.style, {
display: "block",
height: "1px",
bottom: "0",
});
const [date, time] = dateTime.split(" ");
const row = findTimeRow(time);
const column = findDateColumn(date);
const columnRect = queryRect(column);
await (
await drag(resizer)
).drop(row, {
position: { x: columnRect.x, y: -1 },
relative: true,
});
await advanceTime(500);
}
/**
* @param {number} eventId
* @param {string} date
* @returns {Promise<void>}
*/
export async function resizeEventToDate(eventId, date) {
const eventEl = findEvent(eventId);
const slot = findAllDaySlot(date);
instantScrollTo(eventEl);
await hover(".fc-event-main", { root: eventEl });
await animationFrame();
// Show the resizer
const resizer = queryFirst(".fc-event-resizer-end", { root: eventEl });
Object.assign(resizer.style, { display: "block", height: "1px", bottom: "0" });
instantScrollTo(slot);
const rowRect = queryRect(resizer);
// Find the date cell and calculate the positions for dragging
const dateCell = findDateCell(date);
const columnRect = queryRect(dateCell);
// Perform the drag-and-drop operation
await hover(resizer, {
position: { x: 0 },
relative: true,
});
await animationFrame();
const { drop } = await drag(resizer);
await animationFrame();
await drop(dateCell, {
position: { y: rowRect.y - columnRect.y },
relative: true,
});
await advanceTime(500);
}
/**
* @param {"day" | "week" | "month" | "year"} scale
* @returns {Promise<void>}
*/
export async function changeScale(scale) {
await contains(`.o_view_scale_selector .scale_button_selection`).click();
await contains(`.o-dropdown--menu .o_scale_button_${scale}`).click();
}
export async function displayCalendarPanel() {
if (getMockEnv().isSmall) {
await contains(".o_calendar_container .o_other_calendar_panel").click();
}
}
export async function hideCalendarPanel() {
if (getMockEnv().isSmall) {
await contains(".o_calendar_container .o_other_calendar_panel").click();
}
}
/**
* @param {"prev" | "next"} direction
* @returns {Promise<void>}
*/
export async function navigate(direction) {
if (getMockEnv().isSmall) {
if (direction === "next") {
await swipeLeft(".o_calendar_widget");
} else {
await swipeRight(".o_calendar_widget");
}
await advanceFrame(16);
} else {
await contains(`.o_calendar_navigation_buttons .o_calendar_button_${direction}`).click();
}
}
/**
* @param {string} sectionName
* @param {string} filterValue
* @returns {Promise<void>}
*/
export async function toggleFilter(sectionName, filterValue) {
const otherCalendarPanel = queryFirst(".o_other_calendar_panel");
if (otherCalendarPanel) {
click(otherCalendarPanel);
await animationFrame();
}
const root = findFilterPanelFilter(sectionName, filterValue);
const input = queryFirst(`input`, { root });
instantScrollTo(input);
await click(input);
await animationFrame();
if (otherCalendarPanel) {
await click(otherCalendarPanel);
await animationFrame();
}
}
/**
* @param {string} sectionName
* @returns {Promise<void>}
*/
export async function toggleSectionFilter(sectionName) {
const otherCalendarPanel = queryFirst(".o_other_calendar_panel");
if (otherCalendarPanel) {
await click(otherCalendarPanel);
await animationFrame();
}
const root = findFilterPanelSectionFilter(sectionName);
const input = queryFirst(`input`, { root });
instantScrollTo(input);
await click(input);
await animationFrame();
if (otherCalendarPanel) {
await click(otherCalendarPanel);
await animationFrame();
}
}
/**
* @param {string} sectionName
* @param {string} filterValue
* @returns {Promise<void>}
*/
export async function removeFilter(sectionName, filterValue) {
const root = findFilterPanelFilter(sectionName, filterValue);
const button = queryFirst(`.o_remove`, { root });
instantScrollTo(button);
await click(button);
await animationFrame();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,115 @@
import { describe, expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { contains, mountWithCleanup, preloadBundle } from "@web/../tests/web_test_helpers";
import { DEFAULT_DATE, FAKE_MODEL } from "./calendar_test_helpers";
import { CalendarYearPopover } from "@web/views/calendar/calendar_year/calendar_year_popover";
describe.current.tags("desktop");
const FAKE_RECORDS = [
{
id: 1,
start: DEFAULT_DATE,
end: DEFAULT_DATE,
isAllDay: true,
title: "R1",
},
{
id: 2,
start: DEFAULT_DATE.set({ hours: 14 }),
end: DEFAULT_DATE.set({ hours: 16 }),
isAllDay: false,
title: "R2",
},
{
id: 3,
start: DEFAULT_DATE.minus({ days: 1 }),
end: DEFAULT_DATE.plus({ days: 1 }),
isAllDay: true,
title: "R3",
},
{
id: 4,
start: DEFAULT_DATE.minus({ days: 3 }),
end: DEFAULT_DATE.plus({ days: 1 }),
isAllDay: true,
title: "R4",
},
{
id: 5,
start: DEFAULT_DATE.minus({ days: 1 }),
end: DEFAULT_DATE.plus({ days: 3 }),
isAllDay: true,
title: "R5",
},
];
const FAKE_PROPS = {
model: FAKE_MODEL,
date: DEFAULT_DATE,
records: FAKE_RECORDS,
createRecord() {},
deleteRecord() {},
editRecord() {},
close() {},
};
async function start(props = {}) {
await mountWithCleanup(CalendarYearPopover, {
props: { ...FAKE_PROPS, ...props },
});
}
preloadBundle("web.fullcalendar_lib");
test(`canCreate is true`, async () => {
await start({
model: { ...FAKE_MODEL, canCreate: true },
});
expect(`.o_cw_popover_create`).toHaveCount(1);
});
test(`canCreate is false`, async () => {
await start({
model: { ...FAKE_MODEL, canCreate: false },
});
expect(`.o_cw_popover_create`).toHaveCount(0);
});
test(`click on create button`, async () => {
await start({
createRecord: () => expect.step("create"),
model: { ...FAKE_MODEL, canCreate: true },
});
expect(`.o_cw_popover_create`).toHaveCount(1);
await contains(`.o_cw_popover_create`).click();
expect.verifySteps(["create"]);
});
test(`group records`, async () => {
await start();
expect(`.o_cw_body > div`).toHaveCount(4);
expect(`.o_cw_body > a`).toHaveCount(1);
expect(queryAllTexts`.o_cw_body > div`).toEqual([
"July 16, 2021\nR1\n14:00\nR2",
"July 13-17, 2021\nR4",
"July 15-17, 2021\nR3",
"July 15-19, 2021\nR5",
]);
expect(`.o_cw_body`).toHaveText(
"July 16, 2021\nR1\n14:00\nR2\nJuly 13-17, 2021\nR4\nJuly 15-17, 2021\nR3\nJuly 15-19, 2021\nR5\n Create"
);
});
test(`click on record`, async () => {
await start({
records: [FAKE_RECORDS[3]],
editRecord: () => expect.step("edit"),
});
expect(`.o_cw_body a.o_cw_popover_link`).toHaveCount(1);
await contains(`.o_cw_body a.o_cw_popover_link`).click();
expect.verifySteps(["edit"]);
});

View file

@ -0,0 +1,153 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts, resize } from "@odoo/hoot-dom";
import { mockTimeZone, runAllTimers } from "@odoo/hoot-mock";
import {
mockService,
mountWithCleanup,
preloadBundle,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { FAKE_MODEL, clickDate, selectDateRange } from "./calendar_test_helpers";
import { CalendarYearRenderer } from "@web/views/calendar/calendar_year/calendar_year_renderer";
const FAKE_PROPS = {
model: FAKE_MODEL,
createRecord() {},
deleteRecord() {},
editRecord() {},
};
async function start(props = {}) {
await mountWithCleanup(CalendarYearRenderer, {
props: { ...FAKE_PROPS, ...props },
});
}
preloadBundle("web.fullcalendar_lib");
test(`mount a CalendarYearRenderer`, async () => {
await start();
expect(`.fc-month-container`).toHaveCount(12);
// check "title format"
expect(`.fc-toolbar-chunk:nth-child(2) .fc-toolbar-title`).toHaveCount(12);
expect(queryAllTexts`.fc-toolbar-chunk:nth-child(2) .fc-toolbar-title`).toEqual([
"January 2021",
"February 2021",
"March 2021",
"April 2021",
"May 2021",
"June 2021",
"July 2021",
"August 2021",
"September 2021",
"October 2021",
"November 2021",
"December 2021",
]);
// check day header format
expect(`.fc-month:eq(0) .fc-col-header-cell`).toHaveCount(7);
expect(queryAllTexts`.fc-month:eq(0) .fc-col-header-cell`).toEqual([
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]);
// check showNonCurrentDates
expect(`:not(.fc-day-disabled) > * > * > .fc-daygrid-day-number`).toHaveCount(365);
});
test.tags("desktop");
test(`display events`, async () => {
mockService("popover", () => ({
add(target, component, props) {
expect.step(`${props.date.toISODate()} ${props.records[0].title}`);
return () => {};
},
}));
await start({
createRecord(record) {
expect.step(`${record.start.toISODate()} allDay:${record.isAllDay} no event`);
},
});
await clickDate("2021-07-15");
expect.verifySteps(["2021-07-15 allDay:true no event"]);
await clickDate("2021-07-16");
expect.verifySteps(["2021-07-16 1 day, all day in July"]);
await clickDate("2021-07-17");
expect.verifySteps(["2021-07-17 allDay:true no event"]);
await clickDate("2021-07-18");
expect.verifySteps(["2021-07-18 3 days, all day in July"]);
await clickDate("2021-07-19");
expect.verifySteps(["2021-07-19 3 days, all day in July"]);
await clickDate("2021-07-20");
expect.verifySteps(["2021-07-20 3 days, all day in July"]);
await clickDate("2021-07-21");
expect.verifySteps(["2021-07-21 allDay:true no event"]);
await clickDate("2021-06-28");
expect.verifySteps(["2021-06-28 allDay:true no event"]);
await clickDate("2021-06-29");
expect.verifySteps(["2021-06-29 Over June and July"]);
await clickDate("2021-06-30");
expect.verifySteps(["2021-06-30 Over June and July"]);
await clickDate("2021-07-01");
expect.verifySteps(["2021-07-01 Over June and July"]);
await clickDate("2021-07-02");
expect.verifySteps(["2021-07-02 Over June and July"]);
await clickDate("2021-07-03");
expect.verifySteps(["2021-07-03 Over June and July"]);
await clickDate("2021-07-04");
expect.verifySteps(["2021-07-04 allDay:true no event"]);
});
test.tags("desktop");
test(`select a range of date`, async () => {
await start({
createRecord({ isAllDay, start, end }) {
expect.step("create");
expect(isAllDay).toBe(true);
expect(start.toSQL()).toBe("2021-07-02 00:00:00.000 +01:00");
expect(end.toSQL()).toBe("2021-07-05 00:00:00.000 +01:00");
},
});
await selectDateRange("2021-07-02", "2021-07-05");
expect.verifySteps(["create"]);
});
test(`display correct column header for days, independent of the timezone`, async () => {
// Regression test: when the system tz is somewhere in a negative GMT (in our example Alaska)
// the day headers of a months were incorrectly set. (S S M T W T F) instead of (S M T W T F S)
// if the first day of the week is Sunday.
mockTimeZone(-9);
await start();
expect(queryAllTexts`.fc-month:eq(0) .fc-col-header-cell`).toEqual([
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]);
});
test("resize callback is being called", async () => {
patchWithCleanup(CalendarYearRenderer.prototype, {
onWindowResize() {
expect.step("onWindowResize");
},
});
await start();
expect.verifySteps([]);
await resize({ height: 500 });
await runAllTimers();
expect.verifySteps(new Array(12).fill("onWindowResize")); // one for each FullCalendar instance
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,455 @@
import { describe, expect, test } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { createElement } from "@web/core/utils/xml";
import { FormCompiler } from "@web/views/form/form_compiler";
describe.current.tags("headless");
function compileTemplate(arch) {
const parser = new DOMParser();
const xml = parser.parseFromString(arch, "text/xml");
const compiler = new FormCompiler({ form: xml.documentElement });
return compiler.compile("form");
}
test("properly compile simple div", () => {
const arch = /*xml*/ `<form><div>lol</div></form>`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div>lol</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("label with empty string compiles to FormLabel with empty string", () => {
const arch = /*xml*/ `<form><field field_id="test" name="test"/><label for="test" string=""/></form>`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<Field id="'test'" name="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/>
<FormLabel id="'test'" fieldName="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" className="&quot;&quot;" string="\`\`" />
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile simple div with field", () => {
const arch = /*xml*/ `<form><div class="someClass">lol<field field_id="display_name" name="display_name"/></div></form>`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="someClass">
lol
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/>
</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile inner groups", () => {
const arch = /*xml*/ `
<form>
<group>
<group><field field_id="display_name" name="display_name"/></group>
<group><field field_id="charfield" name="charfield"/></group>
</group>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<OuterGroup>
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" isVisible="true" itemSpan="1">
<InnerGroup class="scope &amp;&amp; scope.className">
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" props="{id:'display_name',fieldName:'display_name',record:__comp__.props.record,string:__comp__.props.record.fields.display_name.string,fieldInfo:__comp__.props.archInfo.fieldNodes['display_name']}" Component="__comp__.constructor.components.FormLabel" subType="'item_component'" isVisible="true" itemSpan="2">
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew" class="scope &amp;&amp; scope.className"/>
</t>
</InnerGroup>
</t>
<t t-set-slot="item_1" type="'item'" sequence="1" t-slot-scope="scope" isVisible="true" itemSpan="1">
<InnerGroup class="scope &amp;&amp; scope.className">
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" props="{id:'charfield',fieldName:'charfield',record:__comp__.props.record,string:__comp__.props.record.fields.charfield.string,fieldInfo:__comp__.props.archInfo.fieldNodes['charfield']}" Component="__comp__.constructor.components.FormLabel" subType="'item_component'" isVisible="true" itemSpan="2">
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew" class="scope &amp;&amp; scope.className"/>
</t>
</InnerGroup>
</t>
</OuterGroup>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile attributes with nested forms", () => {
const arch = /*xml*/ `
<form>
<group>
<group>
<form>
<div>
<field field_id="test" name="test"/>
</div>
</form>
</group>
</group>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<OuterGroup>
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" isVisible="true" itemSpan="1">
<InnerGroup class="scope &amp;&amp; scope.className">
<t t-set-slot="item_0" type="'item'" sequence="0" t-slot-scope="scope" isVisible="true" itemSpan="1">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }} {{scope &amp;&amp; scope.className || &quot;&quot; }}">
<div><Field id="'test'" name="'test'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['test']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/></div>
</div>
</t>
</InnerGroup>
</t>
</OuterGroup>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile notebook", () => {
const arch = /*xml*/ `
<form>
<notebook>
<page name="p1" string="Page1"><field field_id="charfield" name="charfield"/></page>
<page name="p2" string="Page2"><field field_id="display_name" name="display_name"/></page>
</notebook>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<Notebook defaultPage="__comp__.props.record.isNew ? undefined : __comp__.props.activeNotebookPages[0]" onPageUpdate="(page) =&gt; __comp__.props.onNotebookPageChange(0, page)">
<t t-set-slot="page_1" title="\`Page1\`" name="\`p1\`" isVisible="true">
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/>
</t>
<t t-set-slot="page_2" title="\`Page2\`" name="\`p2\`" isVisible="true">
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/>
</t>
</Notebook>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile field without placeholder", () => {
const arch = /*xml*/ `
<form>
<field field_id="display_name" name="display_name" placeholder="e.g. Contact's Name or //someinfo..."/>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<Field id="'display_name'" name="'display_name'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['display_name']" readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile no sheet", () => {
const arch = /*xml*/ `
<form>
<header>someHeader</header>
<div>someDiv</div>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="o_form_statusbar position-relative d-flex justify-content-between mb-0 mb-md-2 pb-2 pb-md-0"><StatusBarButtons t-if="!__comp__.env.isSmall or __comp__.env.inDialog"/></div>
<div>someDiv</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile sheet", () => {
const arch = /*xml*/ `
<form>
<header>someHeader</header>
<div>someDiv</div>
<sheet>
<div>inside sheet</div>
</sheet>
<div>after sheet</div>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-flex d-print-block {{ __comp__.uiService.size &lt; 6 ? &quot;flex-column&quot; : &quot;flex-nowrap h-100&quot; }} {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="o_form_sheet_bg">
<div class="o_form_statusbar position-relative d-flex justify-content-between mb-0 mb-md-2 pb-2 pb-md-0"><StatusBarButtons t-if="!__comp__.env.isSmall or __comp__.env.inDialog"/></div>
<div>someDiv</div>
<div class="o_form_sheet position-relative">
<div>inside sheet</div>
</div>
</div>
<div>after sheet</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile buttonBox invisible in sheet", () => {
const arch = /*xml*/ `
<form>
<sheet>
<div class="oe_button_box" name="button_box" invisible="'display_name' == 'plop'">
<div>Hello</div>
</div>
</sheet>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer"
t-att-class="__comp__.props.class"
t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-flex d-print-block {{ __comp__.uiService.size &lt; 6 ? &quot;flex-column&quot; : &quot;flex-nowrap h-100&quot; }} {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}"
t-ref="compiled_view_root">
<div class="o_form_sheet_bg">
<div class="o_form_sheet position-relative">
</div>
</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile invisible", () => {
// cf python side: def transfer_node_to_modifiers
// modifiers' string are evaluated to their boolean or array form
// So the following arch may actually be written as:
// ```<form>
// <field name="display_name" invisible="1" />
// <div class="visible3" invisible="0"/>
// <div invisible="display_name == 'take'"/>
// </form>````
const arch = /*xml*/ `
<form>
<field field_id="display_name" name="display_name" invisible="True" />
<div class="visible3" invisible="False"/>
<div invisible="display_name == &quot;take&quot;"/>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="visible3"/>
<div t-if="!__comp__.evaluateBooleanExpr(&quot;display_name == \\&quot;take\\&quot;&quot;,__comp__.props.record.evalContextWithVirtualIds)"/>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("compile invisible containing string as domain", () => {
const arch = /*xml*/ `
<form>
<field name="display_name" invisible="True"/>
<div class="visible3" invisible="False"/>
<div invisible="display_name == 'take'"/>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="visible3" />
<div t-if="!__comp__.evaluateBooleanExpr(&quot;display_name == 'take'&quot;,__comp__.props.record.evalContextWithVirtualIds)"/>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile status bar with content", () => {
const arch = /*xml*/ `
<form>
<header><div>someDiv</div></header>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="o_form_statusbar position-relative d-flex justify-content-between mb-0 mb-md-2 pb-2 pb-md-0">
<StatusBarButtons t-if="!__comp__.env.isSmall or __comp__.env.inDialog">
<t t-set-slot="button_0" isVisible="true">
<div>someDiv</div>
</t>
</StatusBarButtons>
</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile status bar without content", () => {
const arch = /*xml*/ `
<form>
<header></header>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="o_form_statusbar position-relative d-flex justify-content-between mb-0 mb-md-2 pb-2 pb-md-0">
<StatusBarButtons t-if="!__comp__.env.isSmall or __comp__.env.inDialog"/>
</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile settings", () => {
const arch = /*xml*/ `
<form>
<setting help="this is bar"
documentation="/applications/technical/web/settings/this_is_a_test.html"
company_dependent="1">
<field field_id="bar" name="bar"/>
<label>label with content</label>
</setting>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<Setting title="\`\`"
help="\`this is bar\`"
companyDependent="true"
documentation="\`/applications/technical/web/settings/this_is_a_test.html\`"
record="__comp__.props.record"
fieldInfo="__comp__.props.archInfo.fieldNodes['bar']"
fieldName="\`bar\`"
fieldId="\`bar\`"
string="\`\`"
addLabel="true">
<t t-set-slot="fieldSlot">
<Field id="'bar'"
name="'bar'"
record="__comp__.props.record"
fieldInfo="__comp__.props.archInfo.fieldNodes['bar']"
readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"/>
</t>
<label>label with content</label>
</Setting>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("properly compile empty ButtonBox", () => {
const arch = /*xml*/ `
<form>
<sheet>
<div class="oe_button_box" name="button_box">
</div>
</sheet>
</form>
`;
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-flex d-print-block {{ __comp__.uiService.size &lt; 6 ? &quot;flex-column&quot; : &quot;flex-nowrap h-100&quot; }} {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<div class="o_form_sheet_bg">
<div class="o_form_sheet position-relative">
<div class="oe_button_box" name="button_box">
</div>
</div>
</div>
</div>
</t>
`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("invisible is correctly computed with another t-if", () => {
patchWithCleanup(FormCompiler.prototype, {
setup() {
super.setup();
this.compilers.push({
selector: "myNode",
fn: () => {
const div = createElement("div");
div.className = "myNode";
div.setAttribute("t-if", "myCondition or myOtherCondition");
return div;
},
});
},
});
const arch = `<myNode invisible="field == 'value'"/>`;
const expected = `<t t-translation="off"><div class="myNode" t-if="( myCondition or myOtherCondition ) and !__comp__.evaluateBooleanExpr(&quot;field == 'value'&quot;,__comp__.props.record.evalContextWithVirtualIds)" t-ref="compiled_view_root"/></t>`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("keep nosheet style if a sheet is part of a nested form", () => {
const arch = `
<form>
<field name="move_line_ids" field_id="move_line_ids">
<form>
<sheet/>
</form>
</field>
</form>`;
const expected = `<t t-translation="off">
<div
class="o_form_renderer o_form_nosheet"
t-att-class="__comp__.props.class"
t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}"
t-ref="compiled_view_root"
>
<Field
id="'move_line_ids'"
name="'move_line_ids'"
record="__comp__.props.record"
fieldInfo="__comp__.props.archInfo.fieldNodes['move_line_ids']"
readonly="__comp__.props.archInfo.activeActions?.edit === false and !__comp__.props.record.isNew"
/>
</div>
</t>`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});
test("form with t-translation directive", () => {
patchWithCleanup(console, { warn: (message) => expect.step(message) });
const arch = `
<form>
<div t-translation="off">Hello</div>
</form>`;
const expected = `<t t-translation="off">
<div
class="o_form_renderer o_form_nosheet"
t-att-class="__comp__.props.class"
t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}"
t-ref="compiled_view_root">
<div> Hello </div>
</div>
</t>`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
expect.verifySteps([]); // should no log any warning
});

View file

@ -0,0 +1,190 @@
import { expect, test } from "@odoo/hoot";
import { queryAllTexts } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { defineModels, fields, models, mountView, contains } from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
class Partner extends models.Model {
name = fields.Char();
charfield = fields.Char();
_records = [{ id: 1, name: "firstRecord", charfield: "content of charfield" }];
}
defineModels([Partner]);
test("compile form with modifiers", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<div invisible="display_name == uid">
<field name="charfield"/>
</div>
<field name="display_name" readonly="display_name == uid"/>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
expect(`.o_form_editable input`).toHaveCount(2);
});
test("compile notebook with modifiers", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<sheet>
<notebook>
<page name="p1" invisible="display_name == 'lol'"><field name="charfield"/></page>
<page name="p2"><field name="display_name"/></page>
</notebook>
</sheet>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
expect(queryAllTexts`.o_notebook_headers .nav-item`).toEqual(["p1", "p2"]);
});
test.tags("desktop");
test("compile header and buttons on desktop", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<header>
<button string="ActionButton" class="oe_highlight" name="action_button" type="object"/>
</header>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
expect(`.o_statusbar_buttons button[name=action_button]:contains(ActionButton)`).toHaveCount(1);
});
test.tags("mobile");
test("compile header and buttons on mobile", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<header>
<button string="ActionButton" class="oe_highlight" name="action_button" type="object"/>
</header>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
expect(
`.o-dropdown-item-unstyled-button button[name=action_button]:contains(ActionButton)`
).toHaveCount(1);
});
test("render field with placeholder", async () => {
registry.category("fields").add(
"char",
{
component: class CharField extends Component {
static props = ["*"];
static template = xml`<div/>`;
setup() {
expect.step("setup field component");
expect(this.props.placeholder).toBe("e.g. Contact's Name or //someinfo...");
}
},
extractProps: ({ attrs }) => ({ placeholder: attrs.placeholder }),
},
{ force: true }
);
Partner._views = {
form: /*xml*/ `
<form>
<field name="display_name" placeholder="e.g. Contact's Name or //someinfo..." />
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
expect.verifySteps(["setup field component"]);
});
test.tags("desktop");
test("compile a button with id on desktop", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<header>
<button id="action_button" string="ActionButton"/>
</header>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
expect(`button[id=action_button]`).toHaveCount(1);
});
test.tags("mobile");
test("compile a button with id on mobile", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<header>
<button id="action_button" string="ActionButton"/>
</header>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
expect(`button[id=action_button]`).toHaveCount(1);
});
test("compile a button with disabled", async () => {
Partner._views = {
form: /*xml*/ `
<form>
<button id="action_button" string="ActionButton" name="action_button" type="object" disabled="disabled"/>
</form>
`,
};
await mountView({
resModel: "partner",
type: "form",
resId: 1,
});
expect(`button[id=action_button]`).toHaveAttribute("disabled")
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,212 @@
import { before, expect } from "@odoo/hoot";
import { queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { contains, findComponent, preloadBundle } from "@web/../tests/web_test_helpers";
import { ensureArray } from "@web/core/utils/arrays";
import { patch } from "@web/core/utils/patch";
import { GraphController } from "@web/views/graph/graph_controller";
import { GraphRenderer } from "@web/views/graph/graph_renderer";
/**
* @typedef {"bar" | "line" | "pie"} GraphMode
*
* @typedef {import("@web/views/view").View} GraphView
*/
/**
* @param {GraphView} view
* @param {string | Iterable<string>} keys
* @param {Record<string, any> | Iterable<Record<string, any>>} expectedDatasets
*/
export const checkDatasets = (view, keys, expectedDatasets) => {
keys = ensureArray(keys);
const datasets = getChart(view).data.datasets;
const actualValues = [];
for (const dataset of datasets) {
const partialDataset = {};
for (const key of keys) {
partialDataset[key] = dataset[key];
}
actualValues.push(partialDataset);
}
expect(actualValues).toEqual(ensureArray(expectedDatasets));
};
/**
* @param {GraphView} view
* @param {GraphMode} mode
*/
export const checkModeIs = (view, mode) => {
expect(getGraphModelMetaData(view).mode).toBe(mode);
expect(getChart(view).config.type).toBe(mode);
expect(getModeButton(mode)).toHaveClass("active");
};
/**
* @param {GraphView} view
* @param {{ lines: { label: string, value: string }[], title?: string }} expectedTooltip
* @param {number} index
* @param {number} datasetIndex
*/
export const checkTooltip = (view, { title, lines }, index, datasetIndex = null) => {
// If the tooltip options are changed, this helper should change: we construct the dataPoints
// similarly to Chart.js according to the values set for the tooltips options 'mode' and 'intersect'.
const chart = getChart(view);
const { datasets } = chart.data;
const dataPoints = [];
for (let i = 0; i < datasets.length; i++) {
const dataset = datasets[i];
const raw = dataset.data[index];
if (raw !== undefined && (datasetIndex === null || datasetIndex === i)) {
dataPoints.push({
datasetIndex: i,
dataIndex: index,
raw,
});
}
}
chart.config.options.plugins.tooltip.external({
tooltip: { opacity: 1, x: 1, y: 1, dataPoints },
});
const lineLabels = [];
const lineValues = [];
for (const line of lines) {
lineLabels.push(line.label);
lineValues.push(String(line.value));
}
expect(`.o_graph_custom_tooltip`).toHaveCount(1);
expect(`table thead tr th.o_measure`).toHaveText(title || "Count");
expect(queryAllTexts(`table tbody tr td small.o_label`)).toEqual(lineLabels);
expect(queryAllTexts(`table tbody tr td.o_value`)).toEqual(lineValues);
};
/**
* @param {"asc" | "desc"} direction
*/
export const clickSort = (direction) => contains(`.fa-sort-amount-${direction}`).click();
/**
* @param {GraphView} view
*/
export const getChart = (view) => getGraphRenderer(view).chart;
/**
* @param {GraphView} view
*/
export const getGraphModelMetaData = (view) => getGraphModel(view).metaData;
/**
* @param {GraphMode} mode
*/
export const getModeButton = (mode) => queryOne`.o_graph_button[data-mode=${mode}]`;
/**
* @param {GraphView} view
*/
export const getScaleY = (view) => getChart(view).config.options.scales.y;
/**
* @param {GraphView} view
*/
export const getYAxisLabel = (view) => getChart(view).config.options.scales.y.title.text;
/**
* @param {GraphView} view
* @param {string | Iterable<string>} expectedLabels
*/
export function checkLabels(view, expectedLabels) {
expect(getChart(view).data.labels.map(String)).toEqual(ensureArray(expectedLabels));
}
/**
* @param {GraphView} view
* @param {string | Iterable<string>} expectedLabels
*/
export function checkYTicks(view, expectedLabels) {
const labels = getChart(view).scales.y.ticks.map((l) => l.label);
expect(labels).toEqual(expectedLabels);
}
/**
* @param {GraphView} view
* @param {string | Iterable<string>} expectedLabels
*/
export function checkLegend(view, expectedLabels) {
const chart = getChart(view);
const labels = chart.config.options.plugins.legend.labels
.generateLabels(chart)
.map((o) => o.text);
const expectedLabelsList = ensureArray(expectedLabels);
expect(labels).toEqual(expectedLabelsList, {
message: `Legend should be matching: ${expectedLabelsList
.map((label) => `"${label}"`)
.join(", ")}`,
});
}
/**
* @param {GraphView} view
*/
export async function clickOnDataset(view) {
const chart = getChart(view);
const point = chart.getDatasetMeta(0).data[0].getCenterPoint();
return contains(chart.canvas).click({ position: point, relative: true });
}
/**
* @param {GraphView} view
*/
export function getGraphController(view) {
return findComponent(view, (c) => c instanceof GraphController);
}
/**
* @param {GraphView} view
*/
export function getGraphModel(view) {
return getGraphController(view).model;
}
/**
* @param {GraphView} view
* @returns {GraphRenderer}
*/
export function getGraphRenderer(view) {
return findComponent(view, (c) => c instanceof GraphRenderer);
}
/**
* @param {GraphMode} mode
*/
export function selectMode(mode) {
return contains(getModeButton(mode)).click();
}
/**
* @param {GraphView} view
* @param {string} text
*/
export async function clickOnLegend(view, text) {
const chart = getChart(view);
const index = chart.legend.legendItems.findIndex((e) => e.text === text);
const { left, top, width, height } = chart.legend.legendHitBoxes[index];
const point = {
x: left + width / 2,
y: top + height / 2,
};
return contains(chart.canvas).click({ position: point, relative: true });
}
/**
* Helper to call at the start of a test suite using the Chart.js lib.
*
* It will:
* - pre-load the Chart.js lib before tests are run;
* - disable all animations in the lib.
*/
export function setupChartJsForTests() {
preloadBundle("web.chartjs_lib");
before(() => patch(Chart.defaults, { animation: false }));
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,186 @@
import { beforeEach, expect, test } from "@odoo/hoot";
import { KanbanArchParser } from "@web/views/kanban/kanban_arch_parser";
import { parseXML } from "@web/core/utils/xml";
import {
contains,
defineModels,
fields,
getDropdownMenu,
getKanbanRecord,
models,
mountView,
onRpc,
patchWithCleanup,
toggleKanbanRecordDropdown,
} from "../../web_test_helpers";
import { queryAll } from "@odoo/hoot-dom";
function parseArch(arch) {
const parser = new KanbanArchParser();
const xmlDoc = parseXML(arch);
return parser.parse(xmlDoc, { fake: { name: { string: "Name", type: "char" } } }, "fake");
}
class Category extends models.Model {
_name = "category";
name = fields.Char();
color = fields.Integer();
_records = [
{ id: 6, name: "gold", color: 2 },
{ id: 7, name: "silver", color: 5 },
];
}
defineModels([Category]);
// avoid "kanban-box" deprecation warnings in this suite, which defines legacy kanban on purpose
beforeEach(() => {
const originalConsoleWarn = console.warn;
patchWithCleanup(console, {
warn: (msg) => {
if (msg !== "'kanban-box' is deprecated, define a 'card' template instead") {
originalConsoleWarn(msg);
}
},
});
});
test("oe_kanban_colorpicker in kanban-menu and kanban-box", async () => {
const archInfo = parseArch(`
<kanban>
<templates>
<t t-name="kanban-menu">
<ul class="oe_kanban_colorpicker" data-field="kanban_menu_colorpicker" role="menu"/>
</t>
<t t-name="kanban-box"/>
</templates>
</kanban>
`);
expect(archInfo.colorField).toBe("kanban_menu_colorpicker", {
message: "colorField should be 'kanban_menu_colorpicker'",
});
const archInfo_1 = parseArch(`
<kanban>
<templates>
<t t-name="kanban-menu"/>
<t t-name="kanban-box">
<ul class="oe_kanban_colorpicker" data-field="kanban_box_color" role="menu"/>
</t>
</templates>
</kanban>
`);
expect(archInfo_1.colorField).toBe("kanban_box_color", {
message: "colorField should be 'kanban_box_color'",
});
});
test("kanban with colorpicker and node with color attribute", async () => {
Category._fields.colorpickerField = fields.Integer();
Category._records[0].colorpickerField = 3;
onRpc("web_save", ({ args }) => {
expect.step(`write-color-${args[1].colorpickerField}`);
});
await mountView({
type: "kanban",
resModel: "category",
arch: `
<kanban>
<field name="colorpickerField"/>
<templates>
<t t-name="kanban-menu">
<div class="oe_kanban_colorpicker" data-field="colorpickerField"/>
</t>
<t t-name="kanban-box">
<div color="colorpickerField">
<field name="name"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecord({ index: 0 })).toHaveClass("o_kanban_color_3");
await toggleKanbanRecordDropdown(0);
await contains(`.oe_kanban_colorpicker li[title="Raspberry"] a.oe_kanban_color_9`).click();
// should write on the color field
expect.verifySteps(["write-color-9"]);
expect(getKanbanRecord({ index: 0 })).toHaveClass("o_kanban_color_9");
});
test("edit the kanban color with the colorpicker", async () => {
Category._records[0].color = 12;
onRpc("web_save", ({ args }) => {
expect.step(`write-color-${args[1].color}`);
});
await mountView({
type: "kanban",
resModel: "category",
arch: `
<kanban>
<field name="color"/>
<templates>
<t t-name="kanban-menu">
<div class="oe_kanban_colorpicker"/>
</t>
<t t-name="kanban-box">
<div color="color">
<field name="name"/>
</div>
</t>
</templates>
</kanban>`,
});
await toggleKanbanRecordDropdown(0);
expect(".o_kanban_record.oe_kanban_color_12").toHaveCount(0, {
message: "no record should have the color 12",
});
expect(
queryAll(".oe_kanban_colorpicker", { root: getDropdownMenu(getKanbanRecord({ index: 0 })) })
).toHaveCount(1);
expect(
queryAll(".oe_kanban_colorpicker > *", {
root: getDropdownMenu(getKanbanRecord({ index: 0 })),
})
).toHaveCount(12, { message: "the color picker should have 12 children (the colors)" });
await contains(".oe_kanban_colorpicker a.oe_kanban_color_9").click();
// should write on the color field
expect.verifySteps(["write-color-9"]);
expect(getKanbanRecord({ index: 0 })).toHaveClass("o_kanban_color_9");
});
test("colorpicker doesn't appear when missing access rights", async () => {
await mountView({
type: "kanban",
resModel: "category",
arch: `
<kanban edit="0">
<field name="color"/>
<templates>
<t t-name="kanban-menu">
<div class="oe_kanban_colorpicker"/>
</t>
<t t-name="kanban-box">
<div color="color">
<field name="name"/>
</div>
</t>
</templates>
</kanban>`,
});
await toggleKanbanRecordDropdown(0);
expect(".oe_kanban_colorpicker").toHaveCount(0, {
message: "there shouldn't be a color picker",
});
});

View file

@ -0,0 +1,38 @@
import { expect, test } from "@odoo/hoot";
import { KanbanCompiler } from "@web/views/kanban/kanban_compiler";
function compileTemplate(arch) {
const parser = new DOMParser();
const xml = parser.parseFromString(arch, "text/xml");
const compiler = new KanbanCompiler({ kanban: xml.documentElement });
return compiler.compile("kanban");
}
test("bootstrap dropdowns with kanban_ignore_dropdown class should be left as is", async () => {
const arch = `
<kanban>
<templates>
<t t-name="card">
<button name="dropdown" class="kanban_ignore_dropdown" type="button" data-bs-toggle="dropdown">Boostrap dropdown</button>
<div class="dropdown-menu kanban_ignore_dropdown" role="menu">
<span>Dropdown content</span>
</div>
</t>
</templates>
</kanban>`;
const expected = `
<t t-translation="off">
<kanban>
<templates>
<t t-name="card">
<button name="dropdown" class="kanban_ignore_dropdown" type="button" data-bs-toggle="dropdown">Boostrap dropdown</button>
<div class="dropdown-menu kanban_ignore_dropdown" role="menu">
<span>Dropdown content</span>
</div>
</t>
</templates>
</kanban>
</t>`;
expect(compileTemplate(arch)).toHaveOuterHTML(expected);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,304 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import {
Component,
onWillStart,
reactive,
useChildSubEnv,
useState,
useSubEnv,
xml,
} from "@odoo/owl";
import {
defineModels,
fields,
makeMockEnv,
models,
mountWithCleanup,
mountWithSearch,
} from "@web/../tests/web_test_helpers";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
import { SearchModel } from "@web/search/search_model";
import { getDefaultConfig } from "@web/views/view";
class Foo extends models.Model {
aaa = fields.Selection({
selection: [
["a", "A"],
["b", "B"],
],
});
_views = {
search: `
<search>
<searchpanel>
<field name="aaa"/>
</searchpanel>
</search>
`,
};
}
defineModels([Foo]);
test(`Simple rendering`, async () => {
class ToyComponent extends Component {
static props = ["*"];
static template = xml`
<Layout className="'o_view_sample_data'" display="props.display">
<div class="toy_content"/>
</Layout>
`;
static components = { Layout };
}
await mountWithCleanup(ToyComponent, {
env: await makeMockEnv({ config: {} }),
});
expect(`.o_view_sample_data`).toHaveCount(1);
expect(`.o_control_panel`).toHaveCount(0);
expect(`.o_component_with_search_panel`).toHaveCount(0);
expect(`.o_search_panel`).toHaveCount(0);
expect(`.o_content > .toy_content`).toHaveCount(1);
});
test(`Simple rendering: with search`, async () => {
class ToyComponent extends Component {
static props = ["*"];
static template = xml`
<Layout display="props.display">
<t t-set-slot="layout-actions">
<div class="toy_search_bar"/>
</t>
<div class="toy_content"/>
</Layout>
`;
static components = { Layout };
}
await mountWithSearch(ToyComponent, {
resModel: "foo",
searchViewId: false,
});
expect(`.o_control_panel .o_control_panel_actions .toy_search_bar`).toHaveCount(1);
expect(`.o_component_with_search_panel .o_search_panel`).toHaveCount(1);
expect(`.o_cp_searchview`).toHaveCount(0);
expect(`.o_content > .toy_content`).toHaveCount(1);
});
test(`Rendering with default ControlPanel and SearchPanel`, async () => {
class ToyComponent extends Component {
static props = ["*"];
static template = xml`
<Layout className="'o_view_sample_data'" display="{ controlPanel: {}, searchPanel: true }">
<div class="toy_content"/>
</Layout>
`;
static components = { Layout };
setup() {
this.searchModel = new SearchModel(this.env, {
orm: useService("orm"),
view: useService("view"),
});
useSubEnv({ searchModel: this.searchModel });
onWillStart(async () => {
await this.searchModel.load({ resModel: "foo" });
});
}
}
await mountWithCleanup(ToyComponent, {
env: await makeMockEnv({
config: {
breadcrumbs: getDefaultConfig().breadcrumbs,
},
}),
});
expect(`.o_search_panel`).toHaveCount(1);
expect(`.o_control_panel`).toHaveCount(1);
expect(`.o_breadcrumb`).toHaveCount(1);
expect(`.o_component_with_search_panel`).toHaveCount(1);
expect(`.o_content > .toy_content`).toHaveCount(1);
});
test(`Nested layouts`, async () => {
// Component C: bottom (no control panel)
class ToyC extends Component {
static props = ["*"];
static template = xml`
<Layout className="'toy_c'" display="display">
<div class="toy_c_content"/>
</Layout>
`;
static components = { Layout };
get display() {
return {
controlPanel: false,
searchPanel: true,
};
}
}
// Component B: center (with custom search panel)
class SearchPanel extends Component {
static props = ["*"];
static template = xml`<div class="o_toy_search_panel"/>`;
}
class ToyB extends Component {
static props = ["*"];
static template = xml`
<Layout className="'toy_b'" display="props.display">
<t t-set-slot="layout-actions">
<div class="toy_b_breadcrumbs"/>
</t>
<ToyC/>
</Layout>
`;
static components = { Layout, ToyC };
setup() {
useChildSubEnv({
config: {
...getDefaultConfig(),
SearchPanel,
},
});
}
}
// Component A: top
class ToyA extends Component {
static props = ["*"];
static template = xml`
<Layout className="'toy_a'" display="props.display">
<t t-set-slot="layout-actions">
<div class="toy_a_search"/>
</t>
<ToyB display="props.display"/>
</Layout>
`;
static components = { Layout, ToyB };
}
await mountWithSearch(ToyA, {
resModel: "foo",
searchViewId: false,
});
expect(`.o_content.toy_a .o_content.toy_b .o_content.toy_c`).toHaveCount(1);
expect(".o_control_panel").toHaveCount(2);
expect(".o_content.o_component_with_search_panel").toHaveCount(3);
expect(`.o_search_panel`).toHaveCount(1);
expect(".o_toy_search_panel").toHaveCount(2);
expect(`.toy_a_search`).toHaveCount(1);
expect(`.toy_b_breadcrumbs`).toHaveCount(1);
expect(`.toy_c_content`).toHaveCount(1);
});
test(`Custom control panel`, async () => {
class ToyComponent extends Component {
static props = ["*"];
static template = xml`
<Layout display="props.display">
<div class="o_toy_content"/>
</Layout>
`;
static components = { Layout };
}
class ControlPanel extends Component {
static props = ["*"];
static template = xml`<div class="o_toy_search_panel"/>`;
}
await mountWithSearch(
ToyComponent,
{
resModel: "foo",
searchViewId: false,
},
{ ControlPanel }
);
expect(`.o_toy_content`).toHaveCount(1);
expect(`.o_toy_search_panel`).toHaveCount(1);
expect(`.o_control_panel`).toHaveCount(0);
});
test(`Custom search panel`, async () => {
class ToyComponent extends Component {
static props = ["*"];
static template = xml`
<Layout display="props.display">
<div class="o_toy_content"/>
</Layout>
`;
static components = { Layout };
}
class SearchPanel extends Component {
static props = ["*"];
static template = xml`<div class="o_toy_search_panel"/>`;
}
await mountWithSearch(
ToyComponent,
{
resModel: "foo",
searchViewId: false,
},
{ SearchPanel }
);
expect(`.o_toy_content`).toHaveCount(1);
expect(`.o_toy_search_panel`).toHaveCount(1);
expect(`.o_search_panel`).toHaveCount(0);
});
test(`Simple rendering: with dynamically displayed search`, async () => {
const state = reactive({ displayLayoutActions: true });
class ToyComponent extends Component {
static props = ["*"];
static template = xml`
<Layout display="display">
<t t-set-slot="layout-actions">
<div class="toy_search_bar"/>
</t>
<div class="toy_content"/>
</Layout>
`;
static components = { Layout };
setup() {
this.state = useState(state);
}
get display() {
return {
...this.props.display,
controlPanel: {
...this.props.display.controlPanel,
layoutActions: this.state.displayLayoutActions,
},
};
}
}
await mountWithSearch(ToyComponent, {
resModel: "foo",
searchViewId: false,
});
expect(`.o_control_panel .o_control_panel_actions .toy_search_bar`).toHaveCount(1);
expect(`.o_component_with_search_panel .o_search_panel`).toHaveCount(1);
expect(`.o_cp_searchview`).toHaveCount(0);
expect(`.o_content > .toy_content`).toHaveCount(1);
state.displayLayoutActions = false;
await animationFrame();
expect(`.o_control_panel .o_control_panel_actions .toy_search_bar`).toHaveCount(0);
expect(`.o_component_with_search_panel .o_search_panel`).toHaveCount(1);
expect(`.o_cp_searchview`).toHaveCount(0);
expect(`.o_content > .toy_content`).toHaveCount(1);
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,115 @@
import { expect, test } from "@odoo/hoot";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Component, useRef, xml } from "@odoo/owl";
import { ViewButton } from "@web/views/view_button/view_button";
import { useViewButtons } from "@web/views/view_button/view_button_hook";
import { registry } from "@web/core/registry";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
test("action can be prevented", async () => {
registry.category("services").add(
"action",
{
start() {
return {
doActionButton() {
expect.step("doActionButton");
},
};
},
},
{ force: true }
);
let executeInHook;
let executeInHandler;
class MyComponent extends Component {
static template = xml`<div t-ref="root" t-on-click="onClick" class="myComponent">Some text</div>`;
static props = ["*"];
setup() {
const rootRef = useRef("root");
useViewButtons(rootRef, {
beforeExecuteAction: () => {
expect.step("beforeExecuteAction in hook");
return executeInHook;
},
});
}
onClick() {
const getResParams = () => ({
resIds: [3],
resId: 3,
});
const clickParams = {};
const beforeExecute = () => {
expect.step("beforeExecuteAction on handler");
return executeInHandler;
};
this.env.onClickViewButton({ beforeExecute, getResParams, clickParams });
}
}
await mountWithCleanup(MyComponent);
await contains(".myComponent").click();
expect.verifySteps([
"beforeExecuteAction on handler",
"beforeExecuteAction in hook",
"doActionButton",
]);
executeInHook = false;
await contains(".myComponent").click();
expect.verifySteps(["beforeExecuteAction on handler", "beforeExecuteAction in hook"]);
executeInHandler = false;
await contains(".myComponent").click();
expect.verifySteps(["beforeExecuteAction on handler"]);
});
test("ViewButton clicked in Dropdown close the Dropdown", async () => {
registry.category("services").add(
"action",
{
start() {
return {
doActionButton() {
expect.step("doActionButton");
},
};
},
},
{ force: true }
);
class MyComponent extends Component {
static components = { Dropdown, DropdownItem, ViewButton };
static template = xml`
<div t-ref="root" class="myComponent">
<Dropdown>
<button>dropdown</button>
<DropdownItem>
<ViewButton tag="'a'" clickParams="{ type:'action' }" string="'coucou'" record="{ resId: 1 }" />
</DropdownItem>
</Dropdown>
</div>
`;
static props = ["*"];
setup() {
const rootRef = useRef("root");
useViewButtons(rootRef);
}
}
await mountWithCleanup(MyComponent);
await contains(".dropdown-toggle").click();
expect(".dropdown-menu").toHaveCount(1);
await contains("a[type=action]").click();
expect.verifySteps(["doActionButton"]);
expect(".dropdown-menu").toHaveCount(0);
});

View file

@ -0,0 +1,143 @@
import { expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useState, xml } from "@odoo/owl";
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ViewScaleSelector } from "@web/views/view_components/view_scale_selector";
test("basic ViewScaleSelector component usage", async () => {
class Parent extends Component {
static components = { ViewScaleSelector };
static template = xml`<ViewScaleSelector t-props="compProps" />`;
static props = ["*"];
setup() {
this.state = useState({
scale: "week",
});
}
get compProps() {
return {
scales: {
day: {
description: "Daily",
},
week: {
description: "Weekly",
hotkey: "o",
},
year: {
description: "Yearly",
},
},
isWeekendVisible: true,
setScale: (scale) => {
this.state.scale = scale;
expect.step(scale);
},
toggleWeekendVisibility: () => {
expect.step("toggleWeekendVisibility");
},
currentScale: this.state.scale,
};
}
}
await mountWithCleanup(Parent);
expect(".o_view_scale_selector").toHaveCount(1);
expect.verifySteps([]);
expect(".o_view_scale_selector").toHaveText("Weekly");
expect(".scale_button_selection").toHaveAttribute("data-hotkey", "v");
await click(".scale_button_selection");
await animationFrame();
expect(".o-dropdown--menu").toHaveCount(1);
expect(".o-dropdown--menu .active:first").toHaveText("Weekly", {
message: "the active option is selected",
});
expect(".o-dropdown--menu span:nth-child(2)").toHaveAttribute("data-hotkey", "o", {
message: "'week' scale has the right hotkey",
});
await click(".o_scale_button_day");
await animationFrame();
expect.verifySteps(["day"]);
expect(".o_view_scale_selector").toHaveText("Daily");
await click(".scale_button_selection");
expect(".dropdown-item:last:interactive").not.toHaveCount();
await contains(".dropdown-item:contains(Yearly)").click();
await click(".scale_button_selection");
await contains(".dropdown-item:last").click();
expect.verifySteps(["year", "toggleWeekendVisibility"]);
});
test("ViewScaleSelector with only one scale available", async () => {
class Parent extends Component {
static components = { ViewScaleSelector };
static template = xml`<ViewScaleSelector t-props="compProps" />`;
static props = ["*"];
setup() {
this.state = useState({
scale: "day",
});
}
get compProps() {
return {
scales: {
day: {
description: "Daily",
},
},
setScale: () => {},
isWeekendVisible: false,
toggleWeekendVisibility: () => {},
currentScale: this.state.scale,
};
}
}
await mountWithCleanup(Parent);
expect(".o_view_scale_selector").toHaveCount(0);
});
test("ViewScaleSelector show weekends button is disabled when scale is day", async () => {
class Parent extends Component {
static components = { ViewScaleSelector };
static template = xml`<ViewScaleSelector t-props="compProps"/>`;
static props = ["*"];
setup() {
this.state = useState({
scale: "day",
});
}
get compProps() {
return {
scales: {
day: {
description: "Daily",
},
week: {
description: "Weekly",
hotkey: "o",
},
year: {
description: "Yearly",
},
},
setScale: (key) => (this.state.scale = key),
isWeekendVisible: false,
toggleWeekendVisibility: () => {},
currentScale: this.state.scale,
};
}
}
await mountWithCleanup(Parent);
expect(".o_view_scale_selector").toHaveCount(1);
await click(".scale_button_selection");
await animationFrame();
expect(".o_show_weekends").toHaveClass("disabled");
await click(".dropdown-item:nth-child(2)");
await animationFrame();
await click(".scale_button_selection");
await animationFrame();
expect(".o_show_weekends").not.toHaveClass("disabled");
});

View file

@ -0,0 +1,570 @@
import { expect, test } from "@odoo/hoot";
import { click, edit, press, queryAllTexts, runAllTimers, waitFor } from "@odoo/hoot-dom";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import {
clickSave,
contains,
defineModels,
fieldInput,
fields,
getService,
mockService,
models,
mountView,
mountViewInDialog,
mountWithCleanup,
onRpc,
} from "@web/../tests/web_test_helpers";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { WebClient } from "@web/webclient/webclient";
class Partner extends models.Model {
name = fields.Char({ string: "Displayed name" });
foo = fields.Char({ string: "Foo" });
bar = fields.Boolean({ string: "Bar" });
instrument = fields.Many2one({
string: "Instruments",
relation: "instrument",
});
method1() {}
method2() {}
_records = [
{ id: 1, foo: "blip", name: "blipblip", bar: true },
{ id: 2, foo: "ta tata ta ta", name: "macgyver", bar: false },
{ id: 3, foo: "piou piou", name: "Jack O'Neill", bar: true },
];
}
class Instrument extends models.Model {
name = fields.Char({ string: "name" });
badassery = fields.Many2many({
string: "level",
relation: "badassery",
domain: [["level", "=", "Awsome"]],
});
}
class Badassery extends models.Model {
level = fields.Char({ string: "level" });
_records = [{ id: 1, level: "Awsome" }];
}
class Product extends models.Model {
name = fields.Char({ string: "name" });
partner = fields.One2many({ string: "Doors", relation: "partner" });
_records = [{ id: 1, name: "The end" }];
}
defineModels([Partner, Instrument, Badassery, Product]);
test("formviewdialog buttons in footer are positioned properly", async () => {
Partner._views["form"] = /* xml */ `
<form string="Partner">
<sheet>
<group><field name="foo"/></group >
<footer><button string="Custom Button" type="object" class="btn-primary"/></footer>
</sheet>
</form>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
});
await animationFrame();
expect(".modal-body button").toHaveCount(0, { message: "should not have any button in body" });
expect(".modal-footer button:not(.d-none)").toHaveCount(1, {
message: "should have only one button in footer",
});
});
test("modifiers are considered on multiple <footer/> tags", async () => {
Partner._views["form"] = /* xml */ `
<form>
<field name="bar"/>
<footer invisible="not bar">
<button>Hello</button>
<button>World</button>
</footer>
<footer invisible="bar">
<button>Foo</button>
</footer>
</form>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
});
await animationFrame();
expect(queryAllTexts(".modal-footer button:not(.d-none)")).toEqual(["Hello", "World"], {
message: "only the first button section should be visible",
});
await click(".o_field_boolean input");
await animationFrame();
expect(queryAllTexts(".modal-footer button:not(.d-none)")).toEqual(["Foo"], {
message: "only the second button section should be visible",
});
});
test("formviewdialog buttons in footer are not duplicated", async () => {
Partner._fields.poney_ids = fields.One2many({
string: "Poneys",
relation: "partner",
});
Partner._records[0].poney_ids = [];
Partner._views["form"] = /* xml */ `
<form string="Partner">
<field name="poney_ids"><list editable="top"><field name="name"/></list></field>
<footer><button string="Custom Button" type="object" class="my_button"/></footer>
</form>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
});
await animationFrame();
expect(".modal").toHaveCount(1);
expect(".modal button.my_button").toHaveCount(1, { message: "should have 1 buttons in modal" });
await click(".o_field_x2many_list_row_add a");
await animationFrame();
await press("escape");
await animationFrame();
expect(".modal").toHaveCount(1);
expect(".modal button.btn-primary").toHaveCount(1, {
message: "should still have 1 buttons in modal",
});
});
test.tags("desktop");
test("Form dialog and subview with _view_ref contexts", async () => {
expect.assertions(2);
Instrument._records = [{ id: 1, name: "Tromblon", badassery: [1] }];
Partner._records[0].instrument = 1;
// This is an old test, written before "get_views" (formerly "load_views") automatically
// inlines x2many subviews. As the purpose of this test is to assert that the js fetches
// the correct sub view when it is not inline (which can still happen in nested form views),
// we bypass the inline mecanism of "get_views" by setting widget="many2many" on the field.
Instrument._views["form"] = /* xml */ `
<form>
<field name="name"/>
<field name="badassery" widget="many2many" context="{'list_view_ref': 'some_other_tree_view'}"/>
</form>
`;
Badassery._views["list"] = /* xml */ `<list><field name="level"/></list>`;
onRpc(({ kwargs, method, model }) => {
if (method === "get_formview_id") {
return false;
}
if (method === "get_views" && model === "instrument") {
expect(kwargs.context).toEqual(
{
allowed_company_ids: [1],
lang: "en",
list_view_ref: "some_tree_view",
tz: "taht",
uid: 7,
},
{
message:
"1 The correct _view_ref should have been sent to the server, first time",
}
);
}
if (method === "get_views" && model === "badassery") {
expect(kwargs.context).toEqual(
{
allowed_company_ids: [1],
lang: "en",
list_view_ref: "some_other_tree_view",
tz: "taht",
uid: 7,
},
{
message:
"2 The correct _view_ref should have been sent to the server for the subview",
}
);
}
});
await mountViewInDialog({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="name"/>
<field name="instrument" context="{'list_view_ref': 'some_tree_view'}"/>
</form>
`,
});
await click('.o_field_widget[name="instrument"] button.o_external_button');
await animationFrame();
});
test("click on view buttons in a FormViewDialog", async () => {
Partner._views["form"] = /* xml */ `
<form>
<field name="foo"/>
<button name="method1" type="object" string="Button 1" class="btn1"/>
<button name="method2" type="object" string="Button 2" class="btn2" close="1"/>
</form>
`;
onRpc(({ method }) => expect.step(method));
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
});
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect(".o_dialog .o_form_view button").toHaveCount(2);
expect.verifySteps(["get_views", "web_read"]);
await click(".o_dialog .o_form_view .btn1");
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect.verifySteps(["method1", "web_read"]); // should re-read the record
await click(".o_dialog .o_form_view .btn2");
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(0);
expect.verifySteps(["method2"]); // should not read as we closed
});
test("formviewdialog is not closed when button handlers return a rejected promise", async () => {
Partner._views["form"] = /* xml */ `
<form string="Partner">
<sheet><group><field name="foo"/></group></sheet>
</form>
`;
let reject;
onRpc("web_save", () => {
if (reject) {
return Promise.reject("rejected");
}
});
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
context: { answer: 42 },
});
await animationFrame();
expect(".modal-body button").not.toHaveCount();
expect(".modal-footer button:visible").toHaveCount(2);
// Click "save" inside the dialog (with rejection)
expect.errors(1);
reject = true;
await clickSave();
expect.verifyErrors(["rejected"]);
// Close error modal
await click(waitFor(".o_error_dialog .btn:contains(Close)"));
// Click "save" inside the dialog (without rejection)
reject = false;
await clickSave();
expect(".modal").not.toHaveCount();
});
test("FormViewDialog with remove button", async () => {
Partner._views["form"] = /* xml */ `<form><field name="foo"/></form>`;
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
removeRecord: () => expect.step("remove"),
});
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect(".o_dialog .modal-footer .o_form_button_remove").toHaveCount(1);
await click(".o_dialog .modal-footer .o_form_button_remove");
await animationFrame();
expect.verifySteps(["remove"]);
expect(".o_dialog .o_form_view").toHaveCount(0);
});
test("Buttons are set as disabled on click", async () => {
Partner._views["form"] = /* xml */ `
<form string="Partner">
<sheet>
<group>
<field name="name"/>
</group>
</sheet>
</form>
`;
const def = new Deferred();
onRpc("web_save", async () => await def);
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
});
await animationFrame();
await click(".o_dialog .o_content .o_field_char .o_input");
await edit("test");
await animationFrame();
await clickSave();
expect(".o_dialog .modal-footer .o_form_button_save").toHaveAttribute("disabled", "1");
def.resolve();
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(0);
});
test("FormViewDialog with discard button", async () => {
Partner._views["form"] = /* xml */ `<form><field name="foo"/></form>`;
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
onRecordDiscarded: () => expect.step("discard"),
});
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect(".o_dialog .modal-footer .o_form_button_cancel").toHaveCount(1);
await click(".o_dialog .modal-footer .o_form_button_cancel");
await animationFrame();
expect.verifySteps(["discard"]);
expect(".o_dialog .o_form_view").toHaveCount(0);
});
test("Save a FormViewDialog when a required field is empty don't close the dialog", async () => {
Partner._views["form"] = /* xml */ `
<form string="Partner">
<sheet>
<group><field name="foo" required="1"/></group>
</sheet>
<footer>
<button name="save" special="save" class="btn-primary"/>
</footer>
</form>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
context: { answer: 42 },
});
await animationFrame();
await click('.modal button[name="save"]');
await animationFrame();
expect(".modal").toHaveCount(1, { message: "modal should still be opened" });
await click("[name='foo'] input");
await edit("new");
await click('.modal button[name="save"]');
await animationFrame();
expect(".modal").toHaveCount(0, { message: "modal should be closed" });
});
test("new record has an expand button", async () => {
Partner._views["form"] = /* xml */ `<form><field name="foo"/></form>`;
Partner._records = [];
onRpc("web_save", () => {
expect.step("save");
});
mockService("action", {
doAction(actionRequest) {
expect.step([
actionRequest.res_id,
actionRequest.res_model,
actionRequest.type,
actionRequest.views,
]);
},
});
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
});
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect(".o_dialog .modal-header .o_expand_button").toHaveCount(1);
await fieldInput("foo").edit("new");
await click(".o_dialog .modal-header .o_expand_button");
await animationFrame();
expect.verifySteps(["save", [1, "partner", "ir.actions.act_window", [[false, "form"]]]]);
});
test("existing record has an expand button", async () => {
Partner._views["form"] = /* xml */ `<form><field name="foo"/></form>`;
onRpc("web_save", () => {
expect.step("save");
});
mockService("action", {
doAction(actionRequest) {
expect.step([
actionRequest.res_id,
actionRequest.res_model,
actionRequest.type,
actionRequest.views,
actionRequest.context,
]);
},
});
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
context: {key: "val"}
});
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect(".o_dialog .modal-header .o_expand_button").toHaveCount(1);
await fieldInput("foo").edit("hola");
await click(".o_dialog .modal-header .o_expand_button");
await animationFrame();
expect.verifySteps([
"save",
[
1,
"partner",
"ir.actions.act_window",
[[false, "form"]],
{
key: "val",
},
],
]);
});
test("expand button with save and new", async () => {
Instrument._views["form"] = /* xml */ `<form><field name="name"/></form>`;
Instrument._records = [{ id: 1, name: "Violon" }];
onRpc("web_save", () => {
expect.step("save");
});
mockService("action", {
doAction(actionRequest) {
expect.step([
actionRequest.res_id,
actionRequest.res_model,
actionRequest.type,
actionRequest.views,
]);
},
});
await mountWithCleanup(WebClient);
getService("dialog").add(FormViewDialog, {
resModel: "instrument",
resId: 1,
isToMany: true,
});
await animationFrame();
expect(".o_dialog .o_form_view").toHaveCount(1);
expect(".o_dialog .modal-header .o_expand_button").toHaveCount(1);
await fieldInput("name").edit("Violoncelle");
await click(".o_dialog .modal-footer .o_form_button_save_new");
await animationFrame();
await fieldInput("name").edit("Flute");
await click(".o_dialog .modal-header .o_expand_button");
await animationFrame();
expect.verifySteps([
"save",
"save",
[2, "instrument", "ir.actions.act_window", [[false, "form"]]],
]);
});
test.tags("desktop");
test("close dialog with escape after modifying a field with onchange (no blur)", async () => {
Partner._views["form"] = `<form><field name="foo"/></form>`;
Partner._onChanges.foo = () => {};
onRpc("web_save", () => {
throw new Error("should not save");
});
await mountWithCleanup(WebClient);
// must focus something else than body before opening the form view dialog, such that the ui
// service has something to focus on dialog close, which will then blur the input and fire the
// change event
await contains(".o_navbar_apps_menu button").focus();
expect(".o_navbar_apps_menu button").toBeFocused();
getService("dialog").add(FormViewDialog, {
resModel: "partner",
resId: 1,
});
await animationFrame();
expect(".o_dialog").toHaveCount(1);
await contains(".o_field_widget[name=foo] input").edit("new value", { confirm: false });
await press("escape");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
expect(".o_navbar_apps_menu button").toBeFocused();
});
test.tags("desktop");
test("display a dialog if onchange result is a warning from within a dialog", async function () {
Instrument._views = {
form: `<form><field name="name" /></form>`,
};
onRpc("instrument", "onchange", () => {
expect.step("onchange warning");
return Promise.resolve({
value: {
name: false,
},
warning: {
title: "Warning",
message: "You must first select a partner",
type: "dialog",
},
});
});
await mountView({
type: "form",
resModel: "partner",
arch: `<form><field name="instrument"/></form>`,
resId: 2,
});
await contains(".o_field_widget[name=instrument] input").edit("tralala", { confirm: false });
await runAllTimers();
await contains(".o_field_widget[name=instrument] .o_m2o_dropdown_option_create_edit").click();
await waitFor(".modal.o_inactive_modal");
expect(".modal").toHaveCount(2);
expect(".modal:not(.o_inactive_modal) .modal-body").toHaveText(
"You must first select a partner"
);
await contains(".modal:not(.o_inactive_modal) button").click();
expect(".modal").toHaveCount(1);
expect(".modal:not(.o_inactive_modal) .modal-title").toHaveText("Create Instruments");
expect.verifySteps(["onchange warning"]);
});

View file

@ -0,0 +1,569 @@
import { renderToMarkup } from "@web/core/utils/render";
import { useSetupAction } from "@web/search/action_hook";
import { listView } from "@web/views/list/list_view";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { WebClient } from "@web/webclient/webclient";
import { xml } from "@odoo/owl";
import {
clickModalButton,
clickSave,
contains,
defineModels,
editFavoriteName,
fields,
getService,
models,
mockService,
mountView,
mountWithCleanup,
onRpc,
patchWithCleanup,
removeFacet,
saveFavorite,
toggleMenuItem,
toggleSaveFavorite,
toggleSearchBarMenu,
} from "@web/../tests/web_test_helpers";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { click, queryOne } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
class Partner extends models.Model {
name = fields.Char({ string: "Displayed name" });
foo = fields.Char({ string: "Foo" });
bar = fields.Boolean({ string: "Bar" });
instrument = fields.Many2one({ string: "Instrument", relation: "instrument" });
_records = [
{ id: 1, foo: "blip", name: "blipblip", bar: true },
{ id: 2, foo: "ta tata ta ta", name: "macgyver", bar: false },
{ id: 3, foo: "piou piou", name: "Jack O'Neill", bar: true },
];
}
class Instrument extends models.Model {
name = fields.Char({ string: "name" });
badassery = fields.Many2many({
string: "level",
relation: "badassery",
domain: [["level", "=", "Awsome"]],
});
}
class Badassery extends models.Model {
level = fields.Char({ string: "level" });
_records = [{ id: 1, level: "Awsome" }];
}
class Product extends models.Model {
name = fields.Char({ string: "name" });
partner = fields.One2many({ string: "Doors", relation: "partner" });
_records = [{ id: 1, name: "The end" }];
}
defineModels([Partner, Instrument, Badassery, Product]);
describe.current.tags("desktop");
beforeEach(() => onRpc("has_group", () => true));
test("SelectCreateDialog use domain, group_by and search default", async () => {
expect.assertions(3);
Partner._views["list"] = /* xml */ `
<list string="Partner">
<field name="name"/>
<field name="foo"/>
</list>
`;
Partner._views["search"] = /* xml */ `
<search>
<field name="foo" filter_domain="[('name','ilike',self), ('foo','ilike',self)]"/>
<group expand="0" string="Group By">
<filter name="groupby_bar" context="{'group_by' : 'bar'}"/>
</group>
</search>
`;
let search = 0;
onRpc("web_read_group", ({ kwargs }) => {
expect(kwargs).toMatchObject(
{
domain: [
"&",
["name", "like", "a"],
"&",
["name", "ilike", "piou"],
["foo", "ilike", "piou"],
],
fields: [],
groupby: ["bar"],
orderby: "",
lazy: true,
limit: 80,
offset: 0,
},
{
message:
"should search with the complete domain (domain + search), and group by 'bar'",
}
);
});
onRpc("web_search_read", ({ kwargs }) => {
if (search === 0) {
expect(kwargs).toMatchObject(
{
domain: [
"&",
["name", "like", "a"],
"&",
["name", "ilike", "piou"],
["foo", "ilike", "piou"],
],
specification: { name: {}, foo: {} },
limit: 80,
offset: 0,
order: "",
count_limit: 10001,
},
{
message: "should search with the complete domain (domain + search)",
}
);
} else if (search === 1) {
expect(kwargs).toMatchObject(
{
domain: [["name", "like", "a"]],
specification: { name: {}, foo: {} },
limit: 80,
offset: 0,
order: "",
count_limit: 10001,
},
{
message: "should search with the domain",
}
);
}
search++;
});
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
noCreate: true,
resModel: "partner",
domain: [["name", "like", "a"]],
context: {
search_default_groupby_bar: true,
search_default_foo: "piou",
},
});
await animationFrame();
await removeFacet("Bar");
await removeFacet("Foo piou");
});
test("SelectCreateDialog correctly evaluates domains", async () => {
expect.assertions(1);
Partner._views["list"] = /* xml */ `
<list string="Partner">
<field name="name"/>
<field name="foo"/>
</list>
`;
Partner._views["search"] = /* xml */ `<search><field name="foo"/></search>`;
onRpc("web_search_read", ({ kwargs }) => {
expect(kwargs.domain).toEqual([["id", "=", 2]], {
message: "should have correctly evaluated the domain",
});
});
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
noCreate: true,
resModel: "partner",
domain: [["id", "=", 2]],
});
await animationFrame();
});
test("SelectCreateDialog list view in readonly", async () => {
Partner._views["list"] = /* xml */ `
<list string="Partner" editable="bottom">
<field name="name"/>
<field name="foo"/>
</list>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
resModel: "partner",
});
await animationFrame();
// click on the first row to see if the list is editable
await contains(".o_list_view tbody tr td:first").click();
expect(".o_list_view tbody tr td .o_field_char input").toHaveCount(0, {
message: "list view should not be editable in a SelectCreateDialog",
});
});
test("SelectCreateDialog cascade x2many in create mode", async () => {
expect.assertions(5);
Partner._views["form"] = /* xml */ `
<form>
<field name="name"/>
<field name="instrument" widget="one2many" mode="list"/>
</form>
`;
Instrument._views["form"] = /* xml */ `
<form>
<field name="name"/>
<field name="badassery">
<list>
<field name="level"/>
</list>
</field>
</form>
`;
Badassery._views["list"] = /* xml */ `<list><field name="level"/></list>`;
Badassery._views["search"] = /* xml */ `<search><field name="level"/></search>`;
onRpc(async ({ route, args }) => {
if (route === "/web/dataset/call_kw/partner/get_formview_id") {
return false;
}
if (route === "/web/dataset/call_kw/instrument/get_formview_id") {
return false;
}
if (route === "/web/dataset/call_kw/instrument/web_save") {
expect(args[1]).toEqual(
{ badassery: [[4, 1]], name: "ABC" },
{
message: "The method create should have been called with the right arguments",
}
);
return [{ id: 90 }];
}
});
await mountView({
type: "form",
resModel: "product",
resId: 1,
arch: /* xml */ `
<form>
<field name="name"/>
<field name="partner" widget="one2many" >
<list editable="top">
<field name="name"/>
<field name="instrument"/>
</list>
</field>
</form>
`,
});
await contains(".o_field_x2many_list_row_add a").click();
await contains(".o_field_widget[name=instrument] input").edit("ABC", { confirm: false });
await runAllTimers();
await contains(
`[name="instrument"] .dropdown .dropdown-menu li:contains("Create and edit...")`
).click();
expect(".modal .modal-lg").toHaveCount(1);
await contains(".modal .o_field_x2many_list_row_add a").click();
expect(".modal .modal-lg").toHaveCount(2);
await contains(".modal .o_data_row input[type=checkbox]").check();
await clickModalButton({ text: "Select" });
expect(".modal .modal-lg").toHaveCount(1);
expect(".modal .o_data_cell").toHaveText("Awsome");
// click on modal save button
await clickSave({ index: 1 });
});
test("SelectCreateDialog: save current search", async () => {
expect.assertions(5);
Partner._views["list"] = /* xml */ `<list><field name="name"/> </list>`;
Partner._views[
"search"
] = /* xml */ `<search><filter name="bar" help="Bar" domain="[('bar', '=', True)]"/></search>`;
patchWithCleanup(listView.Controller.prototype, {
setup() {
super.setup(...arguments);
useSetupAction({
getContext: () => ({ shouldBeInFilterContext: true }),
});
},
});
onRpc("get_views", ({ kwargs }) => {
expect(kwargs.options.load_filters).toBe(true, { message: "Missing load_filters option" });
});
onRpc("create_or_replace", ({ model, args }) => {
if (model === "ir.filters") {
const irFilter = args[0];
expect(irFilter.domain).toBe(`[("bar", "=", True)]`, {
message: "should save the correct domain",
});
const expectedContext = {
group_by: [], // default groupby is an empty list
shouldBeInFilterContext: true,
};
expect(irFilter.context).toEqual(expectedContext, {
message: "should save the correct context",
});
return 7; // fake serverSideId
}
});
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
context: { shouldNotBeInFilterContext: false },
resModel: "partner",
});
await animationFrame();
expect(".o_data_row").toHaveCount(3, { message: "should contain 3 records" });
// filter on bar
await toggleSearchBarMenu();
await toggleMenuItem("Bar");
expect(".o_data_row").toHaveCount(2, { message: "should contain 2 records" });
// save filter
await toggleSaveFavorite();
await editFavoriteName("some name");
await saveFavorite();
});
test("SelectCreateDialog calls on_selected with every record matching the domain", async () => {
expect.assertions(1);
Partner._views["list"] = /* xml */ `
<list limit="2" string="Partner">
<field name="name"/>
<field name="foo"/>
</list>
`;
Partner._views["search"] = /* xml */ `<search><field name="foo"/></search>`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
resModel: "partner",
onSelected: (records) => expect(records.join(",")).toBe("1,2,3"),
});
await animationFrame();
await contains("thead .o_list_record_selector input").click();
await contains(".o_list_selection_box .o_list_select_domain").click();
await clickModalButton({ text: "Select" });
});
test("SelectCreateDialog calls on_selected with every record matching without selecting a domain", async () => {
expect.assertions(1);
Partner._views["list"] = /* xml */ `
<list limit="2" string="Partner">
<field name="name"/>
<field name="foo"/>
</list>
`;
Partner._views["search"] = /* xml */ `<search><field name="foo"/></search>`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
resModel: "partner",
onSelected: (records) => expect(records.join(",")).toBe("1,2"),
});
await animationFrame();
await contains("thead .o_list_record_selector input").click();
await contains(".o_list_selection_box").click();
await clickModalButton({ text: "Select", index: 1 });
});
test("SelectCreateDialog: multiple clicks on record", async () => {
Partner._views["list"] = /* xml */ `<list><field name="name"/></list>`;
Partner._views["search"] = /* xml */ `<search><field name="foo"/></search>`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
resModel: "partner",
onSelected: async function (records) {
expect.step(`select record ${records[0]}`);
},
});
await animationFrame();
await click(".modal .o_data_row .o_data_cell");
await click(".modal .o_data_row .o_data_cell");
await click(".modal .o_data_row .o_data_cell");
await animationFrame();
// should have called onSelected only once
expect.verifySteps(["select record 1"]);
});
test("SelectCreateDialog: default props, create a record", async () => {
Partner._views["list"] = /* xml */ `<list><field name="name"/></list>`;
Partner._views["search"] = /* xml */ `
<search>
<filter name="bar" help="Bar" domain="[('bar', '=', True)]"/>
</search>
`;
Partner._views["form"] = /* xml */ `<form><field name="name"/></form>`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
onSelected: (resIds) => expect.step(`onSelected ${resIds}`),
resModel: "partner",
});
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect(".o_dialog .o_list_view .o_data_row").toHaveCount(3);
expect(".o_dialog footer button").toHaveCount(3);
expect(".o_dialog footer button.o_select_button").toHaveCount(1);
expect(".o_dialog footer button.o_create_button").toHaveCount(1);
expect(".o_dialog footer button.o_form_button_cancel").toHaveCount(1);
expect(".o_dialog .o_control_panel_main_buttons .o_list_button_add").toHaveCount(0);
await contains(".o_dialog footer button.o_create_button").click();
expect(".o_dialog").toHaveCount(2);
expect(".o_dialog .o_form_view").toHaveCount(1);
await contains(".o_dialog .o_form_view .o_field_widget input").edit("hello");
await clickSave();
expect(".o_dialog").toHaveCount(0);
expect.verifySteps(["onSelected 4"]);
});
test("SelectCreateDialog empty list, default no content helper", async () => {
Partner._records = [];
Partner._views["list"] = /* xml */ `
<list>
<field name="name"/>
<field name="foo"/>
</list>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, { resModel: "partner" });
await animationFrame();
expect(".o_dialog .o_list_view").toHaveCount(1);
expect(".o_dialog .o_list_view .o_data_row").toHaveCount(0);
expect(".o_dialog .o_list_view .o_view_nocontent").toHaveCount(1);
expect(queryOne(".o_dialog .o_list_view .o_view_nocontent")).toHaveInnerHTML(
`<div class="o_nocontent_help"><p>No record found</p><p>Adjust your filters or create a new record.</p></div>`
);
});
test("SelectCreateDialog empty list, noContentHelp props", async () => {
Partner._records = [];
Partner._views["list"] = /* xml */ `
<list>
<field name="name"/>
<field name="foo"/>
</list>
`;
await mountWithCleanup(WebClient);
const template = xml`
<p class="custom_classname">Hello</p>
<p>I'm an helper</p>
`;
getService("dialog").add(SelectCreateDialog, {
resModel: "partner",
noContentHelp: renderToMarkup(template),
});
await animationFrame();
expect(".o_dialog .o_list_view").toHaveCount(1);
expect(".o_dialog .o_list_view .o_data_row").toHaveCount(0);
expect(".o_dialog .o_list_view .o_view_nocontent").toHaveCount(1);
expect(queryOne(".o_dialog .o_list_view .o_view_nocontent")).toHaveInnerHTML(
`<div class="o_nocontent_help"><p class="custom_classname">Hello</p><p>I'm an helper</p></div>`
);
});
test("SelectCreateDialog with open action", async () => {
Instrument._records = [];
for (let i = 0; i < 25; i++) {
Instrument._records.push({
id: i + 1,
name: "Instrument " + i,
});
}
mockService("action", {
doActionButton(params) {
const { name } = params;
expect.step(`execute_action: ${name}`, params);
},
});
Instrument._views["list"] = /* xml */ `
<list action="test_action" type="object">
<field name="name"/>
</list>
`;
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<field name="instrument"/>
</form>
`,
});
await contains(`.o_field_widget[name="instrument"] .dropdown input`).click();
await contains(`.o_field_widget[name="instrument"] .o_m2o_dropdown_option_search_more`).click();
await contains(
`.o_list_renderer .o_data_row .o_field_cell.o_list_char[data-tooltip="Instrument 10"]`
).click();
expect("input").toHaveValue("Instrument 10");
expect.verifySteps([]);
});
test("SelectCreateDialog: enable select when grouped with domain selection", async () => {
Partner._views["list"] = `
<list string="Partner">
<field name="name"/>
<field name="foo"/>
</list>
`;
Partner._views["search"] = `
<search>
<group expand="0" string="Group By">
<filter name="groupby_bar" context="{'group_by' : 'bar'}"/>
</group>
</search>
`;
await mountWithCleanup(WebClient);
getService("dialog").add(SelectCreateDialog, {
noCreate: true,
resModel: "partner",
domain: [["name", "like", "a"]],
context: {
search_default_groupby_bar: true,
},
});
await animationFrame();
await contains("thead .o_list_record_selector input").click();
await animationFrame();
expect(".o_select_button:not([disabled])").toHaveCount(1);
});

View file

@ -0,0 +1,105 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import {
defineModels,
getService,
makeMockEnv,
models,
onRpc,
} from "@web/../tests/web_test_helpers";
describe.current.tags("headless");
class TakeFive extends models.Model {
_name = "take.five";
_views = {
"list,99": /* xml */ `<list><field name="display_name" /></list>`,
};
}
class IrUiView extends models.Model {
_name = "ir.ui.view";
}
defineModels([TakeFive, IrUiView]);
test("stores calls in cache in success", async () => {
expect.assertions(1);
onRpc("get_views", () => {
expect.step("get_views");
});
await makeMockEnv();
await getService("view").loadViews(
{
resModel: "take.five",
views: [[99, "list"]],
context: { default_field_value: 1 },
},
{}
);
await getService("view").loadViews(
{
resModel: "take.five",
views: [[99, "list"]],
context: { default_field_value: 2 },
},
{}
);
expect.verifySteps(["get_views"]);
});
test("stores calls in cache when failed", async () => {
expect.assertions(3);
onRpc("get_views", () => {
expect.step("get_views");
throw new Error("my little error");
});
await makeMockEnv();
await expect(
getService("view").loadViews(
{
resModel: "take.five",
views: [[99, "list"]],
},
{}
)
).rejects.toThrow(/my little error/);
await expect(
getService("view").loadViews(
{
resModel: "take.five",
views: [[99, "list"]],
},
{}
)
).rejects.toThrow(/my little error/);
expect.verifySteps(["get_views", "get_views"]);
});
test("clear cache when updating ir.ui.view", async () => {
expect.assertions(4);
onRpc("get_views", () => {
expect.step("get_views");
});
await makeMockEnv();
const loadView = () =>
getService("view").loadViews(
{
resModel: "take.five",
views: [[99, "list"]],
context: { default_field_value: 1 },
},
{}
);
await loadView();
expect.verifySteps(["get_views"]);
await loadView();
expect.verifySteps([]); // cache works => no actual rpc
await getService("orm").unlink("ir.ui.view", [3]);
await loadView();
expect.verifySteps(["get_views"]); // cache was invalidated
await getService("orm").unlink("take.five", [3]);
await loadView();
expect.verifySteps([]); // cache was not invalidated
});

View file

@ -0,0 +1,142 @@
import { expect, test } from "@odoo/hoot";
import { click, manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import {
contains,
defineModels,
fields,
mockService,
models,
mountView,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { AttachDocumentWidget } from "@web/views/widgets/attach_document/attach_document";
class Partner extends models.Model {
display_name = fields.Char({ string: "Displayed name" });
_records = [
{
id: 1,
display_name: "first record",
},
];
}
defineModels([Partner]);
test("attach document widget calls action with attachment ids", async () => {
// FIXME: This ugly hack is needed because the input is not attached in the DOM
// The input should be attached to the component and hidden in some way to make
// the interaction easier and more natural.
let fileInput;
patchWithCleanup(AttachDocumentWidget.prototype, {
setup() {
super.setup();
fileInput = this.fileInput;
},
});
mockService("http", {
post(route, params) {
expect.step("post");
expect(route).toBe("/web/binary/upload_attachment");
expect(params.model).toBe("partner");
expect(params.id).toBe(1);
return '[{ "id": 5 }, { "id": 2 }]';
},
});
onRpc(async ({ args, kwargs, method, model }) => {
expect.step(method);
if (method === "my_action") {
expect(model).toBe("partner");
expect(args).toEqual([1]);
expect(kwargs.attachment_ids).toEqual([5, 2]);
return true;
}
if (method === "web_save") {
expect(args[1]).toEqual({ display_name: "yop" });
}
if (method === "web_read") {
expect(args[0]).toEqual([1]);
}
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<widget name="attach_document" action="my_action" string="Attach document"/>
<field name="display_name" required="1"/>
</form>`,
});
expect.verifySteps(["get_views", "web_read"]);
await contains("[name='display_name'] input").edit("yop");
await animationFrame();
await click(".o_attach_document");
await animationFrame();
await manuallyDispatchProgrammaticEvent(fileInput, "change");
await animationFrame();
expect.verifySteps(["web_save", "post", "my_action", "web_read"]);
});
test("attach document widget calls action with attachment ids on a new record", async () => {
// FIXME: This ugly hack is needed because the input is not attached in the DOM
// The input should be attached to the component and hidden in some way to make
// the interaction easier and more natural.
let fileInput;
patchWithCleanup(AttachDocumentWidget.prototype, {
setup() {
super.setup();
fileInput = this.fileInput;
},
});
mockService("http", {
post(route, params) {
expect.step("post");
expect(route).toBe("/web/binary/upload_attachment");
expect(params.model).toBe("partner");
expect(params.id).toBe(2);
return '[{ "id": 5 }, { "id": 2 }]';
},
});
onRpc(async (params) => {
expect.step(params.method);
if (params.method === "my_action") {
expect(params.model).toBe("partner");
expect(params.args).toEqual([2]);
expect(params.kwargs.attachment_ids).toEqual([5, 2]);
return true;
}
if (params.method === "web_save") {
expect(params.args[1]).toEqual({ display_name: "yop" });
}
if (params.method === "web_read") {
expect(params.args[0]).toEqual([2]);
}
});
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<widget name="attach_document" action="my_action" string="Attach document"/>
<field name="display_name" required="1"/>
</form>`,
});
expect.verifySteps(["get_views", "onchange"]);
await contains("[name='display_name'] input").edit("yop");
await click(".o_attach_document");
await animationFrame();
await manuallyDispatchProgrammaticEvent(fileInput, "change");
await animationFrame();
expect.verifySteps(["web_save", "post", "my_action", "web_read"]);
});

View file

@ -0,0 +1,140 @@
import {
defineModels,
fields,
models,
mountView,
mountWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Component, xml } from "@odoo/owl";
import { expect, test } from "@odoo/hoot";
import { DocumentationLink } from "@web/views/widgets/documentation_link/documentation_link";
class Partner extends models.Model {
bar = fields.Boolean();
}
defineModels([Partner]);
test("documentation_link: default label and icon", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="/this_is_a_test.html"/>
</form>`,
});
expect(".o_doc_link").toHaveText("View Documentation");
expect("a.alert-link").toHaveCount(0);
expect(".o_doc_link .fa-external-link").toHaveCount(1);
});
test("documentationLink: alert-link", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="/this_is_a_test.html" alert_link="true"/>
</form>`,
});
expect("a.alert-link").toHaveCount(1);
});
test("DocumentationLink Component: alert-link", async () => {
class Parent extends Component {
static components = { DocumentationLink };
static template = xml`
<DocumentationLink path="'/this_is_a_test.html'" alertLink="true"/>`;
static props = ["*"];
}
await mountWithCleanup(Parent);
expect("a.alert-link").toHaveCount(1);
});
test("documentation_link: given label", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="/this_is_a_test.html" label="docdoc"/>
</form>`,
});
expect(".o_doc_link").toHaveText("docdoc");
expect(".o_doc_link .fa").toHaveCount(0);
});
test("documentation_link: given icon", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="/this_is_a_test.html" icon="fa-question-circle"/>
</form>`,
});
expect(".o_doc_link").toHaveText("");
expect(".o_doc_link .fa-question-circle").toHaveCount(1);
});
test("documentation_link: given label and icon", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="/this_is_a_test.html" label="docdoc" icon="fa-question-circle"/>
</form>`,
});
expect(".o_doc_link").toHaveText("docdoc");
expect(".o_doc_link .fa-question-circle").toHaveCount(1);
});
test("documentation_link: relative path", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="/applications/technical/web/settings/this_is_a_test.html"/>
</form>`,
});
expect(".o_doc_link").toHaveAttribute(
"href",
"https://www.odoo.com/documentation/1.0/applications/technical/web/settings/this_is_a_test.html"
);
});
test("documentation_link: absolute path (http)", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="http://www.odoo.com/"/>
</form>`,
});
expect(".o_doc_link").toHaveAttribute("href", "http://www.odoo.com/");
});
test("documentation_link: absolute path (https)", async () => {
await mountView({
type: "form",
resModel: "partner",
arch: /* xml */ `
<form>
<field name="bar"/>
<widget name="documentation_link" path="https://www.odoo.com/"/>
</form>`,
});
expect(".o_doc_link").toHaveAttribute("href", "https://www.odoo.com/");
});

View file

@ -0,0 +1,40 @@
import { expect, test } from "@odoo/hoot";
import { mockPermission } from "@odoo/hoot-mock";
import { defineModels, models, mountView } from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
_records = [{ id: 1 }];
}
defineModels([Partner]);
const viewData = {
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><widget name="notification_alert"/></form>`,
};
test("notification alert should be displayed when notification denied", async () => {
mockPermission("notifications", "denied");
await mountView(viewData);
expect(".o_widget_notification_alert .alert").toHaveCount(1, {
message: "notification alert should be displayed when notification denied",
});
});
test("notification alert should not be displayed when notification granted", async () => {
mockPermission("notifications", "granted");
await mountView(viewData);
expect(".o_widget_notification_alert .alert").toHaveCount(0, {
message: "notification alert should not be displayed when notification granted",
});
});
test("notification alert should not be displayed when notification default", async () => {
mockPermission("notifications", "default");
await mountView(viewData);
expect(".o_widget_notification_alert .alert").toHaveCount(0, {
message: "notification alert should not be displayed when notification default",
});
});

View file

@ -0,0 +1,226 @@
import { NameAndSignature } from "@web/core/signature/name_and_signature";
import {
defineModels,
fields,
models,
mountView,
onRpc,
patchWithCleanup,
contains,
} from "@web/../tests/web_test_helpers";
import { beforeEach, test, expect } from "@odoo/hoot";
import { click, waitFor } from "@odoo/hoot-dom";
class Partner extends models.Model {
display_name = fields.Char();
product_id = fields.Many2one({ string: "Product Name", relation: "product" });
sign = fields.Binary({ string: "Signature" });
_records = [
{
id: 1,
display_name: "Pop's Chock'lit",
product_id: 7,
},
];
}
class Product extends models.Model {
name = fields.Char({ string: "Product Name" });
_records = [
{
id: 7,
name: "Veggie Burger",
},
];
}
defineModels([Partner, Product]);
beforeEach(async () => {
onRpc("/web/sign/get_fonts/", () => {
return {};
});
});
test.tags("desktop");
test("Signature widget renders a Sign button on desktop", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup();
expect(this.props.signature.name).toBe("");
},
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<widget name="signature" string="Sign"/>
</header>
</form>`,
});
expect("button.o_sign_button").toHaveClass("btn-secondary", {
message: `The button must have the 'btn-secondary' class as "highlight=0"`,
});
expect(".o_widget_signature button.o_sign_button").toHaveCount(1, {
message: "Should have a signature widget button",
});
expect(".modal-dialog").toHaveCount(0, {
message: "Should not have any modal",
});
// Clicks on the sign button to open the sign modal.
await click(".o_widget_signature button.o_sign_button");
await waitFor(".modal .modal-body");
expect(".modal-dialog").toHaveCount(1, {
message: "Should have one modal opened",
});
});
test.tags("mobile");
test("Signature widget renders a Sign button on mobile", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup();
expect(this.props.signature.name).toBe("");
},
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<widget name="signature" string="Sign"/>
</header>
</form>`,
});
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
expect("button.o_sign_button").toHaveClass("btn-secondary", {
message: `The button must have the 'btn-secondary' class as "highlight=0"`,
});
expect(".o_widget_signature button.o_sign_button").toHaveCount(1, {
message: "Should have a signature widget button",
});
expect(".modal-dialog").toHaveCount(0, {
message: "Should not have any modal",
});
// Clicks on the sign button to open the sign modal.
await click(".o_widget_signature button.o_sign_button");
await waitFor(".modal .modal-body");
expect(".modal-dialog").toHaveCount(1, {
message: "Should have one modal opened",
});
});
test.tags("desktop");
test("Signature widget: full_name option on desktop", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup();
expect.step(this.props.signature.name);
},
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<widget name="signature" string="Sign" full_name="display_name"/>
</header>
<field name="display_name"/>
</form>`,
});
// Clicks on the sign button to open the sign modal.
await click("span.o_sign_label");
await waitFor(".modal .modal-body");
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(1);
expect.verifySteps(["Pop's Chock'lit"]);
});
test.tags("mobile");
test("Signature widget: full_name option on mobile", async () => {
patchWithCleanup(NameAndSignature.prototype, {
setup() {
super.setup();
expect.step(this.props.signature.name);
},
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<widget name="signature" string="Sign" full_name="display_name"/>
</header>
<field name="display_name"/>
</form>`,
});
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
// Clicks on the sign button to open the sign modal.
await click("span.o_sign_label");
await waitFor(".modal .modal-body");
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(1);
expect.verifySteps(["Pop's Chock'lit"]);
});
test.tags("desktop");
test("Signature widget: highlight option on desktop", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<widget name="signature" string="Sign" highlight="1"/>
</header>
</form>`,
});
expect("button.o_sign_button").toHaveClass("btn-primary", {
message: `The button must have the 'btn-primary' class as "highlight=1"`,
});
// Clicks on the sign button to open the sign modal.
await click(".o_widget_signature button.o_sign_button");
await waitFor(".modal .modal-body");
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(0);
});
test.tags("mobile");
test("Signature widget: highlight option on mobile", async () => {
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `
<form>
<header>
<widget name="signature" string="Sign" highlight="1"/>
</header>
</form>`,
});
await contains(`.o_cp_action_menus button:has(.fa-cog)`).click();
expect("button.o_sign_button").toHaveClass("btn-primary", {
message: `The button must have the 'btn-primary' class as "highlight=1"`,
});
// Clicks on the sign button to open the sign modal.
await click(".o_widget_signature button.o_sign_button");
await waitFor(".modal .modal-body");
expect(".modal .modal-body a.o_web_sign_auto_button").toHaveCount(0);
});

View file

@ -0,0 +1,134 @@
import { expect, test } from "@odoo/hoot";
import { click, queryAllTexts } from "@odoo/hoot-dom";
import {
clickSave,
defineModels,
defineParams,
fields,
models,
mountView,
onRpc,
} from "@web/../tests/web_test_helpers";
class Partner extends models.Model {
sun = fields.Boolean();
mon = fields.Boolean();
tue = fields.Boolean();
wed = fields.Boolean();
thu = fields.Boolean();
fri = fields.Boolean();
sat = fields.Boolean();
_records = [
{
id: 1,
sun: false,
mon: false,
tue: false,
wed: false,
thu: false,
fri: false,
sat: false,
},
];
}
defineModels([Partner]);
test("simple week recurrence widget", async () => {
expect.assertions(13);
defineParams({ lang_parameters: { week_start: 1 } });
let writeCall = 0;
onRpc("web_save", ({ args }) => {
writeCall++;
if (writeCall === 1) {
expect(args[1].sun).toBe(true);
}
if (writeCall === 2) {
expect(args[1].sun).not.toBe(true);
expect(args[1].mon).toBe(true);
expect(args[1].tue).toBe(true);
}
});
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><sheet><group><widget name="week_days" /></group></sheet></form>`,
});
expect(queryAllTexts(".o_recurrent_weekday_label")).toEqual(
["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
{ message: "labels should be short week names" }
);
expect(".form-check input:disabled").toHaveCount(0, {
message: "all inputs should be enabled in edit mode",
});
await click("td:nth-child(7) input");
expect("td:nth-child(7) input").toBeChecked({
message: "sunday checkbox should be checked",
});
await clickSave();
await click("td:nth-child(1) input");
expect("td:nth-child(1) input").toBeChecked({
message: "monday checkbox should be checked",
});
await click("td:nth-child(2) input");
expect("td:nth-child(2) input").toBeChecked({
message: "tuesday checkbox should be checked",
});
// uncheck Sunday checkbox and check write call
await click("td:nth-child(7) input");
expect("td:nth-child(7) input").not.toBeChecked({
message: "sunday checkbox should be unchecked",
});
await clickSave();
expect("td:nth-child(7) input").not.toBeChecked({
message: "sunday checkbox should be unchecked",
});
expect("td:nth-child(1) input").toBeChecked({ message: "monday checkbox should be checked" });
expect("td:nth-child(2) input").toBeChecked({
message: "tuesday checkbox should be checked",
});
});
test("week recurrence widget readonly modifiers", async () => {
defineParams({ lang_parameters: { week_start: 1 } });
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><sheet><group><widget name="week_days" readonly="1"/></group></sheet></form>`,
});
expect(queryAllTexts(".o_recurrent_weekday_label")).toEqual(
["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
{ message: "labels should be short week names" }
);
expect(".form-check input:disabled").toHaveCount(7, {
message: "all inputs should be disabled in readonly mode",
});
});
test("week recurrence widget show week start as per language configuration", async () => {
defineParams({ lang_parameters: { week_start: 5 } });
await mountView({
type: "form",
resModel: "partner",
resId: 1,
arch: /* xml */ `<form><sheet><group><widget name="week_days"/></group></sheet></form>`,
});
expect(queryAllTexts(".o_recurrent_weekday_label")).toEqual(
["Fri", "Sat", "Sun", "Mon", "Tue", "Wed", "Thu"],
{ message: "labels should be short week names" }
);
});