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,557 @@
/** @odoo-module alias=@web/../tests/views/calendar/helpers default=false */
import { uiService } from "@web/core/ui/ui_service";
import { createElement } from "@web/core/utils/xml";
import { registry } from "@web/core/registry";
import { Field } from "@web/views/fields/field";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, mount, nextTick, triggerEvent } from "../../helpers/utils";
import { setupViewRegistries } from "@web/../tests/views/helpers";
export function makeEnv(services = {}) {
clearRegistryWithCleanup(registry.category("main_components"));
setupViewRegistries();
services = Object.assign(
{
ui: uiService,
},
services
);
for (const [key, service] of Object.entries(services)) {
registry.category("services").add(key, service, { force: true });
}
return makeTestEnv({
config: {
setDisplayName: () => {},
},
});
}
//------------------------------------------------------------------------------
export async function mountComponent(C, env, props) {
const target = getFixture();
return await mount(C, target, { env, props });
}
//------------------------------------------------------------------------------
export function makeFakeDate() {
return luxon.DateTime.local(2021, 7, 16, 8, 0, 0, 0);
}
export function makeFakeRecords() {
return {
1: {
id: 1,
title: "1 day, all day in July",
start: makeFakeDate(),
isAllDay: true,
end: makeFakeDate(),
},
2: {
id: 2,
title: "3 days, all day in July",
start: makeFakeDate().plus({ days: 2 }),
isAllDay: true,
end: makeFakeDate().plus({ days: 4 }),
},
3: {
id: 3,
title: "1 day, all day in June",
start: makeFakeDate().plus({ months: -1 }),
isAllDay: true,
end: makeFakeDate().plus({ months: -1 }),
},
4: {
id: 4,
title: "3 days, all day in June",
start: makeFakeDate().plus({ months: -1, days: 2 }),
isAllDay: true,
end: makeFakeDate().plus({ months: -1, days: 4 }),
},
5: {
id: 5,
title: "Over June and July",
start: makeFakeDate().startOf("month").plus({ days: -2 }),
isAllDay: true,
end: makeFakeDate().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" },
};
function makeFakeModelState() {
const fakeFieldNode = createElement("field", { name: "name" });
const fakeModels = { event: { fields: FAKE_FIELDS } };
return {
canCreate: true,
canDelete: true,
canEdit: true,
date: makeFakeDate(),
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(fakeFieldNode, fakeModels, "event", "calendar"),
},
activeFields: {
name: {
context: "{}",
invisible: false,
readonly: false,
required: false,
onChange: false,
},
},
rangeEnd: makeFakeDate().endOf("month"),
rangeStart: makeFakeDate().startOf("month"),
records: makeFakeRecords(),
resModel: "event",
scale: "month",
scales: ["day", "week", "month", "year"],
unusualDays: [],
};
}
export function makeFakeModel(state = {}) {
return {
...makeFakeModelState(),
load() {},
createFilter() {},
createRecord() {},
unlinkFilter() {},
unlinkRecord() {},
updateFilter() {},
updateRecord() {},
...state,
};
}
// DOM Utils
//------------------------------------------------------------------------------
async function scrollTo(el, scrollParam) {
el.scrollIntoView(scrollParam);
await new Promise(window.requestAnimationFrame);
}
export function findPickedDate(target) {
return target.querySelector(".o_datetime_picker .o_selected");
}
export async function pickDate(target, date) {
const day = date.split("-")[2];
const iDay = parseInt(day, 10) - 1;
const el = target.querySelectorAll(`.o_datetime_picker .o_date_item_cell:not(.o_out_of_range)`)[
iDay
];
el.scrollIntoView();
await click(el);
}
export function expandCalendarView(target) {
// Expends Calendar view and FC too
let tmpElement = target.querySelector(".fc");
do {
tmpElement = tmpElement.parentElement;
tmpElement.classList.add("h-100");
} while (!tmpElement.classList.contains("o_view_controller"));
}
export function findAllDaySlot(target, date) {
return target.querySelector(`.fc-daygrid-body .fc-day[data-date="${date}"]`);
}
export function findDateCell(target, date) {
return target.querySelector(`.fc-day[data-date="${date}"]`);
}
export function findEvent(target, eventId) {
return target.querySelector(`.o_event[data-event-id="${eventId}"]`);
}
export function findDateCol(target, date) {
return target.querySelector(`.fc-col-header-cell.fc-day[data-date="${date}"]`);
}
export function findTimeRow(target, time) {
return target.querySelector(`.fc-timegrid-slot[data-time="${time}"]`);
}
export async function triggerEventForCalendar(el, type, position = {}) {
const rect = el.getBoundingClientRect();
const x = position.x || rect.x + rect.width / 2;
const y = position.y || rect.y + rect.height / 2;
const attrs = {
which: 1,
clientX: x,
clientY: y,
};
await triggerEvent(el, null, type, attrs);
}
export async function clickAllDaySlot(target, date) {
const el = findAllDaySlot(target, date);
await scrollTo(el);
await triggerEventForCalendar(el, "mousedown");
await triggerEventForCalendar(el, "mouseup");
await nextTick();
}
export async function clickDate(target, date) {
const el = findDateCell(target, date);
await scrollTo(el);
await triggerEventForCalendar(el, "mousedown");
await triggerEventForCalendar(el, "mouseup");
await nextTick();
}
export async function clickEvent(target, eventId) {
const el = findEvent(target, eventId);
await scrollTo(el);
await click(el);
await nextTick();
}
export async function selectTimeRange(target, startDateTime, endDateTime) {
const [startDate, startTime] = startDateTime.split(" ");
const [endDate, endTime] = endDateTime.split(" ");
const startCol = findDateCol(target, startDate);
const endCol = findDateCol(target, endDate);
const startRow = findTimeRow(target, startTime);
const endRow = findTimeRow(target, endTime);
await scrollTo(startRow);
const startColRect = startCol.getBoundingClientRect();
const startRowRect = startRow.getBoundingClientRect();
await triggerEventForCalendar(startRow, "mousedown", {
x: startColRect.x + startColRect.width / 2,
y: startRowRect.y + 2,
});
await scrollTo(endRow, false);
const endColRect = endCol.getBoundingClientRect();
const endRowRect = endRow.getBoundingClientRect();
await triggerEventForCalendar(endRow, "mousemove", {
x: endColRect.x + endColRect.width / 2,
y: endRowRect.y - 2,
});
await triggerEventForCalendar(endRow, "mouseup", {
x: endColRect.x + endColRect.width / 2,
y: endRowRect.y - 2,
});
await nextTick();
}
export async function selectDateRange(target, startDate, endDate) {
const start = findDateCell(target, startDate);
const end = findDateCell(target, endDate);
await scrollTo(start);
await triggerEventForCalendar(start, "mousedown");
await scrollTo(end);
await triggerEventForCalendar(end, "mousemove");
await triggerEventForCalendar(end, "mouseup");
await nextTick();
}
export async function selectAllDayRange(target, startDate, endDate) {
const start = findAllDaySlot(target, startDate);
const end = findAllDaySlot(target, endDate);
await scrollTo(start);
await triggerEventForCalendar(start, "mousedown");
await scrollTo(end);
await triggerEventForCalendar(end, "mousemove");
await triggerEventForCalendar(end, "mouseup");
await nextTick();
}
export async function moveEventToDate(target, eventId, date, options = {}) {
const event = findEvent(target, eventId);
const cell = findDateCell(target, date);
await scrollTo(event);
await triggerEventForCalendar(event, "mousedown");
await scrollTo(cell);
await triggerEventForCalendar(cell, "mousemove");
if (!options.disableDrop) {
await triggerEventForCalendar(cell, "mouseup");
}
await nextTick();
}
export async function moveEventToTime(target, eventId, dateTime) {
const event = findEvent(target, eventId);
const [date, time] = dateTime.split(" ");
const col = findDateCol(target, date);
const row = findTimeRow(target, time);
// Find event position
await scrollTo(event);
const eventRect = event.getBoundingClientRect();
const eventPos = {
x: eventRect.x + eventRect.width / 2,
y: eventRect.y,
};
await triggerEventForCalendar(event, "mousedown", eventPos);
// Find target position
await scrollTo(row, false);
const colRect = col.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const toPos = {
x: colRect.x + colRect.width / 2,
y: rowRect.y - 1,
};
await triggerEventForCalendar(row, "mousemove", toPos);
await triggerEventForCalendar(row, "mouseup", toPos);
await nextTick();
}
export async function moveEventToAllDaySlot(target, eventId, date) {
const event = findEvent(target, eventId);
const slot = findAllDaySlot(target, date);
// Find event position
await scrollTo(event);
const eventRect = event.getBoundingClientRect();
const eventPos = {
x: eventRect.x + eventRect.width / 2,
y: eventRect.y,
};
await triggerEventForCalendar(event, "mousedown", eventPos);
// Find target position
await scrollTo(slot);
const slotRect = slot.getBoundingClientRect();
const toPos = {
x: slotRect.x + slotRect.width / 2,
y: slotRect.y - 1,
};
await triggerEventForCalendar(slot, "mousemove", toPos);
await triggerEventForCalendar(slot, "mouseup", toPos);
await nextTick();
}
export async function resizeEventToTime(target, eventId, dateTime) {
const event = findEvent(target, eventId);
const [date, time] = dateTime.split(" ");
const col = findDateCol(target, date);
const row = findTimeRow(target, time);
// Find event position
await scrollTo(event);
await triggerEventForCalendar(event, "mouseover");
// Find event resizer
const resizer = event.querySelector(".fc-event-resizer-end");
resizer.style.display = "block";
resizer.style.width = "100%";
resizer.style.height = "1em";
resizer.style.bottom = "0";
const resizerRect = resizer.getBoundingClientRect();
const resizerPos = {
x: resizerRect.x + resizerRect.width / 2,
y: resizerRect.y + resizerRect.height / 2,
};
await triggerEventForCalendar(resizer, "mousedown", resizerPos);
// Find target position
await scrollTo(row, false);
const colRect = col.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const toPos = {
x: colRect.x + colRect.width / 2,
y: rowRect.y - 1,
};
await triggerEventForCalendar(row, "mousemove", toPos);
await triggerEventForCalendar(row, "mouseup", toPos);
await nextTick();
}
export async function changeScale(target, scale) {
await click(target, `.o_view_scale_selector .scale_button_selection`);
await click(target, `.o-dropdown--menu .o_scale_button_${scale}`);
await nextTick();
}
export async function navigate(target, direction) {
await click(target, `.o_calendar_navigation_buttons .o_calendar_button_${direction}`);
}
export function findFilterPanelSection(target, sectionName) {
return target.querySelector(`.o_calendar_filter[data-name="${sectionName}"]`);
}
export function findFilterPanelFilter(target, sectionName, filterValue) {
return findFilterPanelSection(target, sectionName).querySelector(
`.o_calendar_filter_item[data-value="${filterValue}"]`
);
}
export function findFilterPanelSectionFilter(target, sectionName) {
return findFilterPanelSection(target, sectionName).querySelector(
`.o_calendar_filter_items_checkall`
);
}
export async function toggleFilter(target, sectionName, filterValue) {
const el = findFilterPanelFilter(target, sectionName, filterValue).querySelector(`input`);
await scrollTo(el);
await click(el);
}
export async function toggleSectionFilter(target, sectionName) {
const el = findFilterPanelSectionFilter(target, sectionName).querySelector(`input`);
await scrollTo(el);
await click(el);
}

View file

@ -0,0 +1,44 @@
/** @odoo-module alias=@web/../tests/views/graph_view_tests default=false */
import { click, findChildren, triggerEvent } from "@web/../tests/helpers/utils";
import { ensureArray } from "@web/core/utils/arrays";
// TODO: remove when dependant modules are converted
export function checkLabels(assert, graph, expectedLabels) {
const labels = getGraphRenderer(graph).chart.data.labels.map((l) => l.toString());
assert.deepEqual(labels, expectedLabels);
}
export function checkLegend(assert, graph, expectedLegendLabels) {
expectedLegendLabels = ensureArray(expectedLegendLabels);
const { chart } = getGraphRenderer(graph);
const actualLegendLabels = chart.config.options.plugins.legend.labels
.generateLabels(chart)
.map((o) => o.text);
assert.deepEqual(actualLegendLabels, expectedLegendLabels);
}
export function clickOnDataset(graph) {
const { chart } = getGraphRenderer(graph);
const meta = chart.getDatasetMeta(0);
const rectangle = chart.canvas.getBoundingClientRect();
const point = meta.data[0].getCenterPoint();
return triggerEvent(chart.canvas, null, "click", {
pageX: rectangle.left + point.x,
pageY: rectangle.top + point.y,
});
}
export function getGraphRenderer(graph) {
for (const { component } of Object.values(findChildren(graph).children)) {
if (component.chart) {
return component;
}
}
return null;
}
export function selectMode(el, mode) {
return click(el, `.o_graph_button[data-mode="${mode}"`);
}

View file

@ -0,0 +1,130 @@
/** @odoo-module alias=@web/../tests/views/helpers default=false */
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
import { createDebugContext } from "@web/core/debug/debug_context";
import { Dialog } from "@web/core/dialog/dialog";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { View, getDefaultConfig } from "@web/views/view";
import {
fakeCompanyService,
makeFakeLocalizationService,
patchUserWithCleanup,
} from "../helpers/mock_services";
import {
setupControlPanelFavoriteMenuRegistry,
setupControlPanelServiceRegistry,
} from "../search/helpers";
import { Component, useSubEnv, xml } from "@odoo/owl";
const serviceRegistry = registry.category("services");
const rootDialogTemplate = xml`<Dialog><View t-props="props.viewProps"/></Dialog>`;
/**
* @typedef {{
* serverData: Object,
* mockRPC?: Function,
* type: string,
* resModel: string,
* [prop:string]: any
* }} MakeViewParams
*/
/**
* @template {Component} T
* @param {MakeViewParams} params
* @param {boolean} [inDialog=false]
* @returns {Promise<T>}
*/
async function _makeView(params, inDialog = false) {
const props = { resId: false, ...params };
const serverData = props.serverData;
const mockRPC = props.mockRPC;
const config = {
...getDefaultConfig(),
...props.config,
};
delete props.serverData;
delete props.mockRPC;
delete props.config;
if (props.arch) {
serverData.views = serverData.views || {};
props.viewId = params.viewId || 100000001; // hopefully will not conflict with an id already in views
serverData.views[`${props.resModel},${props.viewId},${props.type}`] = props.arch;
delete props.arch;
props.searchViewId = 100000002; // hopefully will not conflict with an id already in views
const searchViewArch = props.searchViewArch || "<search/>";
serverData.views[`${props.resModel},${props.searchViewId},search`] = searchViewArch;
delete props.searchViewArch;
}
const env = await makeTestEnv({ serverData, mockRPC });
Object.assign(env, createDebugContext(env)); // This is needed if the views are in debug mode
const target = getFixture();
const viewEnv = Object.assign(Object.create(env), { config });
await mount(MainComponentsContainer, target, { env });
let viewNode;
if (inDialog) {
let root;
class RootDialog extends Component {
static components = { Dialog, View };
static template = rootDialogTemplate;
static props = ["*"];
setup() {
root = this;
useSubEnv(viewEnv);
}
}
env.services.dialog.add(RootDialog, { viewProps: props });
await nextTick();
const rootNode = root.__owl__;
const dialogNode = Object.values(rootNode.children)[0];
viewNode = Object.values(dialogNode.children)[0];
} else {
const view = await mount(View, target, { env: viewEnv, props });
await nextTick();
viewNode = view.__owl__;
}
const withSearchNode = Object.values(viewNode.children)[0];
const concreteViewNode = Object.values(withSearchNode.children)[0];
const concreteView = concreteViewNode.component;
return concreteView;
}
/**
* @param {MakeViewParams} params
*/
export function makeView(params) {
return _makeView(params);
}
/**
* @param {MakeViewParams} params
*/
export function makeViewInDialog(params) {
return _makeView(params, true);
}
export function setupViewRegistries() {
setupControlPanelFavoriteMenuRegistry();
setupControlPanelServiceRegistry();
patchUserWithCleanup({
hasGroup: async (group) => {
return [
"base.group_allow_export",
"base.group_user",
].includes(group);
},
isInternalUser: true,
});
serviceRegistry.add("localization", makeFakeLocalizationService());
serviceRegistry.add("company", fakeCompanyService);
}

View file

@ -0,0 +1,122 @@
/** @odoo-module alias=@web/../tests/views/kanban/helpers default=false */
import { makeFakeDialogService } from "@web/../tests/helpers/mock_services";
import { click, editInput, getDropdownMenu, nextTick } from "@web/../tests/helpers/utils";
import { registry } from "@web/core/registry";
export function patchDialog(addDialog) {
registry.category("services").add("dialog", makeFakeDialogService(addDialog), { force: true });
}
// Kanban
// WOWL remove this helper and use the control panel instead
export async function reload(kanban, params = {}) {
kanban.env.searchModel.reload(params);
kanban.env.searchModel.search();
await nextTick();
}
export function getCard(target, cardIndex = 0) {
return target.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")[cardIndex];
}
export function getColumn(target, groupIndex = 0, ignoreFolded = false) {
let selector = ".o_kanban_group";
if (ignoreFolded) {
selector += ":not(.o_column_folded)";
}
return target.querySelectorAll(selector)[groupIndex];
}
export function getColumnDropdownMenu(target, groupIndex = 0, ignoreFolded = false) {
let selector = ".o_kanban_group";
if (ignoreFolded) {
selector += ":not(.o_column_folded)";
}
const column = target.querySelectorAll(selector)[groupIndex];
return getDropdownMenu(target, column);
}
export function getCardTexts(target, groupIndex) {
const root = groupIndex >= 0 ? getColumn(target, groupIndex) : target;
return [...root.querySelectorAll(".o_kanban_record:not(.o_kanban_ghost)")]
.map((card) => card.innerText.trim())
.filter(Boolean);
}
export function getCounters(target) {
return [...target.querySelectorAll(".o_animated_number")].map((counter) => counter.innerText);
}
export function getProgressBars(target, columnIndex) {
const column = getColumn(target, columnIndex);
return [...column.querySelectorAll(".o_column_progress .progress-bar")];
}
export function getTooltips(target, groupIndex) {
const root = groupIndex >= 0 ? getColumn(target, groupIndex) : target;
return [...root.querySelectorAll(".o_column_progress .progress-bar")]
.map((card) => card.dataset.tooltip)
.filter(Boolean);
}
// Record
export async function createRecord(target) {
await click(target, ".o_control_panel_main_buttons button.o-kanban-button-new");
await nextTick();
}
export async function quickCreateRecord(target, groupIndex) {
await click(getColumn(target, groupIndex), ".o_kanban_quick_add");
await nextTick();
}
export async function editQuickCreateInput(target, field, value) {
await editInput(target, `.o_kanban_quick_create .o_field_widget[name=${field}] input`, value);
}
export async function validateRecord(target) {
await click(target, ".o_kanban_quick_create .o_kanban_add");
}
export async function editRecord(target) {
await click(target, ".o_kanban_quick_create .o_kanban_edit");
}
export async function discardRecord(target) {
await click(target, ".o_kanban_quick_create .o_kanban_cancel");
}
export async function toggleRecordDropdown(target, recordIndex) {
const group = target.querySelectorAll(`.o_kanban_record`)[recordIndex];
await click(group, ".o_dropdown_kanban .dropdown-toggle");
}
// Column
export async function createColumn(target) {
await click(target, ".o_column_quick_create > .o_quick_create_folded");
}
export async function editColumnName(target, value) {
await editInput(target, ".o_column_quick_create input", value);
}
export async function validateColumn(target) {
await click(target, ".o_column_quick_create .o_kanban_add");
}
export async function toggleColumnActions(target, columnIndex) {
const group = getColumn(target, columnIndex);
await click(group, ".o_kanban_config .dropdown-toggle");
const buttons = getDropdownMenu(target, group).querySelectorAll(".dropdown-item");
return (buttonText) => {
const re = new RegExp(`\\b${buttonText}\\b`, "i");
const button = [...buttons].find((b) => re.test(b.innerText));
return click(button);
};
}
export async function loadMore(target, columnIndex) {
await click(getColumn(target, columnIndex), ".o_kanban_load_more button");
}

View file

@ -0,0 +1,879 @@
/** @odoo-module alias=@web/../tests/views/list_view_tests default=false */
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { tooltipService } from "@web/core/tooltip/tooltip_service";
import { uiService } from "@web/core/ui/ui_service";
import {
click,
dragAndDrop,
editInput,
getFixture,
getNodesTextContent,
nextTick,
patchWithCleanup,
triggerEvent,
triggerEvents,
triggerHotkey,
} from "../helpers/utils";
import { makeView, setupViewRegistries } from "./helpers";
const serviceRegistry = registry.category("services");
let serverData;
let target;
function getGroup(position) {
return target.querySelectorAll(".o_group_header")[position - 1];
}
QUnit.module("Views", (hooks) => {
hooks.beforeEach(() => {
serverData = {
models: {
foo: {
fields: {
foo: { string: "Foo", type: "char" },
bar: { string: "Bar", type: "boolean" },
date: { string: "Some Date", type: "date" },
int_field: {
string: "int_field",
type: "integer",
sortable: true,
aggregator: "sum",
},
text: { string: "text field", type: "text" },
qux: { string: "my float", type: "float", aggregator: "sum" },
m2o: { string: "M2O field", type: "many2one", relation: "bar" },
o2m: { string: "O2M field", type: "one2many", relation: "bar" },
m2m: { string: "M2M field", type: "many2many", relation: "bar" },
amount: { string: "Monetary field", type: "monetary", aggregator: "sum" },
amount_currency: {
string: "Monetary field (currency)",
type: "monetary",
currency_field: "company_currency_id",
},
currency_id: {
string: "Currency",
type: "many2one",
relation: "res_currency",
default: 1,
},
currency_test: {
string: "Currency",
type: "many2one",
relation: "res_currency",
default: 1,
},
company_currency_id: {
string: "Company Currency",
type: "many2one",
relation: "res_currency",
default: 2,
},
datetime: { string: "Datetime Field", type: "datetime" },
reference: {
string: "Reference Field",
type: "reference",
selection: [
["bar", "Bar"],
["res_currency", "Currency"],
["event", "Event"],
],
},
properties: {
type: "properties",
definition_record: "m2o",
definition_record_field: "definitions",
},
},
records: [
{
id: 1,
bar: true,
foo: "yop",
int_field: 10,
qux: 0.4,
m2o: 1,
m2m: [1, 2],
amount: 1200,
amount_currency: 1100,
currency_id: 2,
company_currency_id: 1,
date: "2017-01-25",
datetime: "2016-12-12 10:55:05",
reference: "bar,1",
properties: [],
},
{
id: 2,
bar: true,
foo: "blip",
int_field: 9,
qux: 13,
m2o: 2,
m2m: [1, 2, 3],
amount: 500,
reference: "res_currency,1",
properties: [],
},
{
id: 3,
bar: true,
foo: "gnap",
int_field: 17,
qux: -3,
m2o: 1,
m2m: [],
amount: 300,
reference: "res_currency,2",
properties: [],
},
{
id: 4,
bar: false,
foo: "blip",
int_field: -4,
qux: 9,
m2o: 1,
m2m: [1],
amount: 0,
properties: [],
},
],
},
bar: {
fields: {
definitions: { type: "properties_definitions" },
},
records: [
{ id: 1, display_name: "Value 1", definitions: [] },
{ id: 2, display_name: "Value 2", definitions: [] },
{ id: 3, display_name: "Value 3", definitions: [] },
],
},
res_currency: {
fields: {
symbol: { string: "Symbol", type: "char" },
position: {
string: "Position",
type: "selection",
selection: [
["after", "A"],
["before", "B"],
],
},
},
records: [
{ id: 1, display_name: "USD", symbol: "$", position: "before" },
{ id: 2, display_name: "EUR", symbol: "€", position: "after" },
],
},
event: {
fields: {
id: { string: "ID", type: "integer" },
name: { string: "name", type: "char" },
},
records: [{ id: "2-20170808020000", name: "virtual" }],
},
},
};
setupViewRegistries();
serviceRegistry.add("tooltip", tooltipService);
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
clearTimeout: () => {},
});
target = getFixture();
serviceRegistry.add("ui", uiService);
});
QUnit.module("ListView");
QUnit.test(
"multi_edit: edit a required field with invalid value and click 'Ok' of alert dialog",
async function (assert) {
serverData.models.foo.fields.foo.required = true;
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list multi_edit="1">
<field name="foo"/>
<field name="int_field"/>
</list>
`,
mockRPC(route, args) {
assert.step(args.method);
},
});
assert.containsN(target, ".o_data_row", 4);
assert.verifySteps(["get_views", "web_search_read"]);
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, "[name='foo'] input", "");
await click(target, ".o_list_view");
assert.containsOnce(target, ".modal");
assert.strictEqual(target.querySelector(".modal .btn").textContent, "Ok");
await click(target.querySelector(".modal .btn"));
assert.strictEqual(
target.querySelector(".o_data_row .o_data_cell[name='foo']").textContent,
"yop"
);
assert.hasClass(target.querySelector(".o_data_row"), "o_data_row_selected");
assert.verifySteps([]);
}
);
QUnit.test(
"multi_edit: edit a required field with invalid value and dismiss alert dialog",
async function (assert) {
serverData.models.foo.fields.foo.required = true;
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list multi_edit="1">
<field name="foo"/>
<field name="int_field"/>
</list>`,
mockRPC(route, args) {
assert.step(args.method);
},
});
assert.containsN(target, ".o_data_row", 4);
assert.verifySteps(["get_views", "web_search_read"]);
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, "[name='foo'] input", "");
await click(target, ".o_list_view");
assert.containsOnce(target, ".modal");
await click(target.querySelector(".modal-header .btn-close"));
assert.strictEqual(
target.querySelector(".o_data_row .o_data_cell[name='foo']").textContent,
"yop"
);
assert.hasClass(target.querySelector(".o_data_row"), "o_data_row_selected");
assert.verifySteps([]);
}
);
QUnit.test("column widths are re-computed on window resize", async function (assert) {
serverData.models.foo.records[0].text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
"Sed blandit, justo nec tincidunt feugiat, mi justo suscipit libero, sit amet tempus " +
"ipsum purus bibendum est.";
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="bottom">
<field name="datetime"/>
<field name="text"/>
</list>`,
});
const initialTextWidth = target.querySelectorAll('th[data-name="text"]')[0].offsetWidth;
const selectorWidth = target.querySelectorAll("th.o_list_record_selector")[0].offsetWidth;
// simulate a window resize
target.style.width = target.getBoundingClientRect().width / 2 + "px";
window.dispatchEvent(new Event("resize"));
const postResizeTextWidth = target.querySelectorAll('th[data-name="text"]')[0].offsetWidth;
const postResizeSelectorWidth = target.querySelectorAll("th.o_list_record_selector")[0]
.offsetWidth;
assert.ok(postResizeTextWidth < initialTextWidth);
assert.strictEqual(selectorWidth, postResizeSelectorWidth);
});
QUnit.test(
"editable list view: multi edition error and cancellation handling",
async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list multi_edit="1">
<field name="foo" required="1"/>
<field name="int_field"/>
</list>`,
});
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
// select two records
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
// edit a line and cancel
await click(rows[0].querySelector(".o_data_cell"));
assert.containsNone(target, ".o_list_record_selector input:enabled");
await editInput(target, ".o_selected_row [name=foo] input", "abc");
await click(target, ".modal .btn.btn-secondary");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"yop10",
"first cell should have discarded any change"
);
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
// edit a line with an invalid format type
await click(rows[0].querySelectorAll(".o_data_cell")[1]);
assert.containsNone(target, ".o_list_record_selector input:enabled");
await editInput(target, ".o_selected_row [name=int_field] input", "hahaha");
assert.containsOnce(target, ".modal", "there should be an opened modal");
await click(target, ".modal .btn-primary");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"yop10",
"changes should be discarded"
);
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
// edit a line with an invalid value
await click(rows[0].querySelector(".o_data_cell"));
assert.containsNone(target, ".o_list_record_selector input:enabled");
await editInput(target, ".o_selected_row [name=foo] input", "");
assert.containsOnce(target, ".modal", "there should be an opened modal");
await click(target, ".modal .btn-primary");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"yop10",
"changes should be discarded"
);
assert.containsN(target, ".o_list_record_selector input:enabled", 5);
}
);
QUnit.test(
'editable list view: mousedown on "Discard", mouseup somewhere else (no multi-edit)',
async function (assert) {
await makeView({
type: "list",
arch: `
<list editable="top">
<field name="foo"/>
</list>`,
mockRPC(route, args) {
assert.step(args.method);
},
serverData,
resModel: "foo",
});
// select two records
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
await click(rows[1], ".o_list_record_selector input");
await click(rows[0].querySelector(".o_data_cell"));
target.querySelector(".o_data_row .o_data_cell input").value = "oof";
await triggerEvents($(".o_list_button_discard:visible").get(0), null, ["mousedown"]);
await triggerEvents(target, ".o_data_row .o_data_cell input", [
"change",
"blur",
"focusout",
]);
await triggerEvents(target, null, ["focus"]);
await triggerEvents(target, null, ["mouseup"]);
await click(target);
assert.containsNone(document.body, ".modal", "should not open modal");
assert.deepEqual(getNodesTextContent(target.querySelectorAll(".o_data_cell")), [
"oof",
"blip",
"gnap",
"blip",
]);
assert.verifySteps(["get_views", "web_search_read", "web_save"]);
}
);
QUnit.test(
"editable readonly list view: single edition does not behave like a multi-edition",
async function (assert) {
await makeView({
type: "list",
arch: `
<list multi_edit="1">
<field name="foo" required="1"/>
</list>`,
serverData,
resModel: "foo",
});
// select a record
const rows = target.querySelectorAll(".o_data_row");
await click(rows[0], ".o_list_record_selector input");
// edit a field (invalid input)
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, ".o_data_row [name=foo] input", "");
assert.containsOnce(target, ".modal", "should have a modal (invalid fields)");
await click(target, ".modal button.btn");
// edit a field
await click(rows[0].querySelector(".o_data_cell"));
await editInput(target, ".o_data_row [name=foo] input", "bar");
assert.containsNone(target, ".modal", "should not have a modal");
assert.strictEqual(
$(target).find(".o_data_row:eq(0) .o_data_cell").text(),
"bar",
"the first row should be updated"
);
}
);
QUnit.test(
"pressing ESC in editable grouped list should discard the current line changes",
async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<list editable="top"><field name="foo"/><field name="bar"/></list>',
groupBy: ["bar"],
});
await click(target.querySelectorAll(".o_group_header")[1]); // open second group
assert.containsN(target, "tr.o_data_row", 3);
await click(target.querySelector(".o_data_cell"));
// update foo field of edited row
await editInput(target, ".o_data_cell [name=foo] input", "new_value");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_data_cell [name=foo] input")
);
// discard by pressing ESC
triggerHotkey("Escape");
await nextTick();
assert.containsNone(target, ".modal");
assert.containsOnce(target, "tbody tr td:contains(yop)");
assert.containsN(target, "tr.o_data_row", 3);
assert.containsNone(target, "tr.o_data_row.o_selected_row");
assert.isNotVisible(target.querySelector(".o_list_button_save"));
}
);
QUnit.test("editing then pressing TAB in editable grouped list", async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<list editable="bottom"><field name="foo"/></list>',
mockRPC(route, args) {
assert.step(args.method || route);
},
groupBy: ["bar"],
});
// open two groups
await click(getGroup(1));
assert.containsN(target, ".o_data_row", 1, "first group contains 1 rows");
await click(getGroup(2));
assert.containsN(target, ".o_data_row", 4, "first group contains 3 row");
// select and edit last row of first group
await click(target.querySelector(".o_data_row").querySelector(".o_data_cell"));
assert.hasClass($(target).find(".o_data_row:nth(0)"), "o_selected_row");
await editInput(target, '.o_selected_row [name="foo"] input', "new value");
// Press 'Tab' -> should create a new record as we edited the previous one
triggerHotkey("Tab");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.hasClass($(target).find(".o_data_row:nth(1)"), "o_selected_row");
// fill foo field for the new record and press 'tab' -> should create another record
await editInput(target, '.o_selected_row [name="foo"] input', "new record");
triggerHotkey("Tab");
await nextTick();
assert.containsN(target, ".o_data_row", 6);
assert.hasClass($(target).find(".o_data_row:nth(2)"), "o_selected_row");
// leave this new row empty and press tab -> should discard the new record and move to the
// next group
triggerHotkey("Tab");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.hasClass($(target).find(".o_data_row:nth(2)"), "o_selected_row");
assert.verifySteps([
"get_views",
"web_read_group",
"web_search_read",
"web_search_read",
"web_save",
"onchange",
"web_save",
"onchange",
]);
});
QUnit.test("cell-level keyboard navigation in editable grouped list", async function (assert) {
serverData.models.foo.records[0].bar = false;
serverData.models.foo.records[1].bar = false;
serverData.models.foo.records[2].bar = false;
serverData.models.foo.records[3].bar = true;
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="bottom">
<field name="foo" required="1"/>
</list>`,
groupBy: ["bar"],
});
await click(target.querySelector(".o_group_name"));
const secondDataRow = target.querySelectorAll(".o_data_row")[1];
await click(secondDataRow, "[name=foo]");
assert.hasClass(secondDataRow, "o_selected_row");
await editInput(secondDataRow, "[name=foo] input", "blipbloup");
triggerHotkey("Escape");
await nextTick();
assert.containsNone(document.body, ".modal");
assert.doesNotHaveClass(secondDataRow, "o_selected_row");
assert.strictEqual(document.activeElement, secondDataRow.querySelector("[name=foo]"));
assert.strictEqual(document.activeElement.textContent, "blip");
triggerHotkey("ArrowLeft");
assert.strictEqual(
document.activeElement,
secondDataRow.querySelector("input[type=checkbox]")
);
triggerHotkey("ArrowUp");
triggerHotkey("ArrowRight");
const firstDataRow = target.querySelector(".o_data_row");
assert.strictEqual(document.activeElement, firstDataRow.querySelector("[name=foo]"));
triggerHotkey("Enter");
await nextTick();
assert.hasClass(firstDataRow, "o_selected_row");
await editInput(firstDataRow, "[name=foo] input", "Zipadeedoodah");
triggerHotkey("Enter");
await nextTick();
assert.strictEqual(firstDataRow.querySelector("[name=foo]").innerText, "Zipadeedoodah");
assert.doesNotHaveClass(firstDataRow, "o_selected_row");
assert.hasClass(secondDataRow, "o_selected_row");
assert.strictEqual(document.activeElement, secondDataRow.querySelector("[name=foo] input"));
assert.strictEqual(document.activeElement.value, "blip");
triggerHotkey("ArrowUp");
triggerHotkey("ArrowRight");
await nextTick();
assert.strictEqual(document.activeElement, secondDataRow.querySelector("[name=foo] input"));
assert.strictEqual(document.activeElement.value, "blip");
triggerHotkey("ArrowDown");
triggerHotkey("ArrowLeft");
await nextTick();
assert.strictEqual(
document.activeElement,
secondDataRow.querySelector("td[name=foo] input")
);
assert.strictEqual(document.activeElement.value, "blip");
triggerHotkey("Escape");
await nextTick();
assert.doesNotHaveClass(secondDataRow, "o_selected_row");
assert.strictEqual(document.activeElement, secondDataRow.querySelector("td[name=foo]"));
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_field_row_add a")
);
triggerHotkey("ArrowDown");
const secondGroupHeader = target.querySelectorAll(".o_group_name")[1];
assert.strictEqual(document.activeElement, secondGroupHeader);
assert.containsN(target, ".o_data_row", 3);
triggerHotkey("Enter");
await nextTick();
assert.containsN(target, ".o_data_row", 4);
assert.strictEqual(document.activeElement, secondGroupHeader);
triggerHotkey("ArrowDown");
const fourthDataRow = target.querySelectorAll(".o_data_row")[3];
assert.strictEqual(document.activeElement, fourthDataRow.querySelector("[name=foo]"));
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelectorAll(".o_group_field_row_add a")[1]
);
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelectorAll(".o_group_field_row_add a")[1]
);
// default Enter on a A tag
const event = await triggerEvent(document.activeElement, null, "keydown", { key: "Enter" });
assert.ok(!event.defaultPrevented);
await click(target.querySelectorAll(".o_group_field_row_add a")[1]);
const fifthDataRow = target.querySelectorAll(".o_data_row")[4];
assert.strictEqual(document.activeElement, fifthDataRow.querySelector("[name=foo] input"));
await editInput(
fifthDataRow.querySelector("[name=foo] input"),
null,
"cheateur arrete de cheater"
);
triggerHotkey("Enter");
await nextTick();
assert.containsN(target, ".o_data_row", 6);
triggerHotkey("Escape");
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelectorAll(".o_group_field_row_add a")[1]
);
// come back to the top
for (let i = 0; i < 9; i++) {
triggerHotkey("ArrowUp");
}
assert.strictEqual(document.activeElement, target.querySelector("thead th:nth-child(2)"));
triggerHotkey("ArrowLeft");
assert.strictEqual(
document.activeElement,
target.querySelector("thead th.o_list_record_selector input")
);
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
triggerHotkey("ArrowRight");
assert.strictEqual(document.activeElement, firstDataRow.querySelector("td[name=foo]"));
triggerHotkey("ArrowUp");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
assert.containsN(target, ".o_data_row", 5);
triggerHotkey("Enter");
await nextTick();
assert.containsN(target, ".o_data_row", 2);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowRight");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowRight");
await nextTick();
assert.containsN(target, ".o_data_row", 5);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowLeft");
await nextTick();
assert.containsN(target, ".o_data_row", 2);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowLeft");
await nextTick();
assert.containsN(target, ".o_data_row", 2);
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(1) .o_group_name")
);
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_header:nth-child(2) .o_group_name")
);
triggerHotkey("ArrowDown");
const firstVisibleDataRow = target.querySelector(".o_data_row");
assert.strictEqual(document.activeElement, firstVisibleDataRow.querySelector("[name=foo]"));
triggerHotkey("ArrowDown");
const secondVisibleDataRow = target.querySelectorAll(".o_data_row")[1];
assert.strictEqual(
document.activeElement,
secondVisibleDataRow.querySelector("[name=foo]")
);
triggerHotkey("ArrowDown");
assert.strictEqual(
document.activeElement,
target.querySelector(".o_group_field_row_add a")
);
triggerHotkey("ArrowUp");
assert.strictEqual(
document.activeElement,
secondVisibleDataRow.querySelector("[name=foo]")
);
triggerHotkey("ArrowUp");
assert.strictEqual(document.activeElement, firstVisibleDataRow.querySelector("[name=foo]"));
});
QUnit.test("editable list: resize column headers", async function (assert) {
// This test will ensure that, on resize list header,
// the resized element have the correct size and other elements are not resized
serverData.models.foo.records[0].foo = "a".repeat(200);
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="top">
<field name="foo"/>
<field name="bar"/>
<field name="reference" optional="hide"/>
</list>`,
});
// Target handle
const th = target.querySelector("th:nth-child(2)");
const thNext = target.querySelector("th:nth-child(3)");
const resizeHandle = th.querySelector(".o_resize");
const nextResizeHandle = thNext.querySelector(".o_resize");
const thOriginalWidth = th.getBoundingClientRect().width;
const thNextOriginalWidth = thNext.getBoundingClientRect().width;
const thExpectedWidth = thOriginalWidth + thNextOriginalWidth;
await dragAndDrop(resizeHandle, nextResizeHandle);
const thFinalWidth = th.getBoundingClientRect().width;
const thNextFinalWidth = thNext.getBoundingClientRect().width;
assert.ok(
Math.abs(Math.floor(thFinalWidth) - Math.floor(thExpectedWidth)) <= 1,
`Wrong width on resize (final: ${thFinalWidth}, expected: ${thExpectedWidth})`
);
assert.strictEqual(
Math.floor(thNextOriginalWidth),
Math.floor(thNextFinalWidth),
"Width must not have been changed"
);
});
QUnit.test(
"continue creating new lines in editable=top on keyboard nav",
async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: `
<list editable="top">
<field name="int_field"/>
</list>`,
});
const initialRowCount = $(".o_data_cell[name=int_field]").length;
// click on int_field cell of first row
await click($(".o_list_button_add:visible").get(0));
await editInput(target, ".o_data_cell[name=int_field] input", "1");
triggerHotkey("Tab");
await nextTick();
await editInput(target, ".o_data_cell[name=int_field] input", "2");
triggerHotkey("Enter");
await nextTick();
// 3 new rows: the two created ("1" and "2", and a new still in edit mode)
assert.strictEqual($(".o_data_cell[name=int_field]").length, initialRowCount + 3);
}
);
});