mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 18:52:02 +02:00
vanilla 18.0
This commit is contained in:
parent
5454004ff9
commit
d7f6d2725e
979 changed files with 428093 additions and 0 deletions
|
|
@ -0,0 +1,296 @@
|
|||
/** @odoo-module alias=@web/../tests/core/condition_tree_editor_helpers default=false */
|
||||
|
||||
import { getNodesTextContent, editInput, click, editSelect } from "../helpers/utils";
|
||||
import { fieldService } from "@web/core/field_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { makeFakeLocalizationService } from "../helpers/mock_services";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { popoverService } from "@web/core/popover/popover_service";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { nameService } from "@web/core/name_service";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
|
||||
export function setupConditionTreeEditorServices() {
|
||||
registry.category("services").add("popover", popoverService);
|
||||
registry.category("services").add("orm", ormService);
|
||||
registry.category("services").add("ui", uiService);
|
||||
registry.category("services").add("hotkey", hotkeyService);
|
||||
registry.category("services").add("localization", makeFakeLocalizationService());
|
||||
registry.category("services").add("field", fieldService);
|
||||
registry.category("services").add("name", nameService);
|
||||
registry.category("services").add("dialog", dialogService);
|
||||
registry.category("services").add("datetime_picker", datetimePickerService);
|
||||
registry.category("services").add("notification", notificationService);
|
||||
}
|
||||
|
||||
export function makeServerData() {
|
||||
const serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char", searchable: true },
|
||||
bar: { string: "Bar", type: "boolean", searchable: true },
|
||||
product_id: {
|
||||
string: "Product",
|
||||
type: "many2one",
|
||||
relation: "product",
|
||||
searchable: true,
|
||||
},
|
||||
date: { string: "Date", type: "date", searchable: true },
|
||||
datetime: { string: "Date Time", type: "datetime", searchable: true },
|
||||
int: { string: "Integer", type: "integer", searchable: true },
|
||||
json_field: { string: "Json Field", type: "json", searchable: true },
|
||||
state: {
|
||||
string: "State",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["abc", "ABC"],
|
||||
["def", "DEF"],
|
||||
["ghi", "GHI"],
|
||||
],
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{ id: 1, foo: "yop", bar: true, product_id: 37 },
|
||||
{ id: 2, foo: "blip", bar: true, product_id: false },
|
||||
{ id: 4, foo: "abc", bar: false, product_id: 41 },
|
||||
],
|
||||
onchanges: {},
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
name: { string: "Product Name", type: "char", searchable: true },
|
||||
},
|
||||
records: [
|
||||
{ id: 37, display_name: "xphone" },
|
||||
{ id: 41, display_name: "xpad" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
return serverData;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const SELECTORS = {
|
||||
node: ".o_tree_editor_node",
|
||||
row: ".o_tree_editor_row",
|
||||
tree: ".o_tree_editor > .o_tree_editor_node",
|
||||
connector: ".o_tree_editor_connector",
|
||||
condition: ".o_tree_editor_condition",
|
||||
addNewRule: ".o_tree_editor_row > a",
|
||||
buttonAddNewRule: ".o_tree_editor_node_control_panel > button:nth-child(1)",
|
||||
buttonAddBranch: ".o_tree_editor_node_control_panel > button:nth-child(2)",
|
||||
buttonDeleteNode: ".o_tree_editor_node_control_panel > button:nth-child(3)",
|
||||
pathEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(1)",
|
||||
operatorEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(2)",
|
||||
valueEditor: ".o_tree_editor_condition > .o_tree_editor_editor:nth-child(3)",
|
||||
editor: ".o_tree_editor_editor",
|
||||
clearNotSupported: ".o_input .fa-times",
|
||||
tag: ".o_input .o_tag",
|
||||
toggleArchive: ".form-switch",
|
||||
complexCondition: ".o_tree_editor_complex_condition",
|
||||
complexConditionInput: ".o_tree_editor_complex_condition input",
|
||||
};
|
||||
|
||||
const CHILD_SELECTOR = ["connector", "condition", "complexCondition"]
|
||||
.map((k) => SELECTORS[k])
|
||||
.join(",");
|
||||
export function getTreeEditorContent(target, options = {}) {
|
||||
const content = [];
|
||||
const nodes = target.querySelectorAll(SELECTORS.node);
|
||||
const mapping = new Map();
|
||||
for (const node of nodes) {
|
||||
const parent = node.parentElement.closest(SELECTORS.node);
|
||||
const level = parent ? mapping.get(parent) + 1 : 0;
|
||||
mapping.set(node, level);
|
||||
const nodeValue = { level };
|
||||
const associatedNode = node.querySelector(CHILD_SELECTOR);
|
||||
const className = associatedNode.className;
|
||||
if (className.includes("connector")) {
|
||||
nodeValue.value = getCurrentConnector(node);
|
||||
} else if (className.includes("complex_condition")) {
|
||||
nodeValue.value = getCurrentComplexCondition(node);
|
||||
} else {
|
||||
nodeValue.value = getCurrentCondition(node);
|
||||
}
|
||||
if (options.node) {
|
||||
nodeValue.node = node;
|
||||
}
|
||||
content.push(nodeValue);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function get(target, selector, index = 0) {
|
||||
if (index) {
|
||||
return [...target.querySelectorAll(selector)].at(index);
|
||||
}
|
||||
return target.querySelector(selector);
|
||||
}
|
||||
|
||||
function getValue(target) {
|
||||
if (target) {
|
||||
const el = target.querySelector("input,select,span:not(.o_tag)");
|
||||
switch (el.tagName) {
|
||||
case "INPUT":
|
||||
return el.value;
|
||||
case "SELECT":
|
||||
return el.options[el.selectedIndex].label;
|
||||
case "SPAN":
|
||||
return el.innerText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentPath(target, index = 0) {
|
||||
const pathEditor = get(target, SELECTORS.pathEditor, index);
|
||||
if (pathEditor) {
|
||||
if (pathEditor.querySelector(".o_model_field_selector")) {
|
||||
return getModelFieldSelectorValues(pathEditor).join(" > ");
|
||||
}
|
||||
return pathEditor.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentOperator(target, index = 0) {
|
||||
const operatorEditor = get(target, SELECTORS.operatorEditor, index);
|
||||
return getValue(operatorEditor);
|
||||
}
|
||||
|
||||
export function getCurrentValue(target, index) {
|
||||
const valueEditor = get(target, SELECTORS.valueEditor, index);
|
||||
const value = getValue(valueEditor);
|
||||
if (valueEditor) {
|
||||
const tags = [...valueEditor.querySelectorAll(".o_tag")];
|
||||
if (tags.length) {
|
||||
let text = `${tags.map((t) => t.innerText).join(" ")}`;
|
||||
if (value) {
|
||||
text += ` ${value}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getOperatorOptions(target, index = 0) {
|
||||
const el = get(target, SELECTORS.operatorEditor, index);
|
||||
if (el) {
|
||||
const select = el.querySelector("select");
|
||||
return [...select.options].map((o) => o.label);
|
||||
}
|
||||
}
|
||||
|
||||
export function getValueOptions(target, index = 0) {
|
||||
const el = get(target, SELECTORS.valueEditor, index);
|
||||
if (el) {
|
||||
const select = el.querySelector("select");
|
||||
return [...select.options].map((o) => o.label);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentComplexCondition(target, index = 0) {
|
||||
const input = get(target, SELECTORS.complexConditionInput, index);
|
||||
return input?.value;
|
||||
}
|
||||
|
||||
export function getConditionText(target, index = 0) {
|
||||
const condition = get(target, SELECTORS.condition, index);
|
||||
if (condition) {
|
||||
const texts = [];
|
||||
for (const t of getNodesTextContent(condition.childNodes)) {
|
||||
const t2 = t.trim();
|
||||
if (t2) {
|
||||
texts.push(t2);
|
||||
}
|
||||
}
|
||||
return texts.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentCondition(target, index = 0) {
|
||||
const values = [getCurrentPath(target, index), getCurrentOperator(target, index)];
|
||||
const valueEditor = get(target, SELECTORS.valueEditor, index);
|
||||
if (valueEditor) {
|
||||
values.push(getCurrentValue(target, index));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function getCurrentConnector(target, index = 0) {
|
||||
const connector = get(
|
||||
target,
|
||||
`${SELECTORS.connector} .dropdown-toggle, ${SELECTORS.connector} > span:nth-child(2), ${SELECTORS.connector} > span > strong`,
|
||||
index
|
||||
);
|
||||
return connector?.textContent.search("all") >= 0 ? "all" : connector?.textContent;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export function isNotSupportedPath(target, index = 0) {
|
||||
const pathEditor = get(target, SELECTORS.pathEditor, index);
|
||||
return Boolean(pathEditor.querySelector(SELECTORS.clearNotSupported));
|
||||
}
|
||||
|
||||
export function isNotSupportedOperator(target, index = 0) {
|
||||
const operatorEditor = get(target, SELECTORS.operatorEditor, index);
|
||||
return Boolean(operatorEditor.querySelector(SELECTORS.clearNotSupported));
|
||||
}
|
||||
|
||||
export function isNotSupportedValue(target, index = 0) {
|
||||
const valueEditor = get(target, SELECTORS.valueEditor, index);
|
||||
return Boolean(valueEditor.querySelector(SELECTORS.clearNotSupported));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export async function selectOperator(target, operator, index = 0) {
|
||||
const el = get(target, SELECTORS.operatorEditor, index);
|
||||
await editSelect(el, "select", JSON.stringify(operator));
|
||||
}
|
||||
|
||||
export async function selectValue(target, value, index = 0) {
|
||||
const el = get(target, SELECTORS.valueEditor, index);
|
||||
await editSelect(el, "select", JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function editValue(target, value, index = 0) {
|
||||
const el = get(target, SELECTORS.valueEditor, index);
|
||||
await editInput(el, "input", value);
|
||||
}
|
||||
|
||||
export async function clickOnButtonAddNewRule(target, index = 0) {
|
||||
await click(get(target, SELECTORS.buttonAddNewRule, index));
|
||||
}
|
||||
|
||||
export async function clickOnButtonAddBranch(target, index = 0) {
|
||||
await click(get(target, SELECTORS.buttonAddBranch, index));
|
||||
}
|
||||
|
||||
export async function clickOnButtonDeleteNode(target, index = 0) {
|
||||
await click(get(target, SELECTORS.buttonDeleteNode, index));
|
||||
}
|
||||
|
||||
export async function clearNotSupported(target, index = 0) {
|
||||
await click(get(target, SELECTORS.clearNotSupported, index));
|
||||
}
|
||||
|
||||
export async function addNewRule(target, index = 0) {
|
||||
await click(get(target, SELECTORS.addNewRule, index));
|
||||
}
|
||||
|
||||
export async function toggleArchive(target) {
|
||||
await click(target, SELECTORS.toggleArchive);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function getModelFieldSelectorValues(target) {
|
||||
return getNodesTextContent(target.querySelectorAll("span.o_model_field_selector_chain_part"));
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/** @odoo-module alias=@web/../tests/core/datetime/datetime_test_helpers default=false */
|
||||
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { ensureArray } from "@web/core/utils/arrays";
|
||||
import { click, getFixture } from "../../helpers/utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {false | {
|
||||
* title?: string | string[],
|
||||
* date?: {
|
||||
* cells: (number | string | [number] | [string])[][],
|
||||
* daysOfWeek?: string[],
|
||||
* weekNumbers?: number[],
|
||||
* }[],
|
||||
* time?: ([number, number] | [number, number, "AM" | "PM"])[],
|
||||
* }} expectedParams
|
||||
*/
|
||||
export function assertDateTimePicker(expectedParams) {
|
||||
const assert = QUnit.assert;
|
||||
const fixture = getFixture();
|
||||
|
||||
// Check for picker in DOM
|
||||
if (expectedParams) {
|
||||
assert.containsOnce(fixture, ".o_datetime_picker");
|
||||
} else {
|
||||
assert.containsNone(fixture, ".o_datetime_picker");
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, date, time } = expectedParams;
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
const expectedTitle = ensureArray(title);
|
||||
assert.containsOnce(fixture, ".o_datetime_picker_header");
|
||||
assert.deepEqual(
|
||||
getTexts(".o_datetime_picker_header", "strong"),
|
||||
expectedTitle,
|
||||
`title should be "${expectedTitle.join(" - ")}"`
|
||||
);
|
||||
} else {
|
||||
assert.containsNone(fixture, ".o_datetime_picker_header");
|
||||
}
|
||||
|
||||
// Time picker
|
||||
if (time) {
|
||||
assert.containsN(fixture, ".o_time_picker", time.length);
|
||||
const timePickers = select(".o_time_picker");
|
||||
for (let i = 0; i < time.length; i++) {
|
||||
const expectedTime = time[i];
|
||||
const values = select(timePickers[i], ".o_time_picker_select").map((sel) => sel.value);
|
||||
const actual = [...values.slice(0, 2).map(Number), ...values.slice(2)];
|
||||
assert.deepEqual(actual, expectedTime, `time values should be [${expectedTime}]`);
|
||||
}
|
||||
} else {
|
||||
assert.containsNone(fixture, ".o_time_picker");
|
||||
}
|
||||
|
||||
// Date picker
|
||||
const datePickerEls = select(".o_date_picker");
|
||||
assert.containsN(fixture, ".o_date_picker", date.length);
|
||||
|
||||
let selectedCells = 0;
|
||||
let outOfRangeCells = 0;
|
||||
let todayCells = 0;
|
||||
for (let i = 0; i < date.length; i++) {
|
||||
const { cells, daysOfWeek, weekNumbers } = date[i];
|
||||
const datePickerEl = datePickerEls[i];
|
||||
const cellEls = select(datePickerEl, ".o_date_item_cell");
|
||||
|
||||
assert.strictEqual(
|
||||
cellEls.length,
|
||||
PICKER_ROWS * PICKER_COLS,
|
||||
`picker should have ${
|
||||
PICKER_ROWS * PICKER_COLS
|
||||
} cells (${PICKER_ROWS} rows and ${PICKER_COLS} columns)`
|
||||
);
|
||||
|
||||
if (daysOfWeek) {
|
||||
const actualDow = getTexts(datePickerEl, ".o_day_of_week_cell");
|
||||
assert.deepEqual(
|
||||
actualDow,
|
||||
daysOfWeek,
|
||||
`picker should display the days of week: ${daysOfWeek
|
||||
.map((dow) => `"${dow}"`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (weekNumbers) {
|
||||
assert.deepEqual(
|
||||
getTexts(datePickerEl, ".o_week_number_cell").map(Number),
|
||||
weekNumbers,
|
||||
`picker should display the week numbers (${weekNumbers.join(", ")})`
|
||||
);
|
||||
}
|
||||
|
||||
// Date cells
|
||||
const expectedCells = cells.flatMap((row, rowIndex) =>
|
||||
row.map((cell, colIndex) => {
|
||||
const cellEl = cellEls[rowIndex * PICKER_COLS + colIndex];
|
||||
|
||||
// Check flags
|
||||
let value = cell;
|
||||
const isSelected = Array.isArray(cell);
|
||||
if (isSelected) {
|
||||
value = value[0];
|
||||
}
|
||||
const isToday = typeof value === "string";
|
||||
if (isToday) {
|
||||
value = Number(value);
|
||||
}
|
||||
const isOutOfRange = value < 0;
|
||||
if (isOutOfRange) {
|
||||
value = Math.abs(value);
|
||||
}
|
||||
|
||||
// Assert based on flags
|
||||
if (isSelected) {
|
||||
selectedCells++;
|
||||
assert.hasClass(cellEl, "o_selected");
|
||||
}
|
||||
if (isOutOfRange) {
|
||||
outOfRangeCells++;
|
||||
assert.hasClass(cellEl, "o_out_of_range");
|
||||
}
|
||||
if (isToday) {
|
||||
todayCells++;
|
||||
assert.hasClass(cellEl, "o_today");
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
cellEls.map((cell) => Number(getTexts(cell)[0])),
|
||||
expectedCells,
|
||||
`cell content should match the expected values: [${expectedCells.join(", ")}]`
|
||||
);
|
||||
}
|
||||
|
||||
assert.containsN(fixture, ".o_selected", selectedCells);
|
||||
assert.containsN(fixture, ".o_out_of_range", outOfRangeCells);
|
||||
assert.containsN(fixture, ".o_today", todayCells);
|
||||
}
|
||||
|
||||
export function getPickerApplyButton() {
|
||||
return select(".o_datetime_picker .o_datetime_buttons .o_apply").at(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RegExp | string} expr
|
||||
*/
|
||||
export function getPickerCell(expr) {
|
||||
const regex = expr instanceof RegExp ? expr : new RegExp(`^${expr}$`, "i");
|
||||
const cells = select(".o_datetime_picker .o_date_item_cell").filter((cell) =>
|
||||
regex.test(getTexts(cell)[0])
|
||||
);
|
||||
return cells.length === 1 ? cells[0] : cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {...(string | HTMLElement)} selectors
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getTexts(...selectors) {
|
||||
return select(...selectors).map((e) => e.innerText.trim().replace(/\s+/g, " "));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.parse=false] whether to directly return the parsed
|
||||
* values of the select elements
|
||||
* @returns {HTMLSelectElement[] | (number | string)[]}
|
||||
*/
|
||||
export function getTimePickers({ parse = false } = {}) {
|
||||
return select(".o_time_picker").map((timePickerEl) => {
|
||||
const selects = select(timePickerEl, ".o_time_picker_select");
|
||||
if (parse) {
|
||||
return selects.map((sel) => (isNaN(sel.value) ? sel.value : Number(sel.value)));
|
||||
} else {
|
||||
return selects;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {...(string | HTMLElement)} selectors
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
const select = (...selectors) => {
|
||||
const root = selectors[0] instanceof Element ? selectors.shift() : getFixture();
|
||||
return selectors.length ? [...root.querySelectorAll(selectors.join(" "))] : [root];
|
||||
};
|
||||
|
||||
export function useTwelveHourClockFormat() {
|
||||
const { dateFormat = "dd/MM/yyyy", timeFormat = "HH:mm:ss" } = localization;
|
||||
const twcTimeFormat = `${timeFormat.replace(/H/g, "h")} a`;
|
||||
patchWithCleanup(localization, {
|
||||
dateTimeFormat: `${dateFormat} ${twcTimeFormat}`,
|
||||
timeFormat: twcTimeFormat,
|
||||
});
|
||||
}
|
||||
|
||||
export function zoomOut() {
|
||||
return click(getFixture(), ".o_zoom_out");
|
||||
}
|
||||
|
||||
const PICKER_ROWS = 6;
|
||||
const PICKER_COLS = 7;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/** @odoo-module alias=@web/../tests/core/domain_selector_tests default=false */
|
||||
|
||||
import {
|
||||
SELECTORS as treeEditorSELECTORS,
|
||||
} from "./condition_tree_editor_helpers";
|
||||
|
||||
export {
|
||||
addNewRule,
|
||||
clearNotSupported,
|
||||
clickOnButtonAddBranch,
|
||||
clickOnButtonAddNewRule,
|
||||
clickOnButtonDeleteNode,
|
||||
editValue,
|
||||
getConditionText,
|
||||
getCurrentOperator,
|
||||
getCurrentPath,
|
||||
getCurrentValue,
|
||||
getOperatorOptions,
|
||||
isNotSupportedOperator,
|
||||
isNotSupportedPath,
|
||||
isNotSupportedValue,
|
||||
selectOperator,
|
||||
selectValue,
|
||||
toggleArchive,
|
||||
} from "./condition_tree_editor_helpers";
|
||||
|
||||
export const SELECTORS = {
|
||||
...treeEditorSELECTORS,
|
||||
debugArea: ".o_domain_selector_debug_container textarea",
|
||||
resetButton: ".o_domain_selector_row > button",
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/** @odoo-module alias=@web/../tests/core/utils/nested_sortable_tests default=false */
|
||||
|
||||
import { drag, getFixture } from "@web/../tests/helpers/utils";
|
||||
|
||||
/**
|
||||
* Dragging methods taking into account the fact that it's the top of the
|
||||
* dragged element that triggers the moves (not the position of the cursor),
|
||||
* and the fact that during the first move, the dragged element is replaced by
|
||||
* a placeholder that does not have the same height. The moves are done with
|
||||
* the same x position to prevent triggering horizontal moves.
|
||||
* @param {string} from
|
||||
*/
|
||||
export const sortableDrag = async (from) => {
|
||||
const fixture = getFixture();
|
||||
const fromEl = fixture.querySelector(from);
|
||||
const fromRect = fromEl.getBoundingClientRect();
|
||||
const { drop, moveTo } = await drag(from);
|
||||
let isFirstMove = true;
|
||||
|
||||
/**
|
||||
* @param {string} [targetSelector]
|
||||
*/
|
||||
const moveAbove = async (targetSelector) => {
|
||||
const el = fixture.querySelector(targetSelector);
|
||||
await moveTo(el, {
|
||||
x: fromRect.x - el.getBoundingClientRect().x + fromRect.width / 2,
|
||||
y: fromRect.height / 2 + 5,
|
||||
});
|
||||
isFirstMove = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} [targetSelector]
|
||||
*/
|
||||
const moveUnder = async (targetSelector) => {
|
||||
const el = fixture.querySelector(targetSelector);
|
||||
const elRect = el.getBoundingClientRect();
|
||||
let firstMoveBelow = false;
|
||||
if (isFirstMove && elRect.y > fromRect.y) {
|
||||
// Need to consider that the moved element will be replaced by a
|
||||
// placeholder with a height of 5px
|
||||
firstMoveBelow = true;
|
||||
}
|
||||
await moveTo(el, {
|
||||
x: fromRect.x - elRect.x + fromRect.width / 2,
|
||||
y:
|
||||
((firstMoveBelow ? -1 : 1) * fromRect.height) / 2 +
|
||||
elRect.height +
|
||||
(firstMoveBelow ? 4 : -1),
|
||||
});
|
||||
isFirstMove = false;
|
||||
};
|
||||
|
||||
return { moveAbove, moveUnder, drop };
|
||||
};
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/** @odoo-module alias=@web/../tests/helpers/cleanup default=false */
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const cleanups = [];
|
||||
|
||||
/**
|
||||
* Register a cleanup callback that will be executed whenever the current test
|
||||
* is done.
|
||||
*
|
||||
* - the cleanups will be executed in reverse order
|
||||
* - they will be executed even if the test fails/crashes
|
||||
*
|
||||
* @param {Function} callback
|
||||
*/
|
||||
export function registerCleanup(callback) {
|
||||
cleanups.push(callback);
|
||||
}
|
||||
|
||||
if (window.QUnit) {
|
||||
QUnit.on("OdooAfterTestHook", (info) => {
|
||||
if (QUnit.config.debug) {
|
||||
return;
|
||||
}
|
||||
let cleanup;
|
||||
// note that this calls the cleanup callbacks in reverse order!
|
||||
while ((cleanup = cleanups.pop())) {
|
||||
try {
|
||||
cleanup(info);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Check leftovers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List of elements tolerated in the body after a test. The property "keep"
|
||||
* prevents the element from being removed (typically: qunit suite elements).
|
||||
*/
|
||||
const validElements = [
|
||||
// always in the body:
|
||||
{ tagName: "DIV", attr: "id", value: "qunit", keep: true },
|
||||
{ tagName: "DIV", attr: "id", value: "qunit-fixture", keep: true },
|
||||
// shouldn't be in the body after a test but are tolerated:
|
||||
{ tagName: "SCRIPT", attr: "id", value: "" },
|
||||
{ tagName: "DIV", attr: "class", value: "o_notification_manager" },
|
||||
{ tagName: "DIV", attr: "class", value: "tooltip fade bs-tooltip-auto" },
|
||||
{ tagName: "DIV", attr: "class", value: "tooltip fade bs-tooltip-auto show" },
|
||||
{ tagName: "DIV", attr: "class", value: "tooltip tooltip-field-info fade bs-tooltip-auto" },
|
||||
{
|
||||
tagName: "DIV",
|
||||
attr: "class",
|
||||
value: "tooltip tooltip-field-info fade bs-tooltip-auto show",
|
||||
},
|
||||
|
||||
// Due to a Document Kanban bug (already present in 12.0)
|
||||
{ tagName: "DIV", attr: "class", value: "ui-helper-hidden-accessible" },
|
||||
{
|
||||
tagName: "UL",
|
||||
attr: "class",
|
||||
value: "ui-menu ui-widget ui-widget-content ui-autocomplete ui-front",
|
||||
},
|
||||
{
|
||||
tagName: "UL",
|
||||
attr: "class",
|
||||
value: "ui-menu ui-widget ui-widget-content ui-autocomplete dropdown-menu ui-front", // many2ones
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* After each test, we check that there is no leftover in the DOM.
|
||||
*
|
||||
* Note: this event is not QUnit standard, we added it for this specific use case.
|
||||
* As a payload, an object with keys 'moduleName' and 'testName' is provided. It
|
||||
* is used to indicate the test that left elements in the DOM, when it happens.
|
||||
*/
|
||||
QUnit.on("OdooAfterTestHook", function (info) {
|
||||
if (QUnit.config.debug) {
|
||||
return;
|
||||
}
|
||||
const failed = info.testReport.getStatus() === "failed";
|
||||
const toRemove = [];
|
||||
// check for leftover elements in the body
|
||||
for (const bodyChild of document.body.children) {
|
||||
const tolerated = validElements.find(
|
||||
(e) => e.tagName === bodyChild.tagName && bodyChild.getAttribute(e.attr) === e.value
|
||||
);
|
||||
if (!failed && !tolerated) {
|
||||
QUnit.pushFailure(
|
||||
`Body still contains undesirable elements:\n${bodyChild.outerHTML}`
|
||||
);
|
||||
}
|
||||
if (!tolerated || !tolerated.keep) {
|
||||
toRemove.push(bodyChild);
|
||||
}
|
||||
}
|
||||
// cleanup leftovers in #qunit-fixture
|
||||
const qunitFixture = document.getElementById("qunit-fixture");
|
||||
if (qunitFixture.children.length) {
|
||||
toRemove.push(...qunitFixture.children);
|
||||
}
|
||||
// remove unwanted elements if not in debug
|
||||
for (const el of toRemove) {
|
||||
el.remove();
|
||||
}
|
||||
document.body.classList.remove("modal-open");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/** @odoo-module alias=@web/../tests/helpers/mock_env default=false */
|
||||
|
||||
import { SERVICES_METADATA } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { makeEnv, startServices } from "@web/env";
|
||||
import { registerCleanup } from "./cleanup";
|
||||
import { makeMockServer } from "./mock_server";
|
||||
import { mocks } from "./mock_services";
|
||||
import { patchWithCleanup } from "./utils";
|
||||
import { Component } from "@odoo/owl";
|
||||
import { startRouter } from "@web/core/browser/router";
|
||||
|
||||
function prepareRegistry(registry, keepContent = false) {
|
||||
const _addEventListener = registry.addEventListener.bind(registry);
|
||||
const _removeEventListener = registry.removeEventListener.bind(registry);
|
||||
const patch = {
|
||||
content: keepContent ? { ...registry.content } : {},
|
||||
elements: null,
|
||||
entries: null,
|
||||
subRegistries: {},
|
||||
addEventListener(type, callback) {
|
||||
_addEventListener(type, callback);
|
||||
registerCleanup(() => {
|
||||
_removeEventListener(type, callback);
|
||||
});
|
||||
},
|
||||
};
|
||||
patchWithCleanup(registry, patch);
|
||||
}
|
||||
|
||||
export function clearRegistryWithCleanup(registry) {
|
||||
prepareRegistry(registry);
|
||||
}
|
||||
|
||||
function cloneRegistryWithCleanup(registry) {
|
||||
prepareRegistry(registry, true);
|
||||
}
|
||||
|
||||
export function clearServicesMetadataWithCleanup() {
|
||||
const servicesMetadata = Object.assign({}, SERVICES_METADATA);
|
||||
for (const key of Object.keys(SERVICES_METADATA)) {
|
||||
delete SERVICES_METADATA[key];
|
||||
}
|
||||
registerCleanup(() => {
|
||||
for (const key of Object.keys(SERVICES_METADATA)) {
|
||||
delete SERVICES_METADATA[key];
|
||||
}
|
||||
Object.assign(SERVICES_METADATA, servicesMetadata);
|
||||
});
|
||||
}
|
||||
|
||||
export const registryNamesToCloneWithCleanup = [
|
||||
"actions",
|
||||
"command_provider",
|
||||
"command_setup",
|
||||
"error_handlers",
|
||||
"fields",
|
||||
"fields",
|
||||
"main_components",
|
||||
"view_widgets",
|
||||
"views",
|
||||
];
|
||||
|
||||
export const utils = {
|
||||
prepareRegistriesWithCleanup() {
|
||||
// Clone registries
|
||||
registryNamesToCloneWithCleanup.forEach((registryName) =>
|
||||
cloneRegistryWithCleanup(registry.category(registryName))
|
||||
);
|
||||
|
||||
// Clear registries
|
||||
clearRegistryWithCleanup(registry.category("command_categories"));
|
||||
clearRegistryWithCleanup(registry.category("debug"));
|
||||
clearRegistryWithCleanup(registry.category("error_dialogs"));
|
||||
clearRegistryWithCleanup(registry.category("favoriteMenu"));
|
||||
clearRegistryWithCleanup(registry.category("ir.actions.report handlers"));
|
||||
clearRegistryWithCleanup(registry.category("main_components"));
|
||||
|
||||
clearRegistryWithCleanup(registry.category("services"));
|
||||
clearServicesMetadataWithCleanup();
|
||||
|
||||
clearRegistryWithCleanup(registry.category("systray"));
|
||||
clearRegistryWithCleanup(registry.category("user_menuitems"));
|
||||
clearRegistryWithCleanup(registry.category("kanban_examples"));
|
||||
clearRegistryWithCleanup(registry.category("__processed_archs__"));
|
||||
// fun fact: at least one registry is missing... this shows that we need a
|
||||
// better design for the way we clear these registries...
|
||||
},
|
||||
};
|
||||
|
||||
// This is exported in a utils object to allow for patching
|
||||
export function prepareRegistriesWithCleanup() {
|
||||
return utils.prepareRegistriesWithCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a test environment
|
||||
*
|
||||
* @param {*} config
|
||||
* @returns {Promise<OdooEnv>}
|
||||
*/
|
||||
export async function makeTestEnv(config = {}) {
|
||||
startRouter();
|
||||
// add all missing dependencies if necessary
|
||||
const serviceRegistry = registry.category("services");
|
||||
const servicesToProcess = serviceRegistry.getAll();
|
||||
while (servicesToProcess.length) {
|
||||
const service = servicesToProcess.pop();
|
||||
if (service.dependencies) {
|
||||
for (const depName of service.dependencies) {
|
||||
if (depName in mocks && !serviceRegistry.contains(depName)) {
|
||||
const dep = mocks[depName]();
|
||||
serviceRegistry.add(depName, dep);
|
||||
servicesToProcess.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.serverData || config.mockRPC || config.activateMockServer) {
|
||||
await makeMockServer(config.serverData, config.mockRPC);
|
||||
}
|
||||
|
||||
let env = makeEnv();
|
||||
await startServices(env);
|
||||
Component.env = env;
|
||||
if ("config" in config) {
|
||||
env = Object.assign(Object.create(env), { config: config.config });
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test environment for dialog tests
|
||||
*
|
||||
* @param {*} config
|
||||
* @returns {Promise<OdooEnv>}
|
||||
*/
|
||||
export async function makeDialogTestEnv(config = {}) {
|
||||
const env = await makeTestEnv(config);
|
||||
env.dialogData = {
|
||||
isActive: true,
|
||||
close() {},
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
/** @odoo-module alias=@web/../tests/helpers/mock_services default=false */
|
||||
|
||||
import { effectService } from "@web/core/effects/effect_service";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { ConnectionAbortedError, rpcBus, rpc } from "@web/core/network/rpc";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { overlayService } from "@web/core/overlay/overlay_service";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { user } from "@web/core/user";
|
||||
import { patchWithCleanup } from "./utils";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Mock Services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const defaultLocalization = {
|
||||
dateFormat: "MM/dd/yyyy",
|
||||
timeFormat: "HH:mm:ss",
|
||||
shortTimeFormat: "HH:mm",
|
||||
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
|
||||
decimalPoint: ".",
|
||||
direction: "ltr",
|
||||
grouping: [],
|
||||
multiLang: false,
|
||||
thousandsSep: ",",
|
||||
weekStart: 7,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Partial<typeof defaultLocalization>} [config]
|
||||
*/
|
||||
export function makeFakeLocalizationService(config = {}) {
|
||||
patchWithCleanup(localization, { ...defaultLocalization, ...config });
|
||||
patchWithCleanup(luxon.Settings, { defaultNumberingSystem: "latn" });
|
||||
|
||||
return {
|
||||
name: "localization",
|
||||
start: async (env) => {
|
||||
return localization;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function patchRPCWithCleanup(mockRPC = () => {}) {
|
||||
let nextId = 1;
|
||||
patchWithCleanup(rpc, {
|
||||
_rpc: function (route, params = {}, settings = {}) {
|
||||
let rejectFn;
|
||||
const data = {
|
||||
id: nextId++,
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: params,
|
||||
};
|
||||
rpcBus.trigger("RPC:REQUEST", { data, url: route, settings });
|
||||
const rpcProm = new Promise((resolve, reject) => {
|
||||
rejectFn = reject;
|
||||
Promise.resolve(mockRPC(...arguments))
|
||||
.then((result) => {
|
||||
rpcBus.trigger("RPC:RESPONSE", { data, settings, result });
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
rpcBus.trigger("RPC:RESPONSE", {
|
||||
data,
|
||||
settings,
|
||||
error,
|
||||
});
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
rpcProm.abort = (rejectError = true) => {
|
||||
if (rejectError) {
|
||||
rejectFn(new ConnectionAbortedError("XmlHttpRequestError abort"));
|
||||
}
|
||||
};
|
||||
return rpcProm;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function makeMockXHR(response, sendCb, def) {
|
||||
const MockXHR = function () {
|
||||
return {
|
||||
_loadListener: null,
|
||||
url: "",
|
||||
addEventListener(type, listener) {
|
||||
if (type === "load") {
|
||||
this._loadListener = listener;
|
||||
} else if (type === "error") {
|
||||
this._errorListener = listener;
|
||||
}
|
||||
},
|
||||
set onload(listener) {
|
||||
this._loadListener = listener;
|
||||
},
|
||||
set onerror(listener) {
|
||||
this._errorListener = listener;
|
||||
},
|
||||
open(method, url) {
|
||||
this.url = url;
|
||||
},
|
||||
getResponseHeader() {},
|
||||
setRequestHeader() {},
|
||||
async send(data) {
|
||||
let listener = this._loadListener;
|
||||
if (sendCb) {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
await sendCb.call(this, data);
|
||||
} catch {
|
||||
listener = this._errorListener;
|
||||
}
|
||||
}
|
||||
if (def) {
|
||||
await def;
|
||||
}
|
||||
listener.call(this);
|
||||
},
|
||||
response: JSON.stringify(response || ""),
|
||||
};
|
||||
};
|
||||
return MockXHR;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Low level API mocking
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function makeMockFetch(mockRPC) {
|
||||
return async (input, params) => {
|
||||
let route = typeof input === "string" ? input : input.url;
|
||||
if (route.includes("load_menus")) {
|
||||
const routeArray = route.split("/");
|
||||
params = {
|
||||
hash: routeArray.pop(),
|
||||
};
|
||||
route = routeArray.join("/");
|
||||
}
|
||||
let res;
|
||||
let status;
|
||||
try {
|
||||
res = await mockRPC(route, params);
|
||||
status = 200;
|
||||
} catch {
|
||||
status = 500;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(res || {})], { type: "application/json" });
|
||||
const response = new Response(blob, { status });
|
||||
// Mock some functions of the Response API to make them almost synchronous (micro-tick level)
|
||||
// as their native implementation is async (tick level), which can lead to undeterministic
|
||||
// errors as it breaks the hypothesis that calling nextTick after fetching data is enough
|
||||
// to see the result rendered in the DOM.
|
||||
response.json = () => Promise.resolve(JSON.parse(JSON.stringify(res || {})));
|
||||
response.text = () => Promise.resolve(String(res || {}));
|
||||
response.blob = () => Promise.resolve(blob);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
export const fakeCommandService = {
|
||||
start() {
|
||||
return {
|
||||
add() {
|
||||
return () => {};
|
||||
},
|
||||
getCommands() {
|
||||
return [];
|
||||
},
|
||||
openPalette() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const fakeTitleService = {
|
||||
start() {
|
||||
let current = {};
|
||||
return {
|
||||
get current() {
|
||||
return JSON.stringify(current);
|
||||
},
|
||||
getParts() {
|
||||
return current;
|
||||
},
|
||||
setParts(parts) {
|
||||
current = Object.assign({}, current, parts);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const fakeColorSchemeService = {
|
||||
start() {
|
||||
return {
|
||||
switchToColorScheme() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function makeFakeNotificationService(mock) {
|
||||
return {
|
||||
start() {
|
||||
function add() {
|
||||
if (mock) {
|
||||
return mock(...arguments);
|
||||
}
|
||||
}
|
||||
return {
|
||||
add,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeFakeDialogService(addDialog, closeAllDialog) {
|
||||
return {
|
||||
start() {
|
||||
return {
|
||||
add: addDialog || (() => () => {}),
|
||||
closeAll: closeAllDialog || (() => () => {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeFakePwaService() {
|
||||
return {
|
||||
start() {
|
||||
return {
|
||||
canPromptToInstall: false,
|
||||
isAvailable: false,
|
||||
isScopedApp: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function patchUserContextWithCleanup(patch) {
|
||||
const context = user.context;
|
||||
patchWithCleanup(user, {
|
||||
get context() {
|
||||
return Object.assign({}, context, patch);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function patchUserWithCleanup(patch) {
|
||||
patchWithCleanup(user, patch);
|
||||
}
|
||||
|
||||
export const fakeCompanyService = {
|
||||
start() {
|
||||
return {
|
||||
allowedCompanies: {},
|
||||
allowedCompaniesWithAncestors: {},
|
||||
activeCompanyIds: [],
|
||||
currentCompany: {},
|
||||
setCompanies: () => {},
|
||||
getCompany: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function makeFakeBarcodeService() {
|
||||
return {
|
||||
start() {
|
||||
return {
|
||||
bus: {
|
||||
async addEventListener() {},
|
||||
async removeEventListener() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeFakeHTTPService(getResponse, postResponse) {
|
||||
getResponse =
|
||||
getResponse ||
|
||||
((route, readMethod) => {
|
||||
return readMethod === "json" ? {} : "";
|
||||
});
|
||||
postResponse =
|
||||
postResponse ||
|
||||
((route, params, readMethod) => {
|
||||
return readMethod === "json" ? {} : "";
|
||||
});
|
||||
return {
|
||||
start() {
|
||||
return {
|
||||
async get(...args) {
|
||||
return getResponse(...args);
|
||||
},
|
||||
async post(...args) {
|
||||
return postResponse(...args);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeActionService() {
|
||||
return {
|
||||
start() {
|
||||
return {
|
||||
doAction() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mocks = {
|
||||
color_scheme: () => fakeColorSchemeService,
|
||||
company: () => fakeCompanyService,
|
||||
command: () => fakeCommandService,
|
||||
effect: () => effectService, // BOI The real service ? Is this what we want ?
|
||||
localization: makeFakeLocalizationService,
|
||||
notification: makeFakeNotificationService,
|
||||
title: () => fakeTitleService,
|
||||
ui: () => uiService,
|
||||
dialog: makeFakeDialogService,
|
||||
orm: () => ormService,
|
||||
action: makeFakeActionService,
|
||||
overlay: () => overlayService,
|
||||
};
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/** @odoo-module alias=@web/../tests/helpers/mount_in_fixture default=false**/
|
||||
|
||||
import { App, Component, xml } from "@odoo/owl";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { mocks } from "@web/../tests/helpers/mock_services";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { getTemplate } from "@web/core/templates";
|
||||
|
||||
class TestComponent extends Component {
|
||||
static props = {
|
||||
components: { type: Array },
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<t t-foreach="props.components" t-as="comp" t-key="comp.component.name">
|
||||
<t t-component="comp.component" t-props="comp.props"/>
|
||||
</t>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Returns the instance of the first component.
|
||||
* @returns {Component}
|
||||
*/
|
||||
get defaultComponent() {
|
||||
return this.__owl__.bdom.children[0].child.component;
|
||||
}
|
||||
}
|
||||
|
||||
function getApp(env, props) {
|
||||
const appConfig = {
|
||||
env,
|
||||
getTemplate,
|
||||
test: true,
|
||||
props: props,
|
||||
};
|
||||
if (env.services && "localization" in env.services) {
|
||||
appConfig.translateFn = env._t;
|
||||
}
|
||||
const app = new App(TestComponent, appConfig);
|
||||
registerCleanup(() => app.destroy());
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @property {Object} env
|
||||
* @property {Object} props
|
||||
* @property {string[]} templates
|
||||
*/
|
||||
|
||||
/**
|
||||
* This functions will mount the given component and
|
||||
* will add a MainComponentsContainer if the overlay
|
||||
* service is loaded.
|
||||
*
|
||||
* @template T
|
||||
* @param {new (...args: any[]) => T} Comp
|
||||
* @param {HTMLElement} target
|
||||
* @param {Config} config
|
||||
* @returns {Promise<T>} Instance of Comp
|
||||
*/
|
||||
export async function mountInFixture(Comp, target, config = {}) {
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let env = config.env || {};
|
||||
const isEnvInitialized = env && env.services;
|
||||
|
||||
function isServiceRegistered(serviceName) {
|
||||
return isEnvInitialized
|
||||
? serviceName in env.services
|
||||
: serviceRegistry.contains(serviceName);
|
||||
}
|
||||
|
||||
async function addService(serviceName, service) {
|
||||
if (isServiceRegistered(serviceName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
service = typeof service === "function" ? service() : service;
|
||||
if (isEnvInitialized) {
|
||||
env.services[serviceName] = await service.start(env);
|
||||
} else {
|
||||
serviceRegistry.add(serviceName, service);
|
||||
}
|
||||
}
|
||||
|
||||
const components = [{ component: Comp, props: config.props || {} }];
|
||||
if (isServiceRegistered("overlay")) {
|
||||
await addService("localization", mocks.localization);
|
||||
components.push({ component: MainComponentsContainer, props: {} });
|
||||
}
|
||||
|
||||
if (!isEnvInitialized) {
|
||||
env = await makeTestEnv(env);
|
||||
}
|
||||
|
||||
const app = getApp(env, { components });
|
||||
|
||||
if (config.templates) {
|
||||
app.addTemplates(config.templates);
|
||||
}
|
||||
|
||||
const testComp = await app.mount(target);
|
||||
return testComp.defaultComponent;
|
||||
}
|
||||
1134
odoo-bringout-oca-ocb-web/web/static/tests/legacy/helpers/utils.js
Normal file
1134
odoo-bringout-oca-ocb-web/web/static/tests/legacy/helpers/utils.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,6 @@
|
|||
// @odoo-module ignore
|
||||
|
||||
// This module has for sole purpose to mark all odoo modules defined between it
|
||||
// and ignore_missing_deps_stop as ignored for missing dependency errors.
|
||||
// see the template conditional_assets_tests to understand how it's used.
|
||||
window.__odooIgnoreMissingDependencies = true;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// @odoo-module ignore
|
||||
|
||||
window.__odooIgnoreMissingDependencies = false;
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/core/class_tests default=false */
|
||||
|
||||
import Class from "@web/legacy/js/core/class";
|
||||
|
||||
QUnit.module('core', {}, function () {
|
||||
|
||||
QUnit.module('Class');
|
||||
|
||||
|
||||
QUnit.test('Basic class creation', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var C = Class.extend({
|
||||
foo: function () {
|
||||
return this.somevar;
|
||||
}
|
||||
});
|
||||
var i = new C();
|
||||
i.somevar = 3;
|
||||
|
||||
assert.ok(i instanceof C);
|
||||
assert.strictEqual(i.foo(), 3);
|
||||
});
|
||||
|
||||
QUnit.test('Class initialization', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var C1 = Class.extend({
|
||||
init: function () {
|
||||
this.foo = 3;
|
||||
}
|
||||
});
|
||||
var C2 = Class.extend({
|
||||
init: function (arg) {
|
||||
this.foo = arg;
|
||||
}
|
||||
});
|
||||
|
||||
var i1 = new C1(),
|
||||
i2 = new C2(42);
|
||||
|
||||
assert.strictEqual(i1.foo, 3);
|
||||
assert.strictEqual(i2.foo, 42);
|
||||
});
|
||||
|
||||
QUnit.test('Inheritance', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var C0 = Class.extend({
|
||||
foo: function () {
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
var C1 = C0.extend({
|
||||
foo: function () {
|
||||
return 1 + this._super();
|
||||
}
|
||||
});
|
||||
var C2 = C1.extend({
|
||||
foo: function () {
|
||||
return 1 + this._super();
|
||||
}
|
||||
});
|
||||
|
||||
assert.strictEqual(new C0().foo(), 1);
|
||||
assert.strictEqual(new C1().foo(), 2);
|
||||
assert.strictEqual(new C2().foo(), 3);
|
||||
});
|
||||
|
||||
QUnit.test('In-place extension', function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var C0 = Class.extend({
|
||||
foo: function () {
|
||||
return 3;
|
||||
},
|
||||
qux: function () {
|
||||
return 3;
|
||||
},
|
||||
bar: 3
|
||||
});
|
||||
|
||||
C0.include({
|
||||
foo: function () {
|
||||
return 5;
|
||||
},
|
||||
qux: function () {
|
||||
return 2 + this._super();
|
||||
},
|
||||
bar: 5,
|
||||
baz: 5
|
||||
});
|
||||
|
||||
assert.strictEqual(new C0().bar, 5);
|
||||
assert.strictEqual(new C0().baz, 5);
|
||||
assert.strictEqual(new C0().foo(), 5);
|
||||
assert.strictEqual(new C0().qux(), 5);
|
||||
});
|
||||
|
||||
QUnit.test('In-place extension and inheritance', function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var C0 = Class.extend({
|
||||
foo: function () { return 1; },
|
||||
bar: function () { return 1; }
|
||||
});
|
||||
var C1 = C0.extend({
|
||||
foo: function () { return 1 + this._super(); }
|
||||
});
|
||||
assert.strictEqual(new C1().foo(), 2);
|
||||
assert.strictEqual(new C1().bar(), 1);
|
||||
|
||||
C1.include({
|
||||
foo: function () { return 2 + this._super(); },
|
||||
bar: function () { return 1 + this._super(); }
|
||||
});
|
||||
assert.strictEqual(new C1().foo(), 4);
|
||||
assert.strictEqual(new C1().bar(), 2);
|
||||
});
|
||||
|
||||
QUnit.test('In-place extensions alter existing instances', function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
var C0 = Class.extend({
|
||||
foo: function () { return 1; },
|
||||
bar: function () { return 1; }
|
||||
});
|
||||
var i = new C0();
|
||||
assert.strictEqual(i.foo(), 1);
|
||||
assert.strictEqual(i.bar(), 1);
|
||||
|
||||
C0.include({
|
||||
foo: function () { return 2; },
|
||||
bar: function () { return 2 + this._super(); }
|
||||
});
|
||||
assert.strictEqual(i.foo(), 2);
|
||||
assert.strictEqual(i.bar(), 3);
|
||||
});
|
||||
|
||||
QUnit.test('In-place extension of subclassed types', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var C0 = Class.extend({
|
||||
foo: function () { return 1; },
|
||||
bar: function () { return 1; }
|
||||
});
|
||||
var C1 = C0.extend({
|
||||
foo: function () { return 1 + this._super(); },
|
||||
bar: function () { return 1 + this._super(); }
|
||||
});
|
||||
var i = new C1();
|
||||
|
||||
assert.strictEqual(i.foo(), 2);
|
||||
|
||||
C0.include({
|
||||
foo: function () { return 2; },
|
||||
bar: function () { return 2 + this._super(); }
|
||||
});
|
||||
|
||||
assert.strictEqual(i.foo(), 3);
|
||||
assert.strictEqual(i.bar(), 4);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils default=false */
|
||||
|
||||
/**
|
||||
* Test Utils
|
||||
*
|
||||
* In this module, we define various utility functions to help simulate a mock
|
||||
* environment as close as possible as a real environment.
|
||||
*/
|
||||
|
||||
import testUtilsDom from "@web/../tests/legacy_tests/helpers/test_utils_dom";
|
||||
import testUtilsFields from "@web/../tests/legacy_tests/helpers/test_utils_fields";
|
||||
import testUtilsMock from "@web/../tests/legacy_tests/helpers/test_utils_mock";
|
||||
|
||||
function deprecated(fn, type) {
|
||||
return function () {
|
||||
const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
|
||||
`Please use 'testUtils.${type}.${fn.name}' instead.`;
|
||||
console.warn(msg);
|
||||
return fn.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function, make a promise with a public resolve function. Note that
|
||||
* this is not standard and should not be used outside of tests...
|
||||
*
|
||||
* @returns {Promise + resolve and reject function}
|
||||
*/
|
||||
function makeTestPromise() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise(function (_resolve, _reject) {
|
||||
resolve = _resolve;
|
||||
reject = _reject;
|
||||
});
|
||||
promise.resolve = function () {
|
||||
resolve.apply(null, arguments);
|
||||
return promise;
|
||||
};
|
||||
promise.reject = function () {
|
||||
reject.apply(null, arguments);
|
||||
return promise;
|
||||
};
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a promise with public resolve and reject functions (see
|
||||
* @makeTestPromise). Perform an assert.step when the promise is
|
||||
* resolved/rejected.
|
||||
*
|
||||
* @param {Object} assert instance object with the assertion methods
|
||||
* @param {function} assert.step
|
||||
* @param {string} str message to pass to assert.step
|
||||
* @returns {Promise + resolve and reject function}
|
||||
*/
|
||||
function makeTestPromiseWithAssert(assert, str) {
|
||||
const prom = makeTestPromise();
|
||||
prom.then(() => assert.step('ok ' + str)).catch(function () { });
|
||||
prom.catch(() => assert.step('ko ' + str));
|
||||
return prom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new promise that can be waited by the caller in order to execute
|
||||
* code after the next microtask tick and before the next jobqueue tick.
|
||||
*
|
||||
* @return {Promise} an already fulfilled promise
|
||||
*/
|
||||
async function nextMicrotaskTick() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that will be resolved after the tick after the
|
||||
* nextAnimationFrame
|
||||
*
|
||||
* This is usefull to guarantee that OWL has had the time to render
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function nextTick() {
|
||||
return testUtilsDom.returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
export const mock = {
|
||||
intercept: testUtilsMock.intercept,
|
||||
patch: testUtilsMock.patch,
|
||||
patchDate: testUtilsMock.patchDate,
|
||||
unpatch: testUtilsMock.unpatch,
|
||||
getView: testUtilsMock.getView,
|
||||
};
|
||||
|
||||
export const dom = {
|
||||
dragAndDrop: testUtilsDom.dragAndDrop,
|
||||
find: testUtilsDom.findItem,
|
||||
click: testUtilsDom.click,
|
||||
clickFirst: testUtilsDom.clickFirst,
|
||||
triggerEvents: testUtilsDom.triggerEvents,
|
||||
triggerEvent: testUtilsDom.triggerEvent,
|
||||
};
|
||||
|
||||
export const fields = {
|
||||
editInput: testUtilsFields.editInput,
|
||||
editAndTrigger: testUtilsFields.editAndTrigger,
|
||||
triggerKeydown: testUtilsFields.triggerKeydown,
|
||||
};
|
||||
|
||||
export default {
|
||||
mock,
|
||||
dom,
|
||||
fields,
|
||||
|
||||
makeTestPromise: makeTestPromise,
|
||||
makeTestPromiseWithAssert: makeTestPromiseWithAssert,
|
||||
nextMicrotaskTick: nextMicrotaskTick,
|
||||
nextTick: nextTick,
|
||||
|
||||
// backward-compatibility
|
||||
dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'),
|
||||
getView: deprecated(testUtilsMock.getView, 'mock'),
|
||||
intercept: deprecated(testUtilsMock.intercept, 'mock'),
|
||||
openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'),
|
||||
patch: deprecated(testUtilsMock.patch, 'mock'),
|
||||
patchDate: deprecated(testUtilsMock.patchDate, 'mock'),
|
||||
unpatch: deprecated(testUtilsMock.unpatch, 'mock'),
|
||||
};
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_dom default=false */
|
||||
|
||||
import { delay } from "@web/core/utils/concurrency";
|
||||
|
||||
/**
|
||||
* DOM Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help simulate DOM events.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Private functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
// TriggerEvent helpers
|
||||
const keyboardEventBubble = args => Object.assign({}, args, { bubbles: true});
|
||||
const mouseEventMapping = args => Object.assign({}, args, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: args ? args.clientX || args.pageX : undefined,
|
||||
clientY: args ? args.clientY || args.pageY : undefined,
|
||||
view: window,
|
||||
});
|
||||
const mouseEventNoBubble = args => Object.assign({}, args, {
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
clientX: args ? args.clientX || args.pageX : undefined,
|
||||
clientY: args ? args.clientY || args.pageY : undefined,
|
||||
view: window,
|
||||
});
|
||||
const touchEventMapping = args => Object.assign({}, args, {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
view: window,
|
||||
rotation: 0.0,
|
||||
zoom: 1.0,
|
||||
});
|
||||
const touchEventCancelMapping = args => Object.assign({}, touchEventMapping(args), {
|
||||
cancelable: false,
|
||||
});
|
||||
const noBubble = args => Object.assign({}, args, { bubbles: false });
|
||||
const onlyBubble = args => Object.assign({}, args, { bubbles: true });
|
||||
// TriggerEvent constructor/args processor mapping
|
||||
const EVENT_TYPES = {
|
||||
auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
click: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
|
||||
mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
|
||||
mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
|
||||
mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping },
|
||||
|
||||
focus: { constructor: FocusEvent, processParameters: noBubble },
|
||||
focusin: { constructor: FocusEvent, processParameters: onlyBubble },
|
||||
blur: { constructor: FocusEvent, processParameters: noBubble },
|
||||
|
||||
cut: { constructor: ClipboardEvent, processParameters: onlyBubble },
|
||||
copy: { constructor: ClipboardEvent, processParameters: onlyBubble },
|
||||
paste: { constructor: ClipboardEvent, processParameters: onlyBubble },
|
||||
|
||||
keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
|
||||
keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
|
||||
keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
|
||||
|
||||
drag: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragend: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragenter: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragstart: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragleave: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
dragover: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
drop: { constructor: DragEvent, processParameters: onlyBubble },
|
||||
|
||||
input: { constructor: InputEvent, processParameters: onlyBubble },
|
||||
|
||||
compositionstart: { constructor: CompositionEvent, processParameters: onlyBubble },
|
||||
compositionend: { constructor: CompositionEvent, processParameters: onlyBubble },
|
||||
};
|
||||
|
||||
if (typeof TouchEvent === 'function') {
|
||||
Object.assign(EVENT_TYPES, {
|
||||
touchstart: {constructor: TouchEvent, processParameters: touchEventMapping},
|
||||
touchend: {constructor: TouchEvent, processParameters: touchEventMapping},
|
||||
touchmove: {constructor: TouchEvent, processParameters: touchEventMapping},
|
||||
touchcancel: {constructor: TouchEvent, processParameters: touchEventCancelMapping},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object is an instance of EventTarget.
|
||||
*
|
||||
* @param {Object} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _isEventTarget(node) {
|
||||
if (!node) {
|
||||
throw new Error(`Provided node is ${node}.`);
|
||||
}
|
||||
if (node instanceof window.top.EventTarget) {
|
||||
return true;
|
||||
}
|
||||
const contextWindow = node.defaultView || // document
|
||||
(node.ownerDocument && node.ownerDocument.defaultView); // iframe node
|
||||
return contextWindow && node instanceof contextWindow.EventTarget;
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Click on a specified element. If the option first or last is not specified,
|
||||
* this method also check the unicity and the visibility of the target.
|
||||
*
|
||||
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
|
||||
* @param {Object} [options={}] click options
|
||||
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
|
||||
* element event if it is invisible
|
||||
* @param {boolean} [options.first=false] if true, clicks on the first element
|
||||
* @param {boolean} [options.last=false] if true, clicks on the last element
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function click(el, options = {}) {
|
||||
let matches, target;
|
||||
let selectorMsg = "";
|
||||
if (typeof el === 'string') {
|
||||
el = $(el);
|
||||
}
|
||||
if (el.disabled || (el instanceof jQuery && el.get(0).disabled)) {
|
||||
throw new Error("Can't click on a disabled button");
|
||||
}
|
||||
if (_isEventTarget(el)) {
|
||||
// EventTarget
|
||||
matches = [el];
|
||||
} else {
|
||||
// Any other iterable object containing EventTarget objects (jQuery, HTMLCollection, etc.)
|
||||
matches = [...el];
|
||||
}
|
||||
|
||||
const validMatches = options.allowInvisible ?
|
||||
matches : matches.filter(t => $(t).is(':visible'));
|
||||
|
||||
if (options.first) {
|
||||
if (validMatches.length === 1) {
|
||||
throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
|
||||
' you are sure that there is exactly one target, please use the ' +
|
||||
'click function instead of the clickFirst function');
|
||||
}
|
||||
target = validMatches[0];
|
||||
} else if (options.last) {
|
||||
if (validMatches.length === 1) {
|
||||
throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
|
||||
' you are sure that there is exactly one target, please use the ' +
|
||||
'click function instead of the clickLast function');
|
||||
}
|
||||
target = validMatches[validMatches.length - 1];
|
||||
} else if (validMatches.length !== 1) {
|
||||
throw new Error(`Found ${validMatches.length} elements to click on, instead of 1 ${selectorMsg}`);
|
||||
} else {
|
||||
target = validMatches[0];
|
||||
}
|
||||
if (validMatches.length === 0 && matches.length > 0) {
|
||||
throw new Error(`Element to click on is not visible ${selectorMsg}`);
|
||||
}
|
||||
if (target.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return triggerEvent(target, 'click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the first element of a list of elements. Note that if the list has
|
||||
* only one visible element, we trigger an error. In that case, it is better to
|
||||
* use the click helper instead.
|
||||
*
|
||||
* @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
|
||||
* @param {boolean} [options={}] click options
|
||||
* @param {boolean} [options.allowInvisible=false] if true, clicks on the
|
||||
* element event if it is invisible
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function clickFirst(el, options) {
|
||||
return click(el, Object.assign({}, options, { first: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a drag and drop operation between 2 jquery nodes: $el and $to.
|
||||
* This is a crude simulation, with only the mousedown, mousemove and mouseup
|
||||
* events, but it is enough to help test drag and drop operations with jqueryUI
|
||||
* sortable.
|
||||
*
|
||||
* @todo: remove the withTrailingClick option when the jquery update branch is
|
||||
* merged. This is not the default as of now, because handlers are triggered
|
||||
* synchronously, which is not the same as the 'reality'.
|
||||
*
|
||||
* @param {jQuery|EventTarget} $el
|
||||
* @param {jQuery|EventTarget} $to
|
||||
* @param {Object} [options]
|
||||
* @param {string|Object} [options.position='center'] target position:
|
||||
* can either be one of {'top', 'bottom', 'left', 'right'} or
|
||||
* an object with two attributes (top and left))
|
||||
* @param {boolean} [options.disableDrop=false] whether to trigger the drop action
|
||||
* @param {boolean} [options.continueMove=false] whether to trigger the
|
||||
* mousedown action (will only work after another call of this function with
|
||||
* without this option)
|
||||
* @param {boolean} [options.withTrailingClick=false] if true, this utility
|
||||
* function will also trigger a click on the target after the mouseup event
|
||||
* (this is actually what happens when a drag and drop operation is done)
|
||||
* @param {jQuery|EventTarget} [options.mouseenterTarget=undefined] target of the mouseenter event
|
||||
* @param {jQuery|EventTarget} [options.mousedownTarget=undefined] target of the mousedown event
|
||||
* @param {jQuery|EventTarget} [options.mousemoveTarget=undefined] target of the mousemove event
|
||||
* @param {jQuery|EventTarget} [options.mouseupTarget=undefined] target of the mouseup event
|
||||
* @param {jQuery|EventTarget} [options.ctrlKey=undefined] if the ctrl key should be considered pressed at the time of mouseup
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function dragAndDrop($el, $to, options) {
|
||||
let el = null;
|
||||
if (_isEventTarget($el)) {
|
||||
el = $el;
|
||||
$el = $(el);
|
||||
}
|
||||
if (_isEventTarget($to)) {
|
||||
$to = $($to);
|
||||
}
|
||||
options = options || {};
|
||||
const position = options.position || 'center';
|
||||
const elementCenter = $el.offset();
|
||||
const toOffset = $to.offset();
|
||||
|
||||
if (typeof position === 'object') {
|
||||
toOffset.top += position.top + 1;
|
||||
toOffset.left += position.left + 1;
|
||||
} else {
|
||||
toOffset.top += $to.outerHeight() / 2;
|
||||
toOffset.left += $to.outerWidth() / 2;
|
||||
const vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
|
||||
if (position === 'top') {
|
||||
toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
|
||||
} else if (position === 'bottom') {
|
||||
toOffset.top += $to.outerHeight() / 2 - vertical_offset;
|
||||
} else if (position === 'left') {
|
||||
toOffset.left -= $to.outerWidth() / 2;
|
||||
} else if (position === 'right') {
|
||||
toOffset.left += $to.outerWidth() / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if ($to[0].ownerDocument !== document) {
|
||||
// we are in an iframe
|
||||
const bound = $('iframe')[0].getBoundingClientRect();
|
||||
toOffset.left += bound.left;
|
||||
toOffset.top += bound.top;
|
||||
}
|
||||
await triggerEvent(options.mouseenterTarget || el || $el, 'mouseover', {}, true);
|
||||
if (!(options.continueMove)) {
|
||||
elementCenter.left += $el.outerWidth() / 2;
|
||||
elementCenter.top += $el.outerHeight() / 2;
|
||||
|
||||
await triggerEvent(options.mousedownTarget || el || $el, 'mousedown', {
|
||||
which: 1,
|
||||
pageX: elementCenter.left,
|
||||
pageY: elementCenter.top
|
||||
}, true);
|
||||
}
|
||||
await triggerEvent(options.mousemoveTarget || el || $el, 'mousemove', {
|
||||
which: 1,
|
||||
pageX: toOffset.left,
|
||||
pageY: toOffset.top
|
||||
}, true);
|
||||
|
||||
if (!options.disableDrop) {
|
||||
await triggerEvent(options.mouseupTarget || el || $el, 'mouseup', {
|
||||
which: 1,
|
||||
pageX: toOffset.left,
|
||||
pageY: toOffset.top,
|
||||
ctrlKey: options.ctrlKey,
|
||||
}, true);
|
||||
if (options.withTrailingClick) {
|
||||
await triggerEvent(options.mouseupTarget || el || $el, 'click', {}, true);
|
||||
}
|
||||
} else {
|
||||
// It's impossible to drag another element when one is already
|
||||
// being dragged. So it's necessary to finish the drop when the test is
|
||||
// over otherwise it's impossible for the next tests to drag and
|
||||
// drop elements.
|
||||
$el.on('remove', function () {
|
||||
triggerEvent($el, 'mouseup', {}, true);
|
||||
});
|
||||
}
|
||||
return returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise that will be resolved after the nextAnimationFrame after
|
||||
* the next tick
|
||||
*
|
||||
* This is useful to guarantee that OWL has had the time to render
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function returnAfterNextAnimationFrame() {
|
||||
await delay(0);
|
||||
await new Promise(resolve => {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an event on the specified target.
|
||||
* This function will dispatch a native event to an EventTarget or a
|
||||
* jQuery event to a jQuery object. This behaviour can be overridden by the
|
||||
* jquery option.
|
||||
*
|
||||
* @param {EventTarget|EventTarget[]} el
|
||||
* @param {string} eventType event type
|
||||
* @param {Object} [eventAttrs] event attributes
|
||||
* on a jQuery element with the `$.fn.trigger` function
|
||||
* @param {Boolean} [fast=false] true if the trigger event have to wait for a single tick instead of waiting for the next animation frame
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) {
|
||||
let matches;
|
||||
let selectorMsg = "";
|
||||
if (_isEventTarget(el)) {
|
||||
matches = [el];
|
||||
} else {
|
||||
matches = [...el];
|
||||
}
|
||||
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Found ${matches.length} elements to trigger "${eventType}" on, instead of 1 ${selectorMsg}`);
|
||||
}
|
||||
|
||||
const target = matches[0];
|
||||
let event;
|
||||
|
||||
if (!EVENT_TYPES[eventType] && !EVENT_TYPES[eventType.type]) {
|
||||
event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true }));
|
||||
} else {
|
||||
if (typeof eventType === "object") {
|
||||
const { constructor, processParameters } = EVENT_TYPES[eventType.type];
|
||||
const eventParameters = processParameters(eventType);
|
||||
event = new constructor(eventType.type, eventParameters);
|
||||
} else {
|
||||
const { constructor, processParameters } = EVENT_TYPES[eventType];
|
||||
event = new constructor(eventType, processParameters(eventAttrs));
|
||||
}
|
||||
}
|
||||
target.dispatchEvent(event);
|
||||
return fast ? undefined : returnAfterNextAnimationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger multiple events on the specified element.
|
||||
*
|
||||
* @param {EventTarget|EventTarget[]} el
|
||||
* @param {string[]} events the events you want to trigger
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async function triggerEvents(el, events) {
|
||||
if (el instanceof jQuery) {
|
||||
if (el.length !== 1) {
|
||||
throw new Error(`target has length ${el.length} instead of 1`);
|
||||
}
|
||||
}
|
||||
if (typeof events === 'string') {
|
||||
events = [events];
|
||||
}
|
||||
|
||||
for (let e = 0; e < events.length; e++) {
|
||||
await triggerEvent(el, events[e]);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
click,
|
||||
clickFirst,
|
||||
dragAndDrop,
|
||||
returnAfterNextAnimationFrame,
|
||||
triggerEvent,
|
||||
triggerEvents,
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_fields default=false */
|
||||
|
||||
/**
|
||||
* Field Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help testing field widgets.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
import testUtilsDom from "./test_utils_dom";
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sets the value of an element and then, trigger all specified events.
|
||||
* Note that this helper also checks the unicity of the target.
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']);
|
||||
*
|
||||
* @param {jQuery|EventTarget} el should target an input, textarea or select
|
||||
* @param {string|number} value
|
||||
* @param {string[]} events
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function editAndTrigger(el, value, events) {
|
||||
if (el instanceof jQuery) {
|
||||
if (el.length !== 1) {
|
||||
throw new Error(`target ${el.selector} has length ${el.length} instead of 1`);
|
||||
}
|
||||
el.val(value);
|
||||
} else {
|
||||
el.value = value;
|
||||
}
|
||||
return testUtilsDom.triggerEvents(el, events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of an input.
|
||||
*
|
||||
* Note that this helper also checks the unicity of the target.
|
||||
*
|
||||
* Example:
|
||||
* testUtils.fields.editInput($('selector'), 'somevalue');
|
||||
*
|
||||
* @param {jQuery|EventTarget} el should target an input, textarea or select
|
||||
* @param {string|number} value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function editInput(el, value) {
|
||||
return editAndTrigger(el, value, ['input']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to trigger a key event on an element.
|
||||
*
|
||||
* @param {string} type type of key event ('press', 'up' or 'down')
|
||||
* @param {jQuery} $el
|
||||
* @param {string} key
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function triggerKey(type, $el, key) {
|
||||
type = 'key' + type;
|
||||
const params = {};
|
||||
params.key = key;
|
||||
return testUtilsDom.triggerEvent($el, type, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to trigger a keydown event on an element.
|
||||
*
|
||||
* @param {jQuery} $el
|
||||
* @param {number|string} key @see triggerKey
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function triggerKeydown($el, key) {
|
||||
return triggerKey('down', $el, key);
|
||||
}
|
||||
|
||||
export default {
|
||||
editAndTrigger,
|
||||
editInput,
|
||||
triggerKeydown,
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_mock default=false */
|
||||
|
||||
/**
|
||||
* Mock Test Utils
|
||||
*
|
||||
* This module defines various utility functions to help mocking data.
|
||||
*
|
||||
* Note that all methods defined in this module are exported in the main
|
||||
* testUtils file.
|
||||
*/
|
||||
|
||||
import { patchDate } from "@web/../tests/helpers/utils";
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// Public functions
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* intercepts an event bubbling up the widget hierarchy. The event intercepted
|
||||
* must be a "custom event", i.e. an event generated by the method 'trigger_up'.
|
||||
*
|
||||
* Note that this method really intercepts the event if @propagate is not set.
|
||||
* It will not be propagated further, and even the handlers on the target will
|
||||
* not fire.
|
||||
*
|
||||
* @param {Widget} widget the target widget (any Odoo widget)
|
||||
* @param {string} eventName description of the event
|
||||
* @param {function} fn callback executed when the even is intercepted
|
||||
* @param {boolean} [propagate=false]
|
||||
*/
|
||||
function intercept(widget, eventName, fn, propagate) {
|
||||
var _trigger_up = widget._trigger_up.bind(widget);
|
||||
widget._trigger_up = function (event) {
|
||||
if (event.name === eventName) {
|
||||
fn(event);
|
||||
if (!propagate) { return; }
|
||||
}
|
||||
_trigger_up(event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch window.Date so that the time starts its flow from the provided Date.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```
|
||||
* testUtils.mock.patchDate(2018, 0, 10, 17, 59, 30)
|
||||
* new window.Date(); // "Wed Jan 10 2018 17:59:30 GMT+0100 (Central European Standard Time)"
|
||||
* ... // 5 hours delay
|
||||
* new window.Date(); // "Wed Jan 10 2018 22:59:30 GMT+0100 (Central European Standard Time)"
|
||||
* ```
|
||||
*
|
||||
* The returned function is there to preserve the former API. Before it was
|
||||
* necessary to call that function to unpatch the date. Now the unpatch is
|
||||
* done automatically via a call to registerCleanup.
|
||||
*
|
||||
* @param {integer} year
|
||||
* @param {integer} month index of the month, starting from zero.
|
||||
* @param {integer} day the day of the month.
|
||||
* @param {integer} hours the digits for hours (24h)
|
||||
* @param {integer} minutes
|
||||
* @param {integer} seconds
|
||||
* @returns {Function} callback function is now useless
|
||||
*/
|
||||
function legacyPatchDate(year, month, day, hours, minutes, seconds) {
|
||||
patchDate(year, month, day, hours, minutes, seconds);
|
||||
return function () {}; // all calls to that function are now useless
|
||||
}
|
||||
|
||||
export default {
|
||||
intercept: intercept,
|
||||
patchDate: legacyPatchDate,
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/helpers/test_utils_tests default=false */
|
||||
|
||||
import testUtils from "./test_utils";
|
||||
const { DateTime } = luxon;
|
||||
|
||||
QUnit.module('web', {}, function () {
|
||||
QUnit.module('testUtils', {}, function () {
|
||||
|
||||
QUnit.module('patch date');
|
||||
|
||||
QUnit.test('new date', function (assert) {
|
||||
assert.expect(5);
|
||||
const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
|
||||
|
||||
const date = new Date();
|
||||
|
||||
assert.strictEqual(date.getFullYear(), 2018);
|
||||
assert.strictEqual(date.getMonth(), 9);
|
||||
assert.strictEqual(date.getDate(), 23);
|
||||
assert.strictEqual(date.getHours(), 14);
|
||||
assert.strictEqual(date.getMinutes(), 50);
|
||||
unpatchDate();
|
||||
});
|
||||
|
||||
QUnit.test('new moment', function (assert) {
|
||||
assert.expect(1);
|
||||
const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
|
||||
|
||||
assert.strictEqual(DateTime.now().toFormat("yyyy-MM-dd HH:mm"), '2018-10-23 14:50');
|
||||
unpatchDate();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/** @odoo-module alias=@web/../tests/legacy_tests/patch_localization default=false */
|
||||
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
|
||||
Object.assign(localization, {
|
||||
dateFormat: "MM/dd/yyyy",
|
||||
timeFormat: "HH:mm:ss",
|
||||
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
|
||||
decimalPoint: ".",
|
||||
direction: "ltr",
|
||||
grouping: [],
|
||||
multiLang: false,
|
||||
thousandsSep: ",",
|
||||
weekStart: 7,
|
||||
code: "en",
|
||||
});
|
||||
10
odoo-bringout-oca-ocb-web/web/static/tests/legacy/main.js
Normal file
10
odoo-bringout-oca-ocb-web/web/static/tests/legacy/main.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @odoo-module alias=@web/../tests/main default=false */
|
||||
|
||||
import { setupQUnit } from "./qunit";
|
||||
import { setupTests } from "./setup";
|
||||
|
||||
(async () => {
|
||||
setupQUnit();
|
||||
await setupTests();
|
||||
QUnit.start();
|
||||
})();
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/helpers default=false */
|
||||
|
||||
import { findElement, triggerEvent } from "../helpers/utils";
|
||||
|
||||
async function swipe(target, selector, direction) {
|
||||
const touchTarget = findElement(target, selector);
|
||||
if (direction === "left") {
|
||||
// The scrollable element is set at its right limit
|
||||
touchTarget.scrollLeft = touchTarget.scrollWidth - touchTarget.offsetWidth;
|
||||
} else {
|
||||
// The scrollable element is set at its left limit
|
||||
touchTarget.scrollLeft = 0;
|
||||
}
|
||||
|
||||
await triggerEvent(target, selector, "touchstart", {
|
||||
touches: [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
target: touchTarget,
|
||||
},
|
||||
],
|
||||
});
|
||||
await triggerEvent(target, selector, "touchmove", {
|
||||
touches: [
|
||||
{
|
||||
identifier: 0,
|
||||
clientX: (direction === "left" ? -1 : 1) * touchTarget.clientWidth,
|
||||
clientY: 0,
|
||||
target: touchTarget,
|
||||
},
|
||||
],
|
||||
});
|
||||
await triggerEvent(target, selector, "touchend", {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Will simulate a swipe right on the target element with the given selector.
|
||||
*
|
||||
* @param {HTMLElement} target
|
||||
* @param {DOMSelector} [selector]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function swipeRight(target, selector) {
|
||||
return swipe(target, selector, "right");
|
||||
}
|
||||
|
||||
/**
|
||||
* Will simulate a swipe left on the target element with the given selector.
|
||||
*
|
||||
* @param {HTMLElement} target
|
||||
* @param {DOMSelector} [selector]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function swipeLeft(target, selector) {
|
||||
return swipe(target, selector, "left");
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a "TAP" (touch) on the target element with the given selector.
|
||||
*
|
||||
* @param {HTMLElement} target
|
||||
* @param {DOMSelector} [selector]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function tap(target, selector) {
|
||||
const touchTarget = findElement(target, selector);
|
||||
const box = touchTarget.getBoundingClientRect();
|
||||
const x = box.left + box.width / 2;
|
||||
const y = box.top + box.height / 2;
|
||||
const touch = {
|
||||
identifier: 0,
|
||||
target: touchTarget,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pageX: x,
|
||||
pageY: y,
|
||||
};
|
||||
await triggerEvent(touchTarget, null, "touchstart", {
|
||||
touches: [touch],
|
||||
});
|
||||
await triggerEvent(touchTarget, null, "touchend", {});
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/fields/many2many_tags_field_tests default=false */
|
||||
|
||||
import { getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Fields", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char" },
|
||||
timmy: { string: "pokemon", type: "many2many", relation: "partner_type" },
|
||||
},
|
||||
},
|
||||
partner_type: {
|
||||
fields: {
|
||||
name: { string: "Partner Type", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 12, display_name: "gold" },
|
||||
{ id: 14, display_name: "silver" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("Many2ManyTagsField");
|
||||
|
||||
QUnit.test("Many2ManyTagsField placeholder should be correct", async function (assert) {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="timmy" widget="many2many_tags" placeholder="foo"/>
|
||||
</form>`,
|
||||
});
|
||||
assert.strictEqual(target.querySelector("#timmy_0").placeholder, "foo");
|
||||
});
|
||||
|
||||
QUnit.test("Many2ManyTagsField placeholder should be empty", async function (assert) {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="timmy" widget="many2many_tags"/>
|
||||
</form>`,
|
||||
});
|
||||
assert.strictEqual(target.querySelector("#timmy_0").placeholder, "");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/fields/many2one_barcode_field_tests default=false */
|
||||
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { click, clickSave, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
|
||||
import * as BarcodeScanner from "@web/core/barcode/barcode_dialog";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
const NAME_SEARCH = "name_search";
|
||||
const PRODUCT_PRODUCT = "product.product";
|
||||
const SALE_ORDER_LINE = "sale_order_line";
|
||||
const PRODUCT_FIELD_NAME = "product_id";
|
||||
|
||||
// MockRPC to allow the search in barcode too
|
||||
async function barcodeMockRPC(route, args, performRPC) {
|
||||
if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
|
||||
const result = await performRPC(route, args);
|
||||
const records = serverData.models[PRODUCT_PRODUCT].records
|
||||
.filter((record) => record.barcode === args.kwargs.name)
|
||||
.map((record) => [record.id, record.name]);
|
||||
return records.concat(result);
|
||||
}
|
||||
}
|
||||
|
||||
QUnit.module("Fields", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
[PRODUCT_PRODUCT]: {
|
||||
fields: {
|
||||
id: { type: "integer" },
|
||||
name: {},
|
||||
barcode: {},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 111,
|
||||
name: "product_cable_management_box",
|
||||
barcode: "601647855631",
|
||||
},
|
||||
{
|
||||
id: 112,
|
||||
name: "product_n95_mask",
|
||||
barcode: "601647855632",
|
||||
},
|
||||
{
|
||||
id: 113,
|
||||
name: "product_surgical_mask",
|
||||
barcode: "601647855633",
|
||||
},
|
||||
],
|
||||
},
|
||||
[SALE_ORDER_LINE]: {
|
||||
fields: {
|
||||
id: { type: "integer" },
|
||||
[PRODUCT_FIELD_NAME]: {
|
||||
string: PRODUCT_FIELD_NAME,
|
||||
type: "many2one",
|
||||
relation: PRODUCT_PRODUCT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
"product.product,false,kanban": `
|
||||
<kanban><templates><t t-name="card">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="barcode"/>
|
||||
</t></templates></kanban>
|
||||
`,
|
||||
"product.product,false,search": "<search></search>",
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
|
||||
patchWithCleanup(AutoComplete, {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
// simulate a environment with a camera/webcam
|
||||
patchWithCleanup(
|
||||
browser,
|
||||
Object.assign({}, browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
navigator: {
|
||||
userAgent: "Chrome/0.0.0 (Linux; Android 13; Odoo TestSuite)",
|
||||
mediaDevices: {
|
||||
getUserMedia: () => [],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.module("Many2OneField Barcode (Small)");
|
||||
|
||||
QUnit.test("barcode button with multiple results", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
// The product selected (mock) for the barcode scanner
|
||||
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[1];
|
||||
|
||||
patchWithCleanup(BarcodeScanner, {
|
||||
scanBarcode: async () => "mask",
|
||||
});
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: SALE_ORDER_LINE,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
|
||||
</form>`,
|
||||
async mockRPC(route, args, performRPC) {
|
||||
if (args.method === "web_save" && args.model === SALE_ORDER_LINE) {
|
||||
const selectedId = args.args[1][PRODUCT_FIELD_NAME];
|
||||
assert.equal(
|
||||
selectedId,
|
||||
selectedRecordTest.id,
|
||||
`product id selected ${selectedId}, should be ${selectedRecordTest.id} (${selectedRecordTest.barcode})`
|
||||
);
|
||||
return performRPC(route, args, performRPC);
|
||||
}
|
||||
return barcodeMockRPC(route, args, performRPC);
|
||||
},
|
||||
});
|
||||
|
||||
const scanButton = target.querySelector(".o_barcode");
|
||||
assert.containsOnce(target, scanButton, "has scanner barcode button");
|
||||
|
||||
await click(target, ".o_barcode");
|
||||
|
||||
const modal = target.querySelector(".modal-dialog.modal-lg");
|
||||
assert.containsOnce(target, modal, "there should be one modal opened in full screen");
|
||||
|
||||
assert.containsN(
|
||||
modal,
|
||||
".o_kanban_record:not(.o_kanban_ghost)",
|
||||
2,
|
||||
"there should be 2 records displayed"
|
||||
);
|
||||
|
||||
await click(modal, ".o_kanban_record:nth-child(1)");
|
||||
await clickSave(target);
|
||||
});
|
||||
|
||||
QUnit.test("many2one with barcode show all records", async function (assert) {
|
||||
// The product selected (mock) for the barcode scanner
|
||||
const selectedRecordTest = serverData.models[PRODUCT_PRODUCT].records[0];
|
||||
|
||||
patchWithCleanup(BarcodeScanner, {
|
||||
scanBarcode: async () => selectedRecordTest.barcode,
|
||||
});
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: SALE_ORDER_LINE,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="${PRODUCT_FIELD_NAME}" options="{'can_scan_barcode': True}"/>
|
||||
</form>`,
|
||||
mockRPC: barcodeMockRPC,
|
||||
});
|
||||
|
||||
// Select one product
|
||||
await click(target, ".o_barcode");
|
||||
|
||||
// Click on the input to show all records
|
||||
await click(target, ".o_input_dropdown > input");
|
||||
|
||||
const modal = target.querySelector(".modal-dialog.modal-lg");
|
||||
assert.containsOnce(target, modal, "there should be one modal opened in full screen");
|
||||
|
||||
assert.containsN(
|
||||
modal,
|
||||
".o_kanban_record:not(.o_kanban_ghost)",
|
||||
3,
|
||||
"there should be 3 records displayed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/fields/statusbar_field_tests default=false */
|
||||
|
||||
import { click, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
|
||||
let fixture;
|
||||
let serverData;
|
||||
|
||||
QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
beforeEach(() => {
|
||||
setupViewRegistries();
|
||||
fixture = getFixture();
|
||||
fixture.setAttribute("style", "width:100vw; height:100vh;");
|
||||
registerCleanup(() => fixture.removeAttribute("style"));
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char" },
|
||||
trululu: { string: "Trululu", type: "many2one", relation: "partner" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "first record", trululu: 4 },
|
||||
{ id: 2, display_name: "second record", trululu: 1 },
|
||||
{ id: 3, display_name: "third record" },
|
||||
{ id: 4, display_name: "aaa" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
QUnit.module("StatusBarField");
|
||||
|
||||
QUnit.test("statusbar is rendered correctly on small devices", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<field name="trululu" widget="statusbar" />
|
||||
</header>
|
||||
<field name="display_name" />
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
assert.containsN(fixture, ".o_statusbar_status .o_arrow_button:visible", 4);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .o_arrow_button.dropdown-toggle:visible");
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .o_arrow_button.o_arrow_button_current");
|
||||
assert.containsNone(fixture, ".o-dropdown--menu", "dropdown should be hidden");
|
||||
assert.strictEqual(
|
||||
fixture.querySelector(".o_statusbar_status button.dropdown-toggle").textContent.trim(),
|
||||
"..."
|
||||
);
|
||||
|
||||
// open the dropdown
|
||||
await click(fixture, ".o_statusbar_status .dropdown-toggle.o_last");
|
||||
|
||||
assert.containsOnce(fixture, ".o-dropdown--menu", "dropdown should be visible");
|
||||
assert.containsOnce(fixture, ".o-dropdown--menu .dropdown-item.disabled");
|
||||
});
|
||||
|
||||
QUnit.test("statusbar with no status on extra small screens", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 4,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<field name="trululu" widget="statusbar" />
|
||||
</header>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
assert.doesNotHaveClass(
|
||||
fixture.querySelector(".o_field_statusbar"),
|
||||
"o_field_empty",
|
||||
"statusbar widget should have class o_field_empty in edit"
|
||||
);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status button.dropdown-toggle:visible:disabled");
|
||||
assert.strictEqual(
|
||||
$(".o_statusbar_status button.dropdown-toggle:visible:disabled").text().trim(),
|
||||
"..."
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
QUnit.test("clickable statusbar widget on mobile view", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<field name="trululu" widget="statusbar" options="{'clickable': '1'}" />
|
||||
</header>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
// Open dropdown
|
||||
await click($(".o_statusbar_status .dropdown-toggle:visible")[0]);
|
||||
|
||||
assert.containsOnce(fixture, ".o-dropdown--menu .dropdown-item");
|
||||
|
||||
await click(fixture, ".o-dropdown--menu .dropdown-item");
|
||||
|
||||
assert.strictEqual($(".o_arrow_button_current").text(), "first record");
|
||||
assert.containsN(fixture, ".o_statusbar_status .o_arrow_button:visible", 3);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-toggle:visible");
|
||||
|
||||
// Open second dropdown
|
||||
await click($(".o_statusbar_status .dropdown-toggle:visible")[0]);
|
||||
|
||||
assert.containsN(fixture, ".o-dropdown--menu .dropdown-item", 2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/form_view_tests default=false */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
click,
|
||||
editInput,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { AttachDocumentWidget } from "@web/views/widgets/attach_document/attach_document";
|
||||
|
||||
let fixture;
|
||||
let serverData;
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
QUnit.module("Mobile Views", ({ beforeEach }) => {
|
||||
beforeEach(() => {
|
||||
setupViewRegistries();
|
||||
fixture = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { type: "char", string: "Display Name" },
|
||||
trululu: { type: "many2one", string: "Trululu", relation: "partner" },
|
||||
boolean: { type: "boolean", string: "Bool" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "first record", trululu: 4 },
|
||||
{ id: 2, display_name: "second record", trululu: 1 },
|
||||
{ id: 4, display_name: "aaa" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
QUnit.module("FormView");
|
||||
|
||||
QUnit.test(`statusbar buttons are correctly rendered in mobile`, async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button string="Confirm" />
|
||||
<button string="Do it" />
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<button name="display_name" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
// open the dropdown
|
||||
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
|
||||
assert.containsOnce(fixture, ".o-dropdown--menu:visible", "dropdown should be visible");
|
||||
assert.containsN(
|
||||
fixture,
|
||||
".o-dropdown--menu button",
|
||||
2,
|
||||
"dropdown should contain 2 buttons"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(`statusbar widgets should appear in the CogMenu dropdown`, async (assert) => {
|
||||
serviceRegistry.add("http", {
|
||||
start: () => ({}),
|
||||
});
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
resId: 2,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<widget name="attach_document" string="Attach document" />
|
||||
<button string="Ciao" invisible="display_name == 'first record'" />
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="display_name" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
// Now there should an action dropdown, because there are two visible buttons
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_cp_action_menus button:has(.fa-cog)",
|
||||
"should have 'CogMenu' dropdown"
|
||||
);
|
||||
|
||||
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
|
||||
assert.containsN(
|
||||
fixture,
|
||||
".o-dropdown--menu button",
|
||||
2,
|
||||
"should have 2 buttons in the dropdown"
|
||||
);
|
||||
|
||||
// change display_name to update buttons modifiers and make one button visible
|
||||
await editInput(fixture, ".o_field_widget[name=display_name] input", "first record");
|
||||
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o-dropdown--menu button",
|
||||
"should have 1 button in the dropdown"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(`CogMenu dropdown should keep its open/close state`, async function (assert) {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button string="Just more than one" />
|
||||
<button string="Confirm" invisible="display_name == ''" />
|
||||
<button string="Do it" invisible="display_name != ''" />
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="display_name" />
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_cp_action_menus button:has(.fa-cog)",
|
||||
"should have a 'CogMenu' dropdown"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
|
||||
"show",
|
||||
"dropdown should be closed"
|
||||
);
|
||||
|
||||
// open the dropdown
|
||||
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
|
||||
"show",
|
||||
"dropdown should be opened"
|
||||
);
|
||||
|
||||
// change display_name to update buttons' modifiers
|
||||
await editInput(fixture, ".o_field_widget[name=display_name] input", "test");
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_cp_action_menus button:has(.fa-cog)",
|
||||
"should have a 'CogMenu' dropdown"
|
||||
);
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
|
||||
"show",
|
||||
"dropdown should still be opened"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
`CogMenu dropdown's open/close state shouldn't be modified after 'onchange'`,
|
||||
async function (assert) {
|
||||
serverData.models.partner.onchanges = {
|
||||
display_name: async () => {},
|
||||
};
|
||||
|
||||
const onchangeDef = makeDeferred();
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button name="create" string="Create Invoice" type="action" />
|
||||
<button name="send" string="Send by Email" type="action" />
|
||||
</header>
|
||||
<sheet>
|
||||
<field name="display_name" />
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
mockRPC(route, { method, args }) {
|
||||
if (method === "onchange" && args[2][0] === "display_name") {
|
||||
return onchangeDef;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_cp_action_menus button:has(.fa-cog)",
|
||||
"statusbar should contain a dropdown"
|
||||
);
|
||||
assert.doesNotHaveClass(
|
||||
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
|
||||
"show",
|
||||
"dropdown should be closed"
|
||||
);
|
||||
|
||||
await editInput(fixture, ".o_field_widget[name=display_name] input", "before onchange");
|
||||
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
|
||||
"show",
|
||||
"dropdown should be opened"
|
||||
);
|
||||
|
||||
onchangeDef.resolve({ value: { display_name: "after onchange" } });
|
||||
await nextTick();
|
||||
assert.strictEqual(
|
||||
fixture.querySelector(".o_field_widget[name=display_name] input").value,
|
||||
"after onchange"
|
||||
);
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_cp_action_menus button:has(.fa-cog)"),
|
||||
"show",
|
||||
"dropdown should still be opened"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
`preserve current scroll position on form view while closing dialog`,
|
||||
async function (assert) {
|
||||
serverData.views = {
|
||||
"partner,false,kanban": `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="display_name" />
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
`,
|
||||
"partner,false,search": `
|
||||
<search />
|
||||
`,
|
||||
};
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 2,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<p style="height:500px" />
|
||||
<field name="trululu" />
|
||||
<p style="height:500px" />
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
let position = { top: 0, left: 0 };
|
||||
patchWithCleanup(window, {
|
||||
scrollTo(newPosition) {
|
||||
position = newPosition;
|
||||
},
|
||||
get scrollX() {
|
||||
return position.left;
|
||||
},
|
||||
get scrollY() {
|
||||
return position.top;
|
||||
},
|
||||
});
|
||||
|
||||
window.scrollTo({ top: 265, left: 0 });
|
||||
assert.strictEqual(window.scrollY, 265, "Should have scrolled 265 px vertically");
|
||||
assert.strictEqual(window.scrollX, 0, "Should be 0 px from left as it is");
|
||||
|
||||
// click on m2o field
|
||||
await click(fixture, ".o_field_many2one input");
|
||||
// assert.strictEqual(window.scrollY, 0, "Should have scrolled to top (0) px");
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".modal.o_modal_full",
|
||||
"there should be a many2one modal opened in full screen"
|
||||
);
|
||||
|
||||
// click on back button
|
||||
await click(fixture, ".modal .modal-header .oi-arrow-left");
|
||||
assert.strictEqual(
|
||||
window.scrollY,
|
||||
265,
|
||||
"Should have scrolled back to 265 px vertically"
|
||||
);
|
||||
assert.strictEqual(window.scrollX, 0, "Should be 0 px from left as it is");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("attach_document widget also works inside a dropdown", async (assert) => {
|
||||
let fileInput;
|
||||
patchWithCleanup(AttachDocumentWidget.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
fileInput = this.fileInput;
|
||||
},
|
||||
});
|
||||
|
||||
serviceRegistry.add("http", {
|
||||
start: () => ({
|
||||
post: (route, params) => {
|
||||
assert.step("post");
|
||||
assert.strictEqual(route, "/web/binary/upload_attachment");
|
||||
assert.strictEqual(params.model, "partner");
|
||||
assert.strictEqual(params.id, 1);
|
||||
return '[{ "id": 5 }, { "id": 2 }]';
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button string="Confirm" />
|
||||
<widget name="attach_document" string="Attach Document"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<button name="display_name" />
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
`,
|
||||
});
|
||||
|
||||
await click(fixture, ".o_cp_action_menus button:has(.fa-cog)");
|
||||
await click(fixture, ".o_attach_document");
|
||||
fileInput.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
assert.verifySteps(["post"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/kanban_view_tests default=false */
|
||||
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { click, getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { AnimatedNumber } from "@web/views/view_components/animated_number";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Views", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
product_id: {
|
||||
string: "something_id",
|
||||
type: "many2one",
|
||||
relation: "product",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
foo: "yop",
|
||||
product_id: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
foo: "blip",
|
||||
product_id: 5,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
foo: "gnap",
|
||||
product_id: 3,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
foo: "blip",
|
||||
product_id: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
id: { string: "ID", type: "integer" },
|
||||
name: { string: "Display Name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 3, name: "hello" },
|
||||
{ id: 5, name: "xmo" },
|
||||
],
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
};
|
||||
target = getFixture();
|
||||
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("KanbanView");
|
||||
|
||||
QUnit.test("Should load grouped kanban with folded column", async (assert) => {
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<kanban>
|
||||
<progressbar field="foo" colors='{"yop": "success", "blip": "danger"}'/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<field name="foo"/>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ["product_id"],
|
||||
async mockRPC(route, args, performRPC) {
|
||||
if (args.method === "web_read_group") {
|
||||
const result = await performRPC(route, args);
|
||||
result.groups[1].__fold = true;
|
||||
return result;
|
||||
}
|
||||
},
|
||||
});
|
||||
assert.containsN(target, ".o_column_progress", 2, "Should have 2 progress bar");
|
||||
assert.containsN(target, ".o_kanban_group", 2, "Should have 2 grouped column");
|
||||
assert.containsN(target, ".o_kanban_record", 2, "Should have 2 loaded record");
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_kanban_load_more",
|
||||
"Should have a folded column with a load more button"
|
||||
);
|
||||
await click(target, ".o_kanban_load_more button");
|
||||
assert.containsNone(target, ".o_kanban_load_more", "Shouldn't have a load more button");
|
||||
assert.containsN(target, ".o_kanban_record", 4, "Should have 4 loaded record");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/list_view_tests default=false */
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { click, getFixture, patchWithCleanup, triggerEvents } from "@web/../tests/helpers/utils";
|
||||
import { getMenuItemTexts, toggleActionMenu } from "@web/../tests/search/helpers";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { patchUserWithCleanup } from "../../helpers/mock_services";
|
||||
|
||||
let serverData;
|
||||
let fixture;
|
||||
|
||||
QUnit.module("Mobile Views", ({ beforeEach }) => {
|
||||
beforeEach(() => {
|
||||
setupViewRegistries();
|
||||
fixture = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
foo: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
bar: { string: "Bar", type: "boolean" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, bar: true, foo: "yop" },
|
||||
{ id: 2, bar: true, foo: "blip" },
|
||||
{ id: 3, bar: true, foo: "gnap" },
|
||||
{ id: 4, bar: false, foo: "blip" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn() || true,
|
||||
clearTimeout: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.module("ListView");
|
||||
|
||||
QUnit.test("selection is properly displayed (single page)", async function (assert) {
|
||||
patchUserWithCleanup({ hasGroup: () => Promise.resolve(false) });
|
||||
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</list>
|
||||
`,
|
||||
loadActionMenus: true,
|
||||
});
|
||||
|
||||
assert.containsN(fixture, ".o_data_row", 4);
|
||||
assert.containsNone(fixture, ".o_list_selection_box");
|
||||
assert.containsOnce(fixture, ".o_control_panel .fa-search");
|
||||
|
||||
// select a record
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
|
||||
assert.containsOnce(fixture, ".o_list_selection_box");
|
||||
assert.containsOnce(fixture, ".o_list_selection_box .o_list_select_domain");
|
||||
assert.containsNone(fixture, ".o_control_panel .o_cp_searchview");
|
||||
assert.ok(
|
||||
fixture.querySelector(".o_list_selection_box").textContent.includes("1 selected")
|
||||
);
|
||||
|
||||
// unselect a record
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
|
||||
assert.containsNone(fixture, ".o_list_selection_box .o_list_select_domain");
|
||||
|
||||
// select 2 records
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(2)", ["touchstart", "touchend"]);
|
||||
assert.ok(
|
||||
fixture.querySelector(".o_list_selection_box > span").textContent.includes("2 selected")
|
||||
);
|
||||
assert.ok(
|
||||
fixture.querySelector(".o_list_selection_box > button.o_list_select_domain").textContent.includes("All")
|
||||
);
|
||||
assert.containsOnce(fixture, "div.o_control_panel .o_cp_action_menus");
|
||||
|
||||
await toggleActionMenu(fixture);
|
||||
assert.deepEqual(
|
||||
getMenuItemTexts(fixture),
|
||||
["Duplicate", "Delete"],
|
||||
"action menu should contain the Duplicate and Delete actions"
|
||||
);
|
||||
|
||||
// unselect all
|
||||
await click(fixture, ".o_list_unselect_all");
|
||||
assert.containsNone(fixture, ".o_list_selection_box");
|
||||
assert.containsOnce(fixture, ".o_control_panel .fa-search");
|
||||
});
|
||||
|
||||
QUnit.test("selection box is properly displayed (multi pages)", async function (assert) {
|
||||
patchUserWithCleanup({ hasGroup: () => Promise.resolve(false) });
|
||||
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list limit="3">
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</list>
|
||||
`,
|
||||
loadActionMenus: true,
|
||||
});
|
||||
|
||||
assert.containsN(fixture, ".o_data_row", 3);
|
||||
assert.containsNone(fixture, ".o_list_selection_box");
|
||||
|
||||
// select a record
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
|
||||
|
||||
assert.containsOnce(fixture, ".o_list_selection_box");
|
||||
assert.containsOnce(fixture, ".o_list_selection_box .o_list_select_domain");
|
||||
assert.ok(
|
||||
fixture.querySelector(".o_list_selection_box").textContent.includes("1 selected")
|
||||
);
|
||||
assert.containsOnce(fixture, ".o_list_selection_box");
|
||||
assert.containsOnce(fixture, "div.o_control_panel .o_cp_action_menus");
|
||||
|
||||
await toggleActionMenu(fixture);
|
||||
assert.deepEqual(
|
||||
getMenuItemTexts(fixture),
|
||||
["Duplicate", "Delete"],
|
||||
"action menu should contain the Duplicate and Delete actions"
|
||||
);
|
||||
|
||||
// select all records of first page
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(2)", ["touchstart", "touchend"]);
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(3)", ["touchstart", "touchend"]);
|
||||
assert.containsOnce(fixture, ".o_list_selection_box");
|
||||
assert.containsOnce(fixture, ".o_list_selection_box .o_list_select_domain");
|
||||
assert.ok(
|
||||
fixture.querySelector(".o_list_selection_box").textContent.includes("3 selected")
|
||||
);
|
||||
assert.containsOnce(fixture, ".o_list_select_domain");
|
||||
|
||||
// select all domain
|
||||
await click(fixture, ".o_list_selection_box .o_list_select_domain");
|
||||
assert.containsOnce(fixture, ".o_list_selection_box");
|
||||
assert.ok(
|
||||
fixture.querySelector(".o_list_selection_box").textContent.includes("All 4 selected")
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("export button is properly hidden", async (assert) => {
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list>
|
||||
<field name="foo"/>
|
||||
<field name="bar"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
assert.containsN(fixture, ".o_data_row", 4);
|
||||
assert.isNotVisible(fixture.querySelector(".o_list_export_xlsx"));
|
||||
});
|
||||
|
||||
QUnit.test("editable readonly list view is disabled", async (assert) => {
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list>
|
||||
<field name="foo" />
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
|
||||
await click(fixture, ".o_data_row:nth-child(1) .o_data_cell:nth-child(1)");
|
||||
assert.containsNone(
|
||||
fixture,
|
||||
".o_selected_row .o_field_widget[name=foo]",
|
||||
"The listview should not contains an edit field"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("add custom field button not shown in mobile (with opt. col.)", async (assert) => {
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list>
|
||||
<field name="foo" />
|
||||
<field name="bar" optional="hide" />
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
assert.containsOnce(fixture, "table .o_optional_columns_dropdown_toggle");
|
||||
await click(fixture, "table .o_optional_columns_dropdown_toggle");
|
||||
assert.containsOnce(fixture, ".dropdown-item");
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"add custom field button not shown to non-system users (wo opt. col.)",
|
||||
async (assert) => {
|
||||
patchUserWithCleanup({ isSystem: false });
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list>
|
||||
<field name="foo" />
|
||||
<field name="bar" />
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
assert.containsNone(fixture, "table .o_optional_columns_dropdown_toggle");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("list view header buttons are shift on the cog menus", async (assert) => {
|
||||
await makeView({
|
||||
type: "list",
|
||||
resModel: "foo",
|
||||
serverData,
|
||||
arch: `
|
||||
<list>
|
||||
<header>
|
||||
<button name="x" type="object" class="plaf" string="plaf"/>
|
||||
</header>
|
||||
<field name="foo"/>
|
||||
</list>
|
||||
`,
|
||||
});
|
||||
|
||||
const getTextMenu = () => [...fixture.querySelectorAll(`.o_popover .o-dropdown-item`)].map((e) => e.innerText.trim());
|
||||
assert.containsOnce(fixture, ".o_control_panel_breadcrumbs .o_cp_action_menus .fa-cog");
|
||||
await click(fixture, ".o_control_panel_breadcrumbs .o_cp_action_menus .fa-cog");
|
||||
assert.deepEqual(getTextMenu(), ["Export All"]);
|
||||
await triggerEvents(fixture, ".o_data_row:nth-child(1)", ["touchstart", "touchend"]);
|
||||
await click(fixture, ".o_control_panel_breadcrumbs .o_cp_action_menus .fa-cog");
|
||||
assert.deepEqual(getTextMenu(), ["plaf", "Export", "Duplicate", "Delete"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/view_dialog/select_create_dialog_tests default=false */
|
||||
|
||||
import { click, getFixture, editInput } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
|
||||
QUnit.module("ViewDialogs", (hooks) => {
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
product: {
|
||||
fields: {
|
||||
id: { type: "integer" },
|
||||
name: {},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 111,
|
||||
name: "product_cable_management_box",
|
||||
},
|
||||
],
|
||||
},
|
||||
sale_order_line: {
|
||||
fields: {
|
||||
id: { type: "integer" },
|
||||
product_id: {
|
||||
string: "product_id",
|
||||
type: "many2one",
|
||||
relation: "product",
|
||||
},
|
||||
linked_sale_order_line: {
|
||||
string: "linked_sale_order_line",
|
||||
type: "many2many",
|
||||
relation: "sale_order_line",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
"product,false,kanban": `
|
||||
<kanban><templates><t t-name="card">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
</t></templates></kanban>
|
||||
`,
|
||||
"sale_order_line,false,kanban": `
|
||||
<kanban><templates><t t-name="card">
|
||||
<field name="id"/>
|
||||
</t></templates></kanban>
|
||||
`,
|
||||
"product,false,search": "<search></search>",
|
||||
},
|
||||
};
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("SelectCreateDialog - Mobile");
|
||||
|
||||
QUnit.test("SelectCreateDialog: clear selection in mobile", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "sale_order_line",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="product_id"/>
|
||||
<field name="linked_sale_order_line" widget="many2many_tags"/>
|
||||
</form>`,
|
||||
async mockRPC(route, args) {
|
||||
if (args.method === "web_save" && args.model === "sale_order_line") {
|
||||
const { product_id: selectedId } = args.args[1];
|
||||
assert.strictEqual(selectedId, false, `there should be no product selected`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const clearBtnSelector = ".btn.o_clear_button";
|
||||
|
||||
await click(target, '.o_field_widget[name="linked_sale_order_line"] input');
|
||||
let modal = target.querySelector(".modal-dialog.modal-lg");
|
||||
assert.containsNone(modal, clearBtnSelector, "there shouldn't be a Clear button");
|
||||
await click(modal, ".o_form_button_cancel");
|
||||
|
||||
// Select a product
|
||||
await click(target, '.o_field_widget[name="product_id"] input');
|
||||
modal = target.querySelector(".modal-dialog.modal-lg");
|
||||
await click(modal, ".o_kanban_record:nth-child(1)");
|
||||
|
||||
// Remove the product
|
||||
await click(target, '.o_field_widget[name="product_id"] input');
|
||||
modal = target.querySelector(".modal-dialog.modal-lg");
|
||||
assert.containsOnce(modal, clearBtnSelector, "there should be a Clear button");
|
||||
await click(modal, clearBtnSelector);
|
||||
|
||||
await click(target, ".o_form_button_save");
|
||||
});
|
||||
|
||||
QUnit.test("SelectCreateDialog: selection_mode should be true", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
serverData.views["product,false,kanban"] = `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="o_primary" t-if="!selection_mode">
|
||||
<a type="object" name="some_action">
|
||||
<field name="name"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="o_primary" t-if="selection_mode">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`;
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "sale_order_line",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="product_id"/>
|
||||
<field name="linked_sale_order_line" widget="many2many_tags"/>
|
||||
</form>`,
|
||||
async mockRPC(route, args) {
|
||||
if (args.method === "web_save" && args.model === "sale_order_line") {
|
||||
const { product_id: selectedId } = args.args[1];
|
||||
assert.strictEqual(selectedId, 111, `the product should be selected`);
|
||||
}
|
||||
if (args.method === "some_action") {
|
||||
assert.step("action should not be called");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await click(target, '.o_field_widget[name="product_id"] input');
|
||||
await click(target, ".modal-dialog.modal-lg .o_kanban_record:nth-child(1) .o_primary span");
|
||||
assert.containsNone(target, ".modal-dialog.modal-lg");
|
||||
await click(target, ".o_form_button_save");
|
||||
assert.verifySteps([]);
|
||||
});
|
||||
|
||||
QUnit.test("SelectCreateDialog: default props, create a record", async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
serverData.views["product,false,form"] = `<form><field name="display_name"/></form>`;
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "sale_order_line",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="product_id"/>
|
||||
<field name="linked_sale_order_line" widget="many2many_tags"/>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
await click(target, '.o_field_widget[name="product_id"] input');
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_dialog .o_kanban_view .o_kanban_record:not(.o_kanban_ghost)"
|
||||
);
|
||||
assert.containsN(target, ".o_dialog footer button", 2);
|
||||
assert.containsOnce(target, ".o_dialog footer button.o_create_button");
|
||||
assert.containsOnce(target, ".o_dialog footer button.o_form_button_cancel");
|
||||
assert.containsNone(target, ".o_dialog .o_control_panel_main_buttons .o-kanban-button-new");
|
||||
|
||||
await click(target.querySelector(".o_dialog footer button.o_create_button"));
|
||||
|
||||
assert.containsN(target, ".o_dialog", 2);
|
||||
assert.containsOnce(target, ".o_dialog .o_form_view");
|
||||
|
||||
await editInput(target, ".o_dialog .o_form_view .o_field_widget input", "hello");
|
||||
await click(target.querySelector(".o_dialog .o_form_button_save"));
|
||||
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/views/widgets/signature_tests default=false */
|
||||
import { click, getFixture, patchWithCleanup, editInput, nextTick } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { SignatureWidget } from "@web/views/widgets/signature/signature";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Widgets", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Name", type: "char" },
|
||||
product_id: {
|
||||
string: "Product Name",
|
||||
type: "many2one",
|
||||
relation: "product",
|
||||
},
|
||||
signature: { string: "", type: "string" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "Pop's Chock'lit",
|
||||
product_id: 7,
|
||||
},
|
||||
],
|
||||
onchanges: {},
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
name: { string: "Product Name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 7,
|
||||
display_name: "Veggie Burger",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("Signature Widget");
|
||||
|
||||
QUnit.test("Signature widget works inside of a dropdown", async (assert) => {
|
||||
assert.expect(7);
|
||||
patchWithCleanup(SignatureWidget.prototype, {
|
||||
async onClickSignature() {
|
||||
await super.onClickSignature(...arguments);
|
||||
assert.step("onClickSignature");
|
||||
},
|
||||
async uploadSignature({signatureImage}) {
|
||||
await super.uploadSignature(...arguments);
|
||||
assert.step("uploadSignature");
|
||||
},
|
||||
});
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<header>
|
||||
<button string="Dummy"/>
|
||||
<widget name="signature" string="Sign" full_name="display_name"/>
|
||||
</header>
|
||||
<field name="display_name" />
|
||||
</form>
|
||||
`,
|
||||
mockRPC: async (route, args) => {
|
||||
if (route === "/web/sign/get_fonts/") {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// change display_name to enable auto-sign feature
|
||||
await editInput(target, ".o_field_widget[name=display_name] input", "test");
|
||||
|
||||
// open the signature dialog
|
||||
await click(target, ".o_cp_action_menus button:has(.fa-cog)");
|
||||
await click(target, ".o_widget_signature button.o_sign_button");
|
||||
|
||||
assert.containsOnce(target, ".modal-dialog", "Should have one modal opened");
|
||||
|
||||
// use auto-sign feature, might take a while
|
||||
await click(target, ".o_web_sign_auto_button");
|
||||
|
||||
assert.containsOnce(target, ".modal-footer button.btn-primary");
|
||||
|
||||
let maxDelay = 100;
|
||||
while (target.querySelector(".modal-footer button.btn-primary")["disabled"] && maxDelay > 0) {
|
||||
await nextTick();
|
||||
maxDelay--;
|
||||
}
|
||||
|
||||
assert.equal(maxDelay > 0, true, "Timeout exceeded");
|
||||
|
||||
// close the dialog and save the signature
|
||||
await click(target, ".modal-footer button.btn-primary:enabled");
|
||||
|
||||
assert.containsNone(target, ".modal-dialog", "Should have no modal opened");
|
||||
|
||||
assert.verifySteps(["onClickSignature", "uploadSignature"], "An error has occurred while signing");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/webclient/burger_menu/burger_user_menu_tests default=false */
|
||||
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { BurgerUserMenu } from "@web/webclient/burger_menu/burger_user_menu/burger_user_menu";
|
||||
import { preferencesItem } from "@web/webclient/user_menu/user_menu_items";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
|
||||
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
|
||||
import { markup } from "@odoo/owl";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
const userMenuRegistry = registry.category("user_menuitems");
|
||||
let target;
|
||||
let env;
|
||||
|
||||
QUnit.module("BurgerUserMenu", {
|
||||
async beforeEach() {
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
target = getFixture();
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("can be rendered", async (assert) => {
|
||||
env = await makeTestEnv();
|
||||
userMenuRegistry.add("bad_item", function () {
|
||||
return {
|
||||
type: "item",
|
||||
id: "bad",
|
||||
description: "Bad",
|
||||
callback: () => {
|
||||
assert.step("callback bad_item");
|
||||
},
|
||||
sequence: 10,
|
||||
};
|
||||
});
|
||||
userMenuRegistry.add("ring_item", function () {
|
||||
return {
|
||||
type: "item",
|
||||
id: "ring",
|
||||
description: "Ring",
|
||||
callback: () => {
|
||||
assert.step("callback ring_item");
|
||||
},
|
||||
sequence: 5,
|
||||
};
|
||||
});
|
||||
userMenuRegistry.add("frodo_item", function () {
|
||||
return {
|
||||
type: "switch",
|
||||
id: "frodo",
|
||||
description: "Frodo",
|
||||
callback: () => {
|
||||
assert.step("callback frodo_item");
|
||||
},
|
||||
sequence: 11,
|
||||
};
|
||||
});
|
||||
userMenuRegistry.add("separator", function () {
|
||||
return {
|
||||
type: "separator",
|
||||
sequence: 15,
|
||||
};
|
||||
});
|
||||
userMenuRegistry.add("invisible_item", function () {
|
||||
return {
|
||||
type: "item",
|
||||
id: "hidden",
|
||||
description: "Hidden Power",
|
||||
callback: () => {
|
||||
assert.step("callback hidden_item");
|
||||
},
|
||||
sequence: 5,
|
||||
hide: true,
|
||||
};
|
||||
});
|
||||
userMenuRegistry.add("eye_item", function () {
|
||||
return {
|
||||
type: "item",
|
||||
id: "eye",
|
||||
description: "Eye",
|
||||
callback: () => {
|
||||
assert.step("callback eye_item");
|
||||
},
|
||||
};
|
||||
});
|
||||
userMenuRegistry.add("html_item", function () {
|
||||
return {
|
||||
type: "item",
|
||||
id: "html",
|
||||
description: markup(`<div>HTML<i class="fa fa-check px-2"></i></div>`),
|
||||
callback: () => {
|
||||
assert.step("callback html_item");
|
||||
},
|
||||
sequence: 20,
|
||||
};
|
||||
});
|
||||
await mount(BurgerUserMenu, target, { env });
|
||||
assert.containsN(target, "a", 4);
|
||||
assert.containsOnce(target, ".form-switch input.form-check-input");
|
||||
assert.containsOnce(target, "hr");
|
||||
const items = [...target.querySelectorAll("a, .form-switch")] || [];
|
||||
assert.deepEqual(
|
||||
items.map((el) => el.textContent),
|
||||
["Ring", "Bad", "Frodo", "HTML", "Eye"]
|
||||
);
|
||||
for (const item of items) {
|
||||
click(item);
|
||||
}
|
||||
assert.verifySteps([
|
||||
"callback ring_item",
|
||||
"callback bad_item",
|
||||
"callback frodo_item",
|
||||
"callback html_item",
|
||||
"callback eye_item",
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test("can execute the callback of settings", async (assert) => {
|
||||
const mockRPC = (route) => {
|
||||
if (route === "/web/dataset/call_kw/res.users/action_get") {
|
||||
return Promise.resolve({
|
||||
name: "Change My Preferences",
|
||||
res_id: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
const testConfig = { mockRPC };
|
||||
serviceRegistry.add("localization", makeFakeLocalizationService());
|
||||
serviceRegistry.add("orm", ormService);
|
||||
const fakeActionService = {
|
||||
name: "action",
|
||||
start() {
|
||||
return {
|
||||
doAction(actionId) {
|
||||
assert.step("" + actionId.res_id);
|
||||
assert.step(actionId.name);
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
serviceRegistry.add("action", fakeActionService, { force: true });
|
||||
|
||||
env = await makeTestEnv(testConfig);
|
||||
userMenuRegistry.add("profile", preferencesItem);
|
||||
await mount(BurgerUserMenu, target, { env });
|
||||
assert.containsOnce(target, "a");
|
||||
const item = target.querySelector("a");
|
||||
assert.strictEqual(item.textContent, "Preferences");
|
||||
await click(item);
|
||||
assert.verifySteps(["7", "Change My Preferences"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/webclient/settings_form_view_tests default=false */
|
||||
|
||||
import { getFixture, mockTimeout, nextTick } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { swipeLeft, swipeRight } from "@web/../tests/mobile/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
import { EventBus } from "@odoo/owl";
|
||||
|
||||
let serverData, target;
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
QUnit.module("Mobile SettingsFormView", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
serverData = {
|
||||
models: {
|
||||
project: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "boolean" },
|
||||
bar: { string: "Bar", type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
target = getFixture();
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("BaseSettings Mobile");
|
||||
|
||||
QUnit.test("swipe settings in mobile [REQUIRE TOUCHEVENT]", async function (assert) {
|
||||
const { execRegisteredTimeouts } = mockTimeout();
|
||||
serviceRegistry.add("ui", {
|
||||
start(env) {
|
||||
Object.defineProperty(env, "isSmall", {
|
||||
value: true,
|
||||
});
|
||||
return {
|
||||
bus: new EventBus(),
|
||||
size: 0,
|
||||
isSmall: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "project",
|
||||
serverData,
|
||||
arch: `
|
||||
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
|
||||
<app string="CRM" name="crm">
|
||||
<block>
|
||||
<setting help="this is bar">
|
||||
<field name="bar"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
<app string="Project" name="project">
|
||||
<block>
|
||||
<setting help="this is foo">
|
||||
<field name="foo"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
await swipeLeft(target, ".settings");
|
||||
execRegisteredTimeouts();
|
||||
await nextTick();
|
||||
assert.hasAttrValue(
|
||||
target.querySelector(".selected"),
|
||||
"data-key",
|
||||
"project",
|
||||
"current setting should be project"
|
||||
);
|
||||
|
||||
await swipeRight(target, ".settings");
|
||||
execRegisteredTimeouts();
|
||||
await nextTick();
|
||||
assert.hasAttrValue(
|
||||
target.querySelector(".selected"),
|
||||
"data-key",
|
||||
"crm",
|
||||
"current setting should be crm"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"swipe settings on larger screen sizes has no effect [REQUIRE TOUCHEVENT]",
|
||||
async function (assert) {
|
||||
const { execRegisteredTimeouts } = mockTimeout();
|
||||
serviceRegistry.add("ui", {
|
||||
start(env) {
|
||||
Object.defineProperty(env, "isSmall", {
|
||||
value: false,
|
||||
});
|
||||
return {
|
||||
bus: new EventBus(),
|
||||
size: 9,
|
||||
isSmall: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "project",
|
||||
serverData,
|
||||
arch: `
|
||||
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
|
||||
<app string="CRM" name="crm">
|
||||
<block>
|
||||
<setting help="this is bar">
|
||||
<field name="bar"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
<app string="Project" name="project">
|
||||
<block>
|
||||
<setting help="this is foo">
|
||||
<field name="foo"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
await swipeLeft(target, ".settings");
|
||||
execRegisteredTimeouts();
|
||||
await nextTick();
|
||||
assert.hasAttrValue(
|
||||
target.querySelector(".selected"),
|
||||
"data-key",
|
||||
"crm",
|
||||
"current setting should still be crm"
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/webclient/window_action_tests default=false */
|
||||
|
||||
import { getFixture } from "@web/../tests/helpers/utils";
|
||||
import { setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
|
||||
|
||||
let serverData, target;
|
||||
|
||||
QUnit.module("ActionManager", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
serverData = {
|
||||
models: {
|
||||
project: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "boolean" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
foo: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
foo: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
views: {
|
||||
"project,false,list": '<list><field name="foo"/></list>',
|
||||
"project,false,kanban": `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name='card'>
|
||||
<field name='foo' />
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
`,
|
||||
"project,false,search": "<search></search>",
|
||||
},
|
||||
};
|
||||
target = getFixture();
|
||||
setupViewRegistries();
|
||||
});
|
||||
|
||||
QUnit.module("Window Actions");
|
||||
|
||||
QUnit.test("execute a window action with mobile_view_mode", async (assert) => {
|
||||
const webClient = await createWebClient({ serverData });
|
||||
await doAction(webClient, {
|
||||
xml_id: "project.action",
|
||||
name: "Project Action",
|
||||
res_model: "project",
|
||||
type: "ir.actions.act_window",
|
||||
view_mode: "list,kanban",
|
||||
mobile_view_mode: "list",
|
||||
views: [
|
||||
[false, "kanban"],
|
||||
[false, "list"],
|
||||
],
|
||||
});
|
||||
assert.containsOnce(target, ".o_list_view");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
|||
/** @odoo-module alias=@web/../tests/patch_translations default=false */
|
||||
|
||||
import { translatedTerms, translationLoaded } from "@web/core/l10n/translation";
|
||||
|
||||
translatedTerms[translationLoaded] = true;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
/** @odoo-module **/
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
import { getFixture, nextTick } from "@web/../tests/helpers/utils";
|
||||
import { DEBOUNCE, makeAsyncHandler, makeButtonHandler } from '@web/legacy/js/public/minimal_dom';
|
||||
|
||||
QUnit.module('core', {}, function () {
|
||||
|
||||
QUnit.module('MinimalDom');
|
||||
|
||||
QUnit.test('MakeButtonHandler does not retrigger the same error', async function (assert) {
|
||||
assert.expect(1);
|
||||
assert.expectErrors();
|
||||
|
||||
// create a target for the button handler
|
||||
const fixture = getFixture();
|
||||
const button = document.createElement("button");
|
||||
fixture.appendChild(button);
|
||||
registerCleanup(() => { button.remove(); });
|
||||
|
||||
// get a way to reject the promise later
|
||||
let rejectPromise;
|
||||
const buttonHandler = makeButtonHandler(() => new Promise((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
}));
|
||||
|
||||
// trigger the handler
|
||||
buttonHandler({ target: button });
|
||||
|
||||
// wait for the button effect has been applied before rejecting the promise
|
||||
await new Promise(res => setTimeout(res, DEBOUNCE + 1));
|
||||
rejectPromise(new Error("reject"));
|
||||
|
||||
// check that there was only one unhandledrejection error
|
||||
await nextTick();
|
||||
assert.verifyErrors(["reject"]);
|
||||
});
|
||||
|
||||
QUnit.test('MakeAsyncHandler does not retrigger the same error', async function (assert) {
|
||||
assert.expect(1);
|
||||
assert.expectErrors();
|
||||
|
||||
// get a way to reject the promise later
|
||||
let rejectPromise;
|
||||
const asyncHandler = makeAsyncHandler(() => new Promise((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
}));
|
||||
|
||||
// trigger the handler
|
||||
asyncHandler();
|
||||
|
||||
rejectPromise(new Error("reject"));
|
||||
|
||||
// check that there was only one unhandledrejection error
|
||||
await nextTick();
|
||||
assert.verifyErrors(["reject"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
import publicWidget from "@web/legacy/js/public/public_widget";
|
||||
import testUtils from "@web/../tests/legacy_tests/helpers/test_utils";
|
||||
import { renderToString } from "@web/core/utils/render";
|
||||
|
||||
const Widget = publicWidget.Widget;
|
||||
|
||||
QUnit.module('core', {}, function () {
|
||||
|
||||
QUnit.module('Widget');
|
||||
|
||||
QUnit.test('proxy (String)', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var W = Widget.extend({
|
||||
exec: function () {
|
||||
this.executed = true;
|
||||
}
|
||||
});
|
||||
var w = new W();
|
||||
var fn = w.proxy('exec');
|
||||
fn();
|
||||
assert.ok(w.executed, 'should execute the named method in the right context');
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('proxy (String)(*args)', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var W = Widget.extend({
|
||||
exec: function (arg) {
|
||||
this.executed = arg;
|
||||
}
|
||||
});
|
||||
var w = new W();
|
||||
var fn = w.proxy('exec');
|
||||
fn(42);
|
||||
assert.ok(w.executed, "should execute the named method in the right context");
|
||||
assert.strictEqual(w.executed, 42, "should be passed the proxy's arguments");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('proxy (String), include', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
// the proxy function should handle methods being changed on the class
|
||||
// and should always proxy "by name", to the most recent one
|
||||
var W = Widget.extend({
|
||||
exec: function () {
|
||||
this.executed = 1;
|
||||
}
|
||||
});
|
||||
var w = new W();
|
||||
var fn = w.proxy('exec');
|
||||
W.include({
|
||||
exec: function () { this.executed = 2; }
|
||||
});
|
||||
|
||||
fn();
|
||||
assert.strictEqual(w.executed, 2, "should be lazily resolved");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('proxy (Function)', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var w = new (Widget.extend({ }))();
|
||||
|
||||
var fn = w.proxy(function () { this.executed = true; });
|
||||
fn();
|
||||
assert.ok(w.executed, "should set the function's context (like Function#bind)");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('proxy (Function)(*args)', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var w = new (Widget.extend({ }))();
|
||||
|
||||
var fn = w.proxy(function (arg) { this.executed = arg; });
|
||||
fn(42);
|
||||
assert.strictEqual(w.executed, 42, "should be passed the proxy's arguments");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('renderElement, no template, default', function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
var widget = new (Widget.extend({ }))();
|
||||
|
||||
assert.strictEqual(widget.$el, undefined, "should not have a root element");
|
||||
|
||||
widget.renderElement();
|
||||
|
||||
assert.ok(widget.$el, "should have generated a root element");
|
||||
assert.strictEqual(widget.$el, widget.$el, "should provide $el alias");
|
||||
assert.ok(widget.$el.is(widget.el), "should provide raw DOM alias");
|
||||
|
||||
assert.strictEqual(widget.el.nodeName, 'DIV', "should have generated the default element");
|
||||
assert.strictEqual(widget.el.attributes.length, 0, "should not have generated any attribute");
|
||||
assert.ok(Object.keys(widget.$el.html() || {}).length === 0, "should not have generated any content");
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('no template, custom tag', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
tagName: 'ul'
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
assert.strictEqual(widget.el.nodeName, 'UL', "should have generated the custom element tag");
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('no template, @id', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
id: 'foo'
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
assert.strictEqual(widget.el.attributes.length, 1, "should have one attribute");
|
||||
assert.hasAttrValue(widget.$el, 'id', 'foo', "should have generated the id attribute");
|
||||
assert.strictEqual(widget.el.id, 'foo', "should also be available via property");
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('no template, @className', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
className: 'oe_some_class'
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
assert.strictEqual(widget.el.className, 'oe_some_class', "should have the right property");
|
||||
assert.hasAttrValue(widget.$el, 'class', 'oe_some_class', "should have the right attribute");
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('no template, bunch of attributes', function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
attributes: {
|
||||
'id': 'some_id',
|
||||
'class': 'some_class',
|
||||
'data-foo': 'data attribute',
|
||||
'clark': 'gable',
|
||||
'spoiler': // don't read the next line if you care about Harry Potter...
|
||||
'snape kills dumbledore'
|
||||
}
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
assert.strictEqual(widget.el.attributes.length, 5, "should have all the specified attributes");
|
||||
|
||||
assert.strictEqual(widget.el.id, 'some_id');
|
||||
assert.hasAttrValue(widget.$el, 'id', 'some_id');
|
||||
|
||||
assert.strictEqual(widget.el.className, 'some_class');
|
||||
assert.hasAttrValue(widget.$el, 'class', 'some_class');
|
||||
|
||||
assert.hasAttrValue(widget.$el, 'data-foo', 'data attribute');
|
||||
assert.strictEqual(widget.$el.data('foo'), 'data attribute');
|
||||
|
||||
assert.hasAttrValue(widget.$el, 'clark', 'gable');
|
||||
assert.hasAttrValue(widget.$el, 'spoiler', 'snape kills dumbledore');
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('template', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
renderToString.app.addTemplate(
|
||||
"test.widget.template.1",
|
||||
`<ol>
|
||||
<li t-foreach="[0, 1, 2, 3, 4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
|
||||
<input/>
|
||||
<t t-esc="counter"/>
|
||||
</li>
|
||||
</ol>`
|
||||
);
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
template: 'test.widget.template.1'
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
assert.strictEqual(widget.el.nodeName, 'OL');
|
||||
assert.strictEqual(widget.$el.children().length, 5);
|
||||
assert.strictEqual(widget.el.textContent, '01234');
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('repeated', async function (assert) {
|
||||
assert.expect(4);
|
||||
var $fix = $( "#qunit-fixture");
|
||||
|
||||
renderToString.app.addTemplate(
|
||||
"test.widget.template.2",
|
||||
`<p>
|
||||
<t t-esc="widget.value"/>
|
||||
</p>`
|
||||
);
|
||||
var widget = new (Widget.extend({
|
||||
template: 'test.widget.template.2'
|
||||
}))();
|
||||
widget.value = 42;
|
||||
|
||||
await widget.appendTo($fix)
|
||||
.then(function () {
|
||||
assert.strictEqual($fix.find('p').text(), '42', "DOM fixture should contain initial value");
|
||||
assert.strictEqual(widget.$el.text(), '42', "should set initial value");
|
||||
widget.value = 36;
|
||||
widget.renderElement();
|
||||
assert.strictEqual($fix.find('p').text(), '36', "DOM fixture should use new value");
|
||||
assert.strictEqual(widget.$el.text(), '36', "should set new value");
|
||||
});
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
|
||||
QUnit.module('Widgets, with QWeb');
|
||||
|
||||
QUnit.test('basic-alias', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
renderToString.app.addTemplate(
|
||||
"test.widget.template.3",
|
||||
`<ol>
|
||||
<li t-foreach="[0,1,2,3,4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
|
||||
<input/>
|
||||
<t t-esc="counter"/>
|
||||
</li>
|
||||
</ol>`
|
||||
);
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
template: 'test.widget.template.3'
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
assert.ok(widget.$('li:eq(3)').is(widget.$el.find('li:eq(3)')),
|
||||
"should do the same thing as calling find on the widget root");
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
|
||||
QUnit.test('delegate', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
renderToString.app.addTemplate(
|
||||
"test.widget.template.4",
|
||||
`<ol>
|
||||
<li t-foreach="[0,1,2,3,4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
|
||||
<input/>
|
||||
<t t-esc="counter"/>
|
||||
</li>
|
||||
</ol>`
|
||||
);
|
||||
|
||||
var a = [];
|
||||
var widget = new (Widget.extend({
|
||||
template: 'test.widget.template.4',
|
||||
events: {
|
||||
'click': function () {
|
||||
a[0] = true;
|
||||
assert.strictEqual(this, widget, "should trigger events in widget");
|
||||
},
|
||||
'click li.class-3': 'class3',
|
||||
'change input': function () { a[2] = true; }
|
||||
},
|
||||
class3: function () { a[1] = true; }
|
||||
}))();
|
||||
widget.renderElement();
|
||||
|
||||
await testUtils.dom.click(widget.$el, {allowInvisible: true});
|
||||
await testUtils.dom.click(widget.$('li:eq(3)'), {allowInvisible: true});
|
||||
await testUtils.fields.editAndTrigger(widget.$('input:last'), 'foo', 'change');
|
||||
|
||||
for(var i=0; i<3; ++i) {
|
||||
assert.ok(a[i], "should pass test " + i);
|
||||
}
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('undelegate', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
renderToString.app.addTemplate(
|
||||
"test.widget.template.5",
|
||||
`<ol>
|
||||
<li t-foreach="[0,1,2,3,4]" t-as="counter" t-key="counter_index" t-attf-class="class-#{counter}">
|
||||
<input/>
|
||||
<t t-esc="counter"/>
|
||||
</li>
|
||||
</ol>`
|
||||
);
|
||||
|
||||
var clicked = false;
|
||||
var newclicked = false;
|
||||
|
||||
var widget = new (Widget.extend({
|
||||
template: 'test.widget.template.5',
|
||||
events: { 'click li': function () { clicked = true; } }
|
||||
}))();
|
||||
|
||||
widget.renderElement();
|
||||
widget.$el.on('click', 'li', function () { newclicked = true; });
|
||||
|
||||
await testUtils.dom.clickFirst(widget.$('li'), {allowInvisible: true});
|
||||
assert.ok(clicked, "should trigger bound events");
|
||||
assert.ok(newclicked, "should trigger bound events");
|
||||
|
||||
clicked = newclicked = false;
|
||||
widget._undelegateEvents();
|
||||
await testUtils.dom.clickFirst(widget.$('li'), {allowInvisible: true});
|
||||
assert.ok(!clicked, "undelegate should unbind events delegated");
|
||||
assert.ok(newclicked, "undelegate should only unbind events it created");
|
||||
widget.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('Widget, and async stuff');
|
||||
|
||||
QUnit.test('start is not called when widget is destroyed', function (assert) {
|
||||
assert.expect(0);
|
||||
const $fix = $("#qunit-fixture");
|
||||
|
||||
// Note: willStart is always async
|
||||
const MyWidget = Widget.extend({
|
||||
start: function () {
|
||||
assert.ok(false, 'Should not call start method');
|
||||
},
|
||||
});
|
||||
|
||||
const widget = new MyWidget();
|
||||
widget.appendTo($fix);
|
||||
widget.destroy();
|
||||
|
||||
const divEl = document.createElement('div');
|
||||
$fix[0].appendChild(divEl);
|
||||
const widget2 = new MyWidget();
|
||||
widget2.attachTo(divEl);
|
||||
widget2.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("don't destroy twice widget's children", function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var parent = new Widget();
|
||||
new (Widget.extend({
|
||||
destroy: function () {
|
||||
assert.step('destroy');
|
||||
}
|
||||
}))(parent);
|
||||
|
||||
parent.destroy();
|
||||
assert.verifySteps(['destroy'], "child should have been detroyed only once");
|
||||
});
|
||||
});
|
||||
667
odoo-bringout-oca-ocb-web/web/static/tests/legacy/qunit.js
Normal file
667
odoo-bringout-oca-ocb-web/web/static/tests/legacy/qunit.js
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
/** @odoo-module alias=@web/../tests/qunit default=false */
|
||||
|
||||
import { isVisible as isElemVisible } from "@web/core/utils/ui";
|
||||
import { fullTraceback, fullAnnotatedTraceback } from "@web/core/errors/error_utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Component, whenReady } from "@odoo/owl";
|
||||
|
||||
const consoleError = console.error;
|
||||
|
||||
function setQUnitDebugMode() {
|
||||
whenReady(() => document.body.classList.add("debug")); // make the test visible to the naked eye
|
||||
QUnit.config.debug = true; // allows for helper functions to behave differently (logging, the HTML element in which the test occurs etc...)
|
||||
QUnit.config.testTimeout = 60 * 60 * 1000;
|
||||
// Allows for interacting with the test when it is over
|
||||
// In fact, this will pause QUnit.
|
||||
// Also, logs useful info in the console.
|
||||
QUnit.testDone(async (...args) => {
|
||||
console.groupCollapsed("Debug Test output");
|
||||
console.log(...args);
|
||||
console.groupEnd();
|
||||
await new Promise(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// need to do this outside of the setup function so the QUnit.debug is defined when we need it
|
||||
QUnit.debug = (name, cb) => {
|
||||
setQUnitDebugMode();
|
||||
QUnit.only(name, cb);
|
||||
};
|
||||
|
||||
// need to do this outside of the setup function so it is executed quickly
|
||||
QUnit.config.autostart = false;
|
||||
|
||||
export function setupQUnit() {
|
||||
// -----------------------------------------------------------------------------
|
||||
// QUnit config
|
||||
// -----------------------------------------------------------------------------
|
||||
QUnit.config.testTimeout = 1 * 60 * 1000;
|
||||
QUnit.config.hidepassed = window.location.href.match(/[?&]testId=/) === null;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// QUnit assert
|
||||
// -----------------------------------------------------------------------------
|
||||
/**
|
||||
* Checks that the target contains exactly n matches for the selector.
|
||||
*
|
||||
* Example: assert.containsN(document.body, '.modal', 0)
|
||||
*/
|
||||
function containsN(target, selector, n, msg) {
|
||||
let $el;
|
||||
if (target._widgetRenderAndInsert) {
|
||||
$el = target.$el; // legacy widget
|
||||
} else if (target instanceof Component) {
|
||||
if (!target.el) {
|
||||
throw new Error(
|
||||
`containsN assert with selector '${selector}' called on an unmounted component`
|
||||
);
|
||||
}
|
||||
$el = $(target.el);
|
||||
} else {
|
||||
$el = target instanceof Element ? $(target) : target;
|
||||
}
|
||||
msg = msg || `Selector '${selector}' should have exactly ${n} matches inside the target`;
|
||||
QUnit.assert.strictEqual($el.find(selector).length, n, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the target contains exactly 0 match for the selector.
|
||||
*
|
||||
* @param {Element} el
|
||||
* @param {string} selector
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function containsNone(target, selector, msg) {
|
||||
containsN(target, selector, 0, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the target contains exactly 1 match for the selector.
|
||||
*
|
||||
* @param {Element} el
|
||||
* @param {string} selector
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function containsOnce(target, selector, msg) {
|
||||
containsN(target, selector, 1, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function, to check if a given element has (or has not) classnames.
|
||||
*
|
||||
* @private
|
||||
* @param {Element | jQuery | Widget} el
|
||||
* @param {string} classNames
|
||||
* @param {boolean} shouldHaveClass
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function _checkClass(el, classNames, shouldHaveClass, msg) {
|
||||
if (el) {
|
||||
if (el._widgetRenderAndInsert) {
|
||||
el = el.el; // legacy widget
|
||||
} else if (!(el instanceof Element)) {
|
||||
el = el[0];
|
||||
}
|
||||
}
|
||||
msg =
|
||||
msg ||
|
||||
`target should ${shouldHaveClass ? "have" : "not have"} classnames ${classNames}`;
|
||||
const isFalse = classNames.split(" ").some((cls) => {
|
||||
const hasClass = el.classList.contains(cls);
|
||||
return shouldHaveClass ? !hasClass : hasClass;
|
||||
});
|
||||
QUnit.assert.ok(!isFalse, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the target element has the given classnames.
|
||||
*
|
||||
* @param {Element} el
|
||||
* @param {string} classNames
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function hasClass(el, classNames, msg) {
|
||||
_checkClass(el, classNames, true, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the target element does not have the given classnames.
|
||||
*
|
||||
* @param {Element} el
|
||||
* @param {string} classNames
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function doesNotHaveClass(el, classNames, msg) {
|
||||
_checkClass(el, classNames, false, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the target element (described by widget/jquery or html element)
|
||||
* - exists
|
||||
* - is unique
|
||||
* - has the given attribute with the proper value
|
||||
*
|
||||
* @param {Component | Element | Widget | jQuery} w
|
||||
* @param {string} attr
|
||||
* @param {string} value
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function hasAttrValue(target, attr, value, msg) {
|
||||
let $el;
|
||||
if (target._widgetRenderAndInsert) {
|
||||
$el = target.$el; // legacy widget
|
||||
} else if (target instanceof Component) {
|
||||
if (!target.el) {
|
||||
throw new Error(
|
||||
`hasAttrValue assert with attr '${attr}' called on an unmounted component`
|
||||
);
|
||||
}
|
||||
$el = $(target.el);
|
||||
} else {
|
||||
$el = target instanceof Element ? $(target) : target;
|
||||
}
|
||||
|
||||
if ($el.length !== 1) {
|
||||
const descr = `hasAttrValue (${attr}: ${value})`;
|
||||
QUnit.assert.ok(
|
||||
false,
|
||||
`Assertion '${descr}' targets ${$el.length} elements instead of 1`
|
||||
);
|
||||
} else {
|
||||
msg = msg || `attribute '${attr}' of target should be '${value}'`;
|
||||
QUnit.assert.strictEqual($el.attr(attr), value, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function, to check if a given element
|
||||
* - is unique (if it is a jquery node set)
|
||||
* - is (or not) visible
|
||||
*
|
||||
* @private
|
||||
* @param {Element | jQuery | Widget} el
|
||||
* @param {boolean} shouldBeVisible
|
||||
* @param {string} [msg]
|
||||
*/
|
||||
function _checkVisible(el, shouldBeVisible, msg) {
|
||||
if (el) {
|
||||
if (el._widgetRenderAndInsert) {
|
||||
el = el.el; // legacy widget
|
||||
} else if (!(el instanceof Element)) {
|
||||
el = el[0];
|
||||
}
|
||||
}
|
||||
msg = msg || `target should ${shouldBeVisible ? "" : "not"} be visible`;
|
||||
const _isVisible = isElemVisible(el);
|
||||
const condition = shouldBeVisible ? _isVisible : !_isVisible;
|
||||
QUnit.assert.ok(condition, msg);
|
||||
}
|
||||
function isVisible(el, msg) {
|
||||
return _checkVisible(el, true, msg);
|
||||
}
|
||||
function isNotVisible(el, msg) {
|
||||
return _checkVisible(el, false, msg);
|
||||
}
|
||||
function expectErrors() {
|
||||
QUnit.config.current.expectErrors = true;
|
||||
QUnit.config.current.unverifiedErrors = [];
|
||||
}
|
||||
function verifyErrors(expectedErrors) {
|
||||
if (!QUnit.config.current.expectErrors) {
|
||||
QUnit.pushFailure(`assert.expectErrors() must be called at the beginning of the test`);
|
||||
return;
|
||||
}
|
||||
const unverifiedErrors = QUnit.config.current.unverifiedErrors;
|
||||
QUnit.config.current.assert.deepEqual(unverifiedErrors, expectedErrors, "verifying errors");
|
||||
QUnit.config.current.unverifiedErrors = [];
|
||||
}
|
||||
QUnit.assert.containsN = containsN;
|
||||
QUnit.assert.containsNone = containsNone;
|
||||
QUnit.assert.containsOnce = containsOnce;
|
||||
QUnit.assert.doesNotHaveClass = doesNotHaveClass;
|
||||
QUnit.assert.hasClass = hasClass;
|
||||
QUnit.assert.hasAttrValue = hasAttrValue;
|
||||
QUnit.assert.isVisible = isVisible;
|
||||
QUnit.assert.isNotVisible = isNotVisible;
|
||||
QUnit.assert.expectErrors = expectErrors;
|
||||
QUnit.assert.verifyErrors = verifyErrors;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// QUnit logs
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If we want to log several errors, we have to log all of them at once, as
|
||||
* browser_js is closed as soon as an error is logged.
|
||||
*/
|
||||
let errorMessages = [];
|
||||
async function logErrors() {
|
||||
const messages = errorMessages.slice();
|
||||
errorMessages = [];
|
||||
const infos = await Promise.all(messages);
|
||||
consoleError(infos.map((info) => info.error || info).join("\n"));
|
||||
// Only log the source of the errors in "info" log level to allow matching the same
|
||||
// error with its log message, as source contains asset file name which changes
|
||||
console.info(
|
||||
infos
|
||||
.map((info) =>
|
||||
info.source ? `${info.error}\n${info.source.replace(/^/gm, "\t")}\n` : info
|
||||
)
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we want to log several errors, we have to log all of them at once, as
|
||||
* browser_js is closed as soon as an error is logged.
|
||||
*/
|
||||
QUnit.done(async (result) => {
|
||||
await odoo.loader.checkErrorProm;
|
||||
const moduleLoadingError = document.querySelector(".o_module_error");
|
||||
if (moduleLoadingError) {
|
||||
errorMessages.unshift(moduleLoadingError.innerText);
|
||||
}
|
||||
if (result.failed) {
|
||||
errorMessages.push(`${result.failed} / ${result.total} tests failed.`);
|
||||
}
|
||||
if (!result.failed && !moduleLoadingError) {
|
||||
// use console.dir for this log to appear on runbot sub-builds page
|
||||
console.dir(
|
||||
`QUnit: passed ${testPassedCount} tests (${
|
||||
result.passed
|
||||
} assertions), took ${Math.round(result.runtime / 1000)}s`
|
||||
);
|
||||
console.log("QUnit test suite done.");
|
||||
} else {
|
||||
logErrors();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This is done mostly for the .txt log file generated by the runbot.
|
||||
*/
|
||||
QUnit.moduleDone(async (result) => {
|
||||
if (!result.failed) {
|
||||
console.log(
|
||||
`"${result.name}" passed ${result.tests.length} tests (${result.total} assertions).`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'"' + result.name + '"',
|
||||
"failed",
|
||||
result.failed,
|
||||
"tests out of",
|
||||
result.total,
|
||||
"."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This logs various data in the console, which will be available in the log
|
||||
* .txt file generated by the runbot.
|
||||
*/
|
||||
QUnit.log((result) => {
|
||||
if (result.result) {
|
||||
return;
|
||||
}
|
||||
errorMessages.push(
|
||||
Promise.resolve(result.annotateProm).then(() => {
|
||||
let info = `QUnit test failed: ${result.module} > ${result.name} :`;
|
||||
if (result.message) {
|
||||
info += `\n\tmessage: "${result.message}"`;
|
||||
}
|
||||
if ("expected" in result) {
|
||||
info += `\n\texpected: "${result.expected}"`;
|
||||
}
|
||||
if (result.actual !== null) {
|
||||
info += `\n\tactual: "${result.actual}"`;
|
||||
}
|
||||
return {
|
||||
error: info,
|
||||
source: result.source,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The purpose of this function is to reset the timer nesting level of the execution context
|
||||
* to 0, to prevent situations where a setTimeout with a timeout of 0 may end up being
|
||||
* scheduled after another one that also has a timeout of 0 that was called later.
|
||||
* Example code:
|
||||
* (async () => {
|
||||
* const timeout = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
* const animationFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
*
|
||||
* for (let i = 0; i < 4; i++) {
|
||||
* await timeout();
|
||||
* }
|
||||
* timeout().then(() => console.log("after timeout"));
|
||||
* await animationFrame()
|
||||
* timeout().then(() => console.log("after animationFrame"));
|
||||
* // logs "after animationFrame" before "after timeout"
|
||||
* })()
|
||||
*
|
||||
* When the browser runs a task that was the result of a timer (setTimeout or setInterval),
|
||||
* that task has an intrinsic "timer nesting level". If you schedule another task with
|
||||
* a timer from within such a task, the new task has the existing task's timer nesting level,
|
||||
* plus one. When the timer nesting level of a task is greater than 5, the `timeout` parameter
|
||||
* for setTimeout/setInterval will be forced to at least 4 (see step 5 in the timer initialization
|
||||
* steps in the HTML spec: https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps).
|
||||
*
|
||||
* In the above example, every `await timeout()` besides inside the loop schedules a new task
|
||||
* from within a task that was initiated by a timer, causing the nesting level to be 5 after
|
||||
* the loop. The first timeout after the loop is now forced to 4.
|
||||
*
|
||||
* When we await the animation frame promise, we create a task that is *not* initiated by a timer,
|
||||
* reseting the nesting level to 0, causing the timeout following it to properly be treated as 0,
|
||||
* as such the callback that was registered by it is oftentimes executed before the previous one.
|
||||
*
|
||||
* While we can't prevent this from happening within a given test, we want to at least prevent
|
||||
* the timer nesting level to propagate from one test to the next as this can be a cause of
|
||||
* indeterminism. To avoid slowing down the tests by waiting one frame after every test,
|
||||
* we instead use a MessageChannel to add a task with not nesting level to the event queue immediately.
|
||||
*/
|
||||
QUnit.testDone(async () => {
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = () => {
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
resolve();
|
||||
};
|
||||
channel.port2.postMessage("");
|
||||
});
|
||||
});
|
||||
|
||||
// Append a "Rerun in debug" link.
|
||||
// Only works if the test is not hidden.
|
||||
QUnit.testDone(async ({ testId }) => {
|
||||
if (errorMessages.length > 0) {
|
||||
logErrors();
|
||||
}
|
||||
const testElement = document.getElementById(`qunit-test-output-${testId}`);
|
||||
if (!testElement) {
|
||||
// Is probably hidden because it passed
|
||||
return;
|
||||
}
|
||||
const reRun = testElement.querySelector("li a");
|
||||
const reRunDebug = document.createElement("a");
|
||||
reRunDebug.textContent = "Rerun in debug";
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set("testId", testId);
|
||||
url.searchParams.set("debugTest", "true");
|
||||
reRunDebug.setAttribute("href", url.href);
|
||||
reRun.parentElement.insertBefore(reRunDebug, reRun.nextSibling);
|
||||
});
|
||||
|
||||
const debugTest = new URLSearchParams(location.search).get("debugTest");
|
||||
if (debugTest) {
|
||||
setQUnitDebugMode();
|
||||
}
|
||||
|
||||
// Override global UnhandledRejection that is assigned wayyy before this file
|
||||
// Do not really crash on non-errors rejections
|
||||
const qunitUnhandledReject = QUnit.onUnhandledRejection;
|
||||
QUnit.onUnhandledRejection = (reason) => {
|
||||
const error = reason instanceof Error && "cause" in reason ? reason.cause : reason;
|
||||
if (error instanceof Error) {
|
||||
qunitUnhandledReject(reason);
|
||||
}
|
||||
};
|
||||
|
||||
// Essentially prevents default error logging when the rejection was
|
||||
// not due to an actual error
|
||||
const windowUnhandledReject = window.onunhandledrejection;
|
||||
window.onunhandledrejection = (ev) => {
|
||||
const error =
|
||||
ev.reason instanceof Error && "cause" in ev.reason ? ev.reason.cause : ev.reason;
|
||||
if (!(error instanceof Error)) {
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
} else if (windowUnhandledReject) {
|
||||
windowUnhandledReject.call(window, ev);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FailFast
|
||||
// -----------------------------------------------------------------------------
|
||||
/**
|
||||
* We add here a 'fail fast' feature: we often want to stop the test suite after
|
||||
* the first failed test. This is also useful for the runbot test suites.
|
||||
*/
|
||||
QUnit.config.urlConfig.push({
|
||||
id: "failfast",
|
||||
label: "Fail Fast",
|
||||
tooltip: "Stop the test suite immediately after the first failed test.",
|
||||
});
|
||||
|
||||
QUnit.begin(function () {
|
||||
if (odoo.debug && odoo.debug.includes("assets")) {
|
||||
QUnit.annotateTraceback = fullAnnotatedTraceback;
|
||||
} else {
|
||||
QUnit.annotateTraceback = (err) => Promise.resolve(fullTraceback(err));
|
||||
}
|
||||
const config = QUnit.config;
|
||||
if (config.failfast) {
|
||||
QUnit.testDone(function (details) {
|
||||
if (details.failed > 0) {
|
||||
config.queue.length = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Add sort button
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
let sortButtonAppended = false;
|
||||
/**
|
||||
* Add a sort button on top of the QUnit result page, so we can see which tests
|
||||
* take the most time.
|
||||
*/
|
||||
function addSortButton() {
|
||||
sortButtonAppended = true;
|
||||
var $sort = $("<label> sort by time (desc)</label>").css({ float: "right" });
|
||||
$("h2#qunit-userAgent").append($sort);
|
||||
$sort.click(function () {
|
||||
var $ol = $("ol#qunit-tests");
|
||||
var $results = $ol.children("li").get();
|
||||
$results.sort(function (a, b) {
|
||||
var timeA = Number($(a).find("span.runtime").first().text().split(" ")[0]);
|
||||
var timeB = Number($(b).find("span.runtime").first().text().split(" ")[0]);
|
||||
if (timeA < timeB) {
|
||||
return 1;
|
||||
} else if (timeA > timeB) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
$.each($results, function (idx, $itm) {
|
||||
$ol.append($itm);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
QUnit.done(() => {
|
||||
if (!sortButtonAppended) {
|
||||
addSortButton();
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Add statistics
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
let passedEl;
|
||||
let failedEl;
|
||||
let skippedEl;
|
||||
let todoCompletedEl;
|
||||
let todoUncompletedEl;
|
||||
function insertStats() {
|
||||
const toolbar = document.querySelector("#qunit-testrunner-toolbar .qunit-url-config");
|
||||
const statsEl = document.createElement("label");
|
||||
passedEl = document.createElement("span");
|
||||
passedEl.classList.add("text-success", "ms-5", "me-3");
|
||||
statsEl.appendChild(passedEl);
|
||||
todoCompletedEl = document.createElement("span");
|
||||
todoCompletedEl.classList.add("text-warning", "me-3");
|
||||
statsEl.appendChild(todoCompletedEl);
|
||||
failedEl = document.createElement("span");
|
||||
failedEl.classList.add("text-danger", "me-3");
|
||||
statsEl.appendChild(failedEl);
|
||||
todoUncompletedEl = document.createElement("span");
|
||||
todoUncompletedEl.classList.add("text-primary", "me-3");
|
||||
statsEl.appendChild(todoUncompletedEl);
|
||||
skippedEl = document.createElement("span");
|
||||
skippedEl.classList.add("text-dark");
|
||||
statsEl.appendChild(skippedEl);
|
||||
toolbar.appendChild(statsEl);
|
||||
}
|
||||
|
||||
let testPassedCount = 0;
|
||||
let testFailedCount = 0;
|
||||
let testSkippedCount = 0;
|
||||
let todoCompletedCount = 0;
|
||||
let todoUncompletedCount = 0;
|
||||
QUnit.testDone(({ skipped, failed, todo }) => {
|
||||
if (!passedEl) {
|
||||
insertStats();
|
||||
}
|
||||
if (!skipped) {
|
||||
if (failed > 0) {
|
||||
if (todo) {
|
||||
todoUncompletedCount++;
|
||||
} else {
|
||||
testFailedCount++;
|
||||
}
|
||||
} else {
|
||||
if (todo) {
|
||||
todoCompletedCount++;
|
||||
} else {
|
||||
testPassedCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
testSkippedCount++;
|
||||
}
|
||||
passedEl.innerText = `${testPassedCount} passed`;
|
||||
if (todoCompletedCount > 0) {
|
||||
todoCompletedEl.innerText = `${todoCompletedCount} todo completed`;
|
||||
}
|
||||
if (todoUncompletedCount > 0) {
|
||||
todoUncompletedEl.innerText = `${todoUncompletedCount} todo uncompleted`;
|
||||
}
|
||||
if (testFailedCount > 0) {
|
||||
failedEl.innerText = `${testFailedCount} failed`;
|
||||
}
|
||||
if (testSkippedCount > 0) {
|
||||
skippedEl.innerText = `${testSkippedCount} skipped`;
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FIXME: This sounds stupid, it feels stupid... but it fixes visibility check in folded <details> since Chromium 97+ 💩
|
||||
// Since https://bugs.chromium.org/p/chromium/issues/detail?id=1185950
|
||||
// See regression report https://bugs.chromium.org/p/chromium/issues/detail?id=1276028
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
QUnit.begin(() => {
|
||||
const el = document.createElement("style");
|
||||
el.innerText = "details:not([open]) > :not(summary) { display: none; }";
|
||||
document.head.appendChild(el);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Error management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
QUnit.on("OdooAfterTestHook", (info) => {
|
||||
const { expectErrors, unverifiedErrors } = QUnit.config.current;
|
||||
if (expectErrors && unverifiedErrors.length) {
|
||||
QUnit.pushFailure(
|
||||
`Expected assert.verifyErrors() to be called before end of test. Unverified errors: ${unverifiedErrors}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const { onUnhandledRejection } = QUnit;
|
||||
QUnit.onUnhandledRejection = () => {};
|
||||
QUnit.onError = () => {};
|
||||
|
||||
console.error = function () {
|
||||
if (QUnit.config.current) {
|
||||
QUnit.pushFailure(`console.error called with "${arguments[0]}"`);
|
||||
} else {
|
||||
consoleError(...arguments);
|
||||
}
|
||||
};
|
||||
|
||||
function onUncaughtErrorInTest(error) {
|
||||
if (!QUnit.config.current.expectErrors) {
|
||||
// we did not expect any error, so notify qunit to add a failure
|
||||
onUnhandledRejection(error);
|
||||
} else {
|
||||
// we expected errors, so store it, it will be checked later (see verifyErrors)
|
||||
while (error instanceof Error && "cause" in error) {
|
||||
error = error.cause;
|
||||
}
|
||||
QUnit.config.current.unverifiedErrors.push(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// e.g. setTimeout(() => throw new Error()) (event handler crashes synchronously)
|
||||
window.addEventListener("error", async (ev) => {
|
||||
if (!QUnit.config.current) {
|
||||
return; // we are not in a test -> do nothing
|
||||
}
|
||||
// do not log to the console as this will kill python test early
|
||||
ev.preventDefault();
|
||||
// if the error service is deployed, we'll get to the patched default handler below if no
|
||||
// other handler handled the error, so do nothing here
|
||||
if (registry.category("services").get("error", false)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
ev.message === "ResizeObserver loop limit exceeded" ||
|
||||
ev.message === "ResizeObserver loop completed with undelivered notifications."
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onUncaughtErrorInTest(ev.error);
|
||||
});
|
||||
|
||||
// e.g. Promise.resolve().then(() => throw new Error()) (crash in event handler after async boundary)
|
||||
window.addEventListener("unhandledrejection", async (ev) => {
|
||||
if (!QUnit.config.current) {
|
||||
return; // we are not in a test -> do nothing
|
||||
}
|
||||
// do not log to the console as this will kill python test early
|
||||
ev.preventDefault();
|
||||
// if the error service is deployed, we'll get to the patched default handler below if no
|
||||
// other handler handled the error, so do nothing here
|
||||
if (registry.category("services").get("error", false)) {
|
||||
return;
|
||||
}
|
||||
onUncaughtErrorInTest(ev.reason);
|
||||
});
|
||||
|
||||
// This is an approximation, but we can't directly import the default error handler, because
|
||||
// it's not the same in all tested environments (e.g. /web and /pos), so we get the last item
|
||||
// from the handler registry and assume it is the default one, which handles all "not already
|
||||
// handled" errors, like tracebacks.
|
||||
const errorHandlerRegistry = registry.category("error_handlers");
|
||||
const [defaultHandlerName, defaultHandler] = errorHandlerRegistry.getEntries().at(-1);
|
||||
const testDefaultHandler = (env, uncaughtError, originalError) => {
|
||||
onUncaughtErrorInTest(originalError);
|
||||
return defaultHandler(env, uncaughtError, originalError);
|
||||
};
|
||||
errorHandlerRegistry.add(defaultHandlerName, testDefaultHandler, {
|
||||
sequence: Number.POSITIVE_INFINITY,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
/** @odoo-module alias=@web/../tests/search/helpers default=false */
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import {
|
||||
click,
|
||||
editInput,
|
||||
getFixture,
|
||||
mount,
|
||||
mouseEnter,
|
||||
triggerEvent,
|
||||
triggerEvents,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import { commandService } from "@web/core/commands/command_service";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { fieldService } from "@web/core/field_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { popoverService } from "@web/core/popover/popover_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { CustomFavoriteItem } from "@web/search/custom_favorite_item/custom_favorite_item";
|
||||
import { WithSearch } from "@web/search/with_search/with_search";
|
||||
import { getDefaultConfig } from "@web/views/view";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { nameService } from "@web/core/name_service";
|
||||
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
const favoriteMenuRegistry = registry.category("favoriteMenu");
|
||||
|
||||
export function setupControlPanelServiceRegistry() {
|
||||
serviceRegistry.add("action", actionService);
|
||||
serviceRegistry.add("dialog", dialogService);
|
||||
serviceRegistry.add("field", fieldService);
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("name", nameService);
|
||||
serviceRegistry.add("notification", notificationService);
|
||||
serviceRegistry.add("orm", ormService);
|
||||
serviceRegistry.add("popover", popoverService);
|
||||
serviceRegistry.add("view", viewService);
|
||||
serviceRegistry.add("command", commandService);
|
||||
serviceRegistry.add("datetime_picker", datetimePickerService);
|
||||
}
|
||||
|
||||
export function setupControlPanelFavoriteMenuRegistry() {
|
||||
favoriteMenuRegistry.add(
|
||||
"custom-favorite-item",
|
||||
{ Component: CustomFavoriteItem, groupNumber: 3 },
|
||||
{ sequence: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function makeWithSearch(params) {
|
||||
const props = { ...params };
|
||||
|
||||
const serverData = props.serverData || undefined;
|
||||
const mockRPC = props.mockRPC || undefined;
|
||||
const config = {
|
||||
...getDefaultConfig(),
|
||||
...props.config,
|
||||
};
|
||||
|
||||
delete props.serverData;
|
||||
delete props.mockRPC;
|
||||
delete props.config;
|
||||
const componentProps = props.componentProps || {};
|
||||
delete props.componentProps;
|
||||
delete props.Component;
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<WithSearch t-props="withSearchProps" t-slot-scope="search">
|
||||
<Component t-props="getProps(search)"/>
|
||||
</WithSearch>
|
||||
<MainComponentsContainer />
|
||||
`;
|
||||
static components = { Component: params.Component, WithSearch, MainComponentsContainer };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.withSearchProps = props;
|
||||
}
|
||||
|
||||
getProps(search) {
|
||||
const props = Object.assign({}, componentProps, {
|
||||
context: search.context,
|
||||
domain: search.domain,
|
||||
groupBy: search.groupBy,
|
||||
orderBy: search.orderBy,
|
||||
comparison: search.comparison,
|
||||
display: Object.assign({}, search.display, componentProps.display),
|
||||
});
|
||||
return filterPropsForComponent(params.Component, props);
|
||||
}
|
||||
}
|
||||
|
||||
const env = await makeTestEnv({ serverData, mockRPC });
|
||||
const searchEnv = Object.assign(Object.create(env), { config });
|
||||
const parent = await mount(Parent, getFixture(), { env: searchEnv, props });
|
||||
const parentNode = parent.__owl__;
|
||||
const withSearchNode = getUniqueChild(parentNode);
|
||||
const componentNode = getUniqueChild(withSearchNode);
|
||||
const component = componentNode.component;
|
||||
return component;
|
||||
}
|
||||
|
||||
/** This function is aim to be used only in the tests.
|
||||
* It will filter the props that are needed by the Component.
|
||||
* This is to avoid errors of props validation. This occurs for example, on ControlPanel tests.
|
||||
* In production, View use WithSearch for the Controllers, and the Layout send only the props that
|
||||
* need to the ControlPanel.
|
||||
*
|
||||
* @param {Component} Component
|
||||
* @param {Object} props
|
||||
* @returns {Object} filtered props
|
||||
*/
|
||||
function filterPropsForComponent(Component, props) {
|
||||
// This if, can be removed once all the Components have the props defined
|
||||
if (Component.props) {
|
||||
let componentKeys = null;
|
||||
if (Component.props instanceof Array) {
|
||||
componentKeys = Component.props.map((x) => x.replace("?", ""));
|
||||
} else {
|
||||
componentKeys = Object.keys(Component.props);
|
||||
}
|
||||
if (componentKeys.includes("*")) {
|
||||
return props;
|
||||
} else {
|
||||
return Object.keys(props)
|
||||
.filter((k) => componentKeys.includes(k))
|
||||
.reduce((o, k) => {
|
||||
o[k] = props[k];
|
||||
return o;
|
||||
}, {});
|
||||
}
|
||||
} else {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueChild(node) {
|
||||
return Object.values(node.children)[0];
|
||||
}
|
||||
|
||||
function getNode(target) {
|
||||
return target instanceof Component ? target.el : target;
|
||||
}
|
||||
|
||||
export function findItem(target, selector, finder = 0) {
|
||||
const el = getNode(target);
|
||||
const elems = [...el.querySelectorAll(selector)];
|
||||
if (Number.isInteger(finder)) {
|
||||
return elems[finder];
|
||||
}
|
||||
return elems.find((el) => el.innerText.trim().toLowerCase() === String(finder).toLowerCase());
|
||||
}
|
||||
|
||||
/** Menu (generic) */
|
||||
|
||||
export async function toggleMenu(el, menuFinder) {
|
||||
const menu = findItem(el, `button.dropdown-toggle`, menuFinder);
|
||||
await click(menu);
|
||||
}
|
||||
|
||||
export async function toggleMenuItem(el, itemFinder) {
|
||||
const item = findItem(el, `.o_menu_item`, itemFinder);
|
||||
if (item.classList.contains("dropdown-toggle")) {
|
||||
await mouseEnter(item);
|
||||
} else {
|
||||
await click(item);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMenuItemOption(el, itemFinder, optionFinder) {
|
||||
const item = findItem(el, `.o_menu_item`, itemFinder);
|
||||
const option = findItem(item.parentNode, ".o_item_option", optionFinder);
|
||||
if (option.classList.contains("dropdown-toggle")) {
|
||||
await mouseEnter(option);
|
||||
} else {
|
||||
await click(option);
|
||||
}
|
||||
}
|
||||
|
||||
export function isItemSelected(el, itemFinder) {
|
||||
const item = findItem(el, `.o_menu_item`, itemFinder);
|
||||
return item.classList.contains("selected");
|
||||
}
|
||||
|
||||
export function isOptionSelected(el, itemFinder, optionFinder) {
|
||||
const item = findItem(el, `.o_menu_item`, itemFinder);
|
||||
const option = findItem(item.parentNode, ".o_item_option", optionFinder);
|
||||
return option.classList.contains("selected");
|
||||
}
|
||||
|
||||
export function getMenuItemTexts(target) {
|
||||
const el = getNode(target);
|
||||
return [...el.querySelectorAll(`.dropdown-menu .o_menu_item`)].map((e) => e.innerText.trim());
|
||||
}
|
||||
|
||||
export function getVisibleButtons(el) {
|
||||
return [
|
||||
...$(el).find(
|
||||
[
|
||||
"div.o_control_panel_breadcrumbs button:visible", // button in the breadcrumbs
|
||||
"div.o_control_panel_actions button:visible", // buttons for list selection
|
||||
].join(",")
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Filter menu */
|
||||
|
||||
export async function openAddCustomFilterDialog(el) {
|
||||
await click(findItem(el, `.o_filter_menu .o_menu_item.o_add_custom_filter`));
|
||||
}
|
||||
|
||||
/** Group by menu */
|
||||
|
||||
export async function selectGroup(el, fieldName) {
|
||||
el.querySelector(".o_add_custom_group_menu").value = fieldName;
|
||||
await triggerEvent(el, ".o_add_custom_group_menu", "change");
|
||||
}
|
||||
|
||||
export async function groupByMenu(el, fieldName) {
|
||||
await toggleSearchBarMenu(el);
|
||||
await selectGroup(el, fieldName);
|
||||
}
|
||||
|
||||
/** Favorite menu */
|
||||
|
||||
export async function deleteFavorite(el, favoriteFinder) {
|
||||
const favorite = findItem(el, `.o_favorite_menu .o_menu_item`, favoriteFinder);
|
||||
await click(findItem(favorite, "i.fa-trash-o"));
|
||||
}
|
||||
|
||||
export async function toggleSaveFavorite(el) {
|
||||
await click(findItem(el, `.o_favorite_menu .o_add_favorite`));
|
||||
}
|
||||
|
||||
export async function editFavoriteName(el, name) {
|
||||
const input = findItem(
|
||||
el,
|
||||
`.o_favorite_menu .o_add_favorite + .o_accordion_values input[type="text"]`
|
||||
);
|
||||
input.value = name;
|
||||
await triggerEvents(input, null, ["input", "change"]);
|
||||
}
|
||||
|
||||
export async function saveFavorite(el) {
|
||||
await click(findItem(el, `.o_favorite_menu .o_add_favorite + .o_accordion_values button`));
|
||||
}
|
||||
|
||||
/** Search bar */
|
||||
|
||||
export function getFacetTexts(target) {
|
||||
const el = getNode(target);
|
||||
return [...el.querySelectorAll(`div.o_searchview_facet`)].map((facet) =>
|
||||
facet.innerText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeFacet(el, facetFinder = 0) {
|
||||
const facet = findItem(el, `div.o_searchview_facet`, facetFinder);
|
||||
await click(facet.querySelector(".o_facet_remove"));
|
||||
}
|
||||
|
||||
export async function editSearch(el, value) {
|
||||
const input = findItem(el, `.o_searchview input`);
|
||||
input.value = value;
|
||||
await triggerEvent(input, null, "input");
|
||||
}
|
||||
|
||||
export async function validateSearch(el) {
|
||||
const input = findItem(el, `.o_searchview input`);
|
||||
await triggerEvent(input, null, "keydown", { key: "Enter" });
|
||||
}
|
||||
|
||||
/** Switch View */
|
||||
|
||||
export async function switchView(el, viewType) {
|
||||
await click(findItem(el, `button.o_switch_view.o_${viewType}`));
|
||||
}
|
||||
|
||||
/** Pager */
|
||||
|
||||
export function getPagerValue(el) {
|
||||
const valueEl = findItem(el, ".o_pager .o_pager_value");
|
||||
return valueEl.innerText.trim().split("-").map(Number);
|
||||
}
|
||||
|
||||
export function getPagerLimit(el) {
|
||||
const limitEl = findItem(el, ".o_pager .o_pager_limit");
|
||||
return Number(limitEl.innerText.trim());
|
||||
}
|
||||
|
||||
export async function pagerNext(el) {
|
||||
await click(findItem(el, ".o_pager button.o_pager_next"));
|
||||
}
|
||||
|
||||
export async function pagerPrevious(el) {
|
||||
await click(findItem(el, ".o_pager button.o_pager_previous"));
|
||||
}
|
||||
|
||||
export async function editPager(el, value) {
|
||||
await click(findItem(el, ".o_pager .o_pager_value"));
|
||||
await editInput(getNode(el), ".o_pager .o_pager_value.o_input", value);
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
// Action Menu
|
||||
/////////////////////////////////////
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} [menuFinder="Action"]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function toggleActionMenu(el) {
|
||||
await click(el.querySelector(".o_cp_action_menus .dropdown-toggle"));
|
||||
}
|
||||
|
||||
/** SearchBarMenu */
|
||||
export async function toggleSearchBarMenu(el) {
|
||||
await click(findItem(el, `.o_searchview_dropdown_toggler`));
|
||||
}
|
||||
404
odoo-bringout-oca-ocb-web/web/static/tests/legacy/setup.js
Normal file
404
odoo-bringout-oca-ocb-web/web/static/tests/legacy/setup.js
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
/** @odoo-module alias=@web/../tests/setup default=false */
|
||||
|
||||
import { assets } from "@web/core/assets";
|
||||
import { user, _makeUser } from "@web/core/user";
|
||||
import { browser, makeRAMLocalStorage } from "@web/core/browser/browser";
|
||||
import { patchTimeZone, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { memoize } from "@web/core/utils/functions";
|
||||
import { registerCleanup } from "./helpers/cleanup";
|
||||
import { prepareRegistriesWithCleanup } from "./helpers/mock_env";
|
||||
import { session as sessionInfo } from "@web/session";
|
||||
import { config as transitionConfig } from "@web/core/transition";
|
||||
import { loadLanguages } from "@web/core/l10n/translation";
|
||||
|
||||
transitionConfig.disabled = true;
|
||||
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { App, EventBus, whenReady } from "@odoo/owl";
|
||||
import { currencies } from "@web/core/currency";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { router } from "@web/core/browser/router";
|
||||
import { registerTemplateProcessor } from "@web/core/templates";
|
||||
|
||||
function forceLocaleAndTimezoneWithCleanup() {
|
||||
const originalLocale = luxon.Settings.defaultLocale;
|
||||
luxon.Settings.defaultLocale = "en";
|
||||
registerCleanup(() => {
|
||||
luxon.Settings.defaultLocale = originalLocale;
|
||||
});
|
||||
patchTimeZone(60);
|
||||
}
|
||||
|
||||
function makeMockLocation() {
|
||||
return Object.assign(document.createElement("a"), {
|
||||
href: window.location.origin + "/odoo",
|
||||
assign(url) {
|
||||
this.href = url;
|
||||
},
|
||||
reload() {},
|
||||
});
|
||||
}
|
||||
|
||||
function patchOwlApp() {
|
||||
patchWithCleanup(App.prototype, {
|
||||
destroy() {
|
||||
if (!this.destroyed) {
|
||||
super.destroy(...arguments);
|
||||
this.destroyed = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function patchCookie() {
|
||||
const cookieJar = {};
|
||||
|
||||
patchWithCleanup(cookie, {
|
||||
get _cookieMonster() {
|
||||
return Object.entries(cookieJar)
|
||||
.filter(([, value]) => value !== "kill")
|
||||
.map((cookie) => cookie.join("="))
|
||||
.join("; ");
|
||||
},
|
||||
set _cookieMonster(value) {
|
||||
const cookies = value.split("; ");
|
||||
for (const cookie of cookies) {
|
||||
const [key, value] = cookie.split(/=(.*)/);
|
||||
if (!["path", "max-age"].includes(key)) {
|
||||
cookieJar[key] = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function patchBrowserWithCleanup() {
|
||||
const originalAddEventListener = browser.addEventListener;
|
||||
const originalRemoveEventListener = browser.removeEventListener;
|
||||
const originalSetTimeout = browser.setTimeout;
|
||||
const originalClearTimeout = browser.clearTimeout;
|
||||
const originalSetInterval = browser.setInterval;
|
||||
const originalClearInterval = browser.clearInterval;
|
||||
|
||||
let nextAnimationFrameHandle = 1;
|
||||
const animationFrameHandles = new Set();
|
||||
const mockLocation = makeMockLocation();
|
||||
let historyStack = [[null, mockLocation.href]];
|
||||
let currentHistoryStack = 0;
|
||||
patchWithCleanup(browser, {
|
||||
// patch addEventListner to automatically remove listeners bound (via
|
||||
// browser.addEventListener) during a test (e.g. during the deployment of a service)
|
||||
addEventListener() {
|
||||
originalAddEventListener(...arguments);
|
||||
registerCleanup(() => {
|
||||
originalRemoveEventListener(...arguments);
|
||||
});
|
||||
},
|
||||
// patch setTimeout to automatically remove timeouts bound (via
|
||||
// browser.setTimeout) during a test (e.g. during the deployment of a service)
|
||||
setTimeout() {
|
||||
const timeout = originalSetTimeout(...arguments);
|
||||
registerCleanup(() => {
|
||||
originalClearTimeout(timeout);
|
||||
});
|
||||
return timeout;
|
||||
},
|
||||
// patch setInterval to automatically remove callbacks registered (via
|
||||
// browser.setInterval) during a test (e.g. during the deployment of a service)
|
||||
setInterval() {
|
||||
const interval = originalSetInterval(...arguments);
|
||||
registerCleanup(() => {
|
||||
originalClearInterval(interval);
|
||||
});
|
||||
return interval;
|
||||
},
|
||||
// patch BeforeInstallPromptEvent to prevent the pwa service to return an uncontrolled
|
||||
// canPromptToInstall value depending the browser settings (we ensure the value is always falsy)
|
||||
BeforeInstallPromptEvent: undefined,
|
||||
navigator: {
|
||||
mediaDevices: browser.navigator.mediaDevices,
|
||||
permissions: browser.navigator.permissions,
|
||||
userAgent: browser.navigator.userAgent.replace(/\([^)]*\)/, "(X11; Linux x86_64)"),
|
||||
sendBeacon: () => {
|
||||
throw new Error("sendBeacon called in test but not mocked");
|
||||
},
|
||||
},
|
||||
// in tests, we never want to interact with the real url or reload the page
|
||||
location: mockLocation,
|
||||
history: {
|
||||
pushState(state, title, url) {
|
||||
historyStack = historyStack.slice(0, currentHistoryStack + 1);
|
||||
historyStack.push([state, url]);
|
||||
currentHistoryStack++;
|
||||
mockLocation.assign(url);
|
||||
},
|
||||
replaceState(state, title, url) {
|
||||
historyStack[currentHistoryStack] = [state, url];
|
||||
mockLocation.assign(url);
|
||||
},
|
||||
back() {
|
||||
currentHistoryStack--;
|
||||
const [state, url] = historyStack[currentHistoryStack];
|
||||
if (!url) {
|
||||
throw new Error("there is no history");
|
||||
}
|
||||
mockLocation.assign(url);
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
||||
},
|
||||
forward() {
|
||||
currentHistoryStack++;
|
||||
const [state, url] = historyStack[currentHistoryStack];
|
||||
if (!url) {
|
||||
throw new Error("No more history");
|
||||
}
|
||||
mockLocation.assign(url);
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state }));
|
||||
},
|
||||
get length() {
|
||||
return historyStack.length;
|
||||
},
|
||||
},
|
||||
// in tests, we never want to interact with the real local/session storages.
|
||||
localStorage: makeRAMLocalStorage(),
|
||||
sessionStorage: makeRAMLocalStorage(),
|
||||
// Don't want original animation frames in tests
|
||||
requestAnimationFrame: (fn) => {
|
||||
const handle = nextAnimationFrameHandle++;
|
||||
animationFrameHandles.add(handle);
|
||||
|
||||
Promise.resolve().then(() => {
|
||||
if (animationFrameHandles.has(handle)) {
|
||||
fn(16);
|
||||
}
|
||||
});
|
||||
|
||||
return handle;
|
||||
},
|
||||
cancelAnimationFrame: (handle) => {
|
||||
animationFrameHandles.delete(handle);
|
||||
},
|
||||
// BroadcastChannels need to be closed to be garbage collected
|
||||
BroadcastChannel: class SelfClosingBroadcastChannel extends BroadcastChannel {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
registerCleanup(() => this.close());
|
||||
}
|
||||
},
|
||||
// XHR: we don't want tests to do real RPCs
|
||||
XMLHttpRequest: class MockXHR {
|
||||
constructor() {
|
||||
throw new Error("XHR not patched in a test. Consider using patchRPCWithCleanup.");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function patchBodyAddEventListener() {
|
||||
// In some cases, e.g. tooltip service, event handlers are registered on document.body and not
|
||||
// browser, because the events we listen to aren't triggered on window. We want to clear those
|
||||
// handlers as well after each test.
|
||||
const originalBodyAddEventListener = document.body.addEventListener;
|
||||
const originalBodyRemoveEventListener = document.body.removeEventListener;
|
||||
document.body.addEventListener = function () {
|
||||
originalBodyAddEventListener.call(this, ...arguments);
|
||||
registerCleanup(() => {
|
||||
originalBodyRemoveEventListener.call(this, ...arguments);
|
||||
});
|
||||
};
|
||||
registerCleanup(() => {
|
||||
document.body.addEventListener = originalBodyAddEventListener;
|
||||
});
|
||||
}
|
||||
|
||||
function patchOdoo() {
|
||||
patchWithCleanup(odoo, {
|
||||
debug: "",
|
||||
});
|
||||
}
|
||||
|
||||
function cleanLoadedLanguages() {
|
||||
registerCleanup(() => {
|
||||
loadLanguages.installedLanguages = null;
|
||||
});
|
||||
}
|
||||
|
||||
function patchSessionInfo() {
|
||||
patchWithCleanup(sessionInfo, {
|
||||
cache_hashes: {
|
||||
load_menus: "161803",
|
||||
translations: "314159",
|
||||
},
|
||||
qweb: "owl",
|
||||
// Commit: 3e847fc8f499c96b8f2d072ab19f35e105fd7749
|
||||
// to see what user_companies is
|
||||
user_companies: {
|
||||
allowed_companies: { 1: { id: 1, name: "Hermit" } },
|
||||
current_company: 1,
|
||||
},
|
||||
user_context: {
|
||||
lang: "en",
|
||||
tz: "taht",
|
||||
},
|
||||
db: "test",
|
||||
is_admin: true,
|
||||
is_system: true,
|
||||
username: "thewise@odoo.com",
|
||||
name: "Mitchell",
|
||||
partner_id: 7,
|
||||
uid: 7,
|
||||
server_version: "1.0",
|
||||
server_version_info: [1, 0, 0, "final", 0, ""],
|
||||
});
|
||||
const mockedUser = _makeUser(sessionInfo);
|
||||
patchWithCleanup(user, mockedUser);
|
||||
patchWithCleanup(user, { hasGroup: () => Promise.resolve(false) });
|
||||
patchWithCleanup(currencies, {
|
||||
1: { name: "USD", digits: [69, 2], position: "before", symbol: "$" },
|
||||
2: { name: "EUR", digits: [69, 2], position: "after", symbol: "€" },
|
||||
});
|
||||
}
|
||||
|
||||
function replaceAttr(attrName, prefix, element) {
|
||||
const attrKey = `${prefix}${attrName}`;
|
||||
const attrValue = element.getAttribute(attrKey);
|
||||
element.removeAttribute(attrKey);
|
||||
element.setAttribute(`${prefix}data-${attrName}`, attrValue);
|
||||
}
|
||||
|
||||
registerTemplateProcessor((template) => {
|
||||
// We remove all the attributes `src` and `alt` from the template and replace them by
|
||||
// data attributes (e.g. `src` to `data-src`, `alt` to `data-alt`).
|
||||
// alt attribute causes issues with scroll tests. Indeed, alt is
|
||||
// displayed between the time we scroll programmatically and the time
|
||||
// we assert for the scroll position. The src attribute is removed
|
||||
// as well to make sure images won't trigger a GET request on the
|
||||
// server.
|
||||
for (const attrName of ["alt", "src"]) {
|
||||
for (const prefix of ["", "t-att-", "t-attf-"]) {
|
||||
for (const element of template.querySelectorAll(`*[${prefix}${attrName}]`)) {
|
||||
replaceAttr(attrName, prefix, element);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function patchAssets() {
|
||||
const { getBundle, loadJS, loadCSS } = assets;
|
||||
patch(assets, {
|
||||
getBundle: memoize(async function (xmlID) {
|
||||
console.log(
|
||||
"%c[assets] fetch libs from xmlID: " + xmlID,
|
||||
"color: #66e; font-weight: bold;"
|
||||
);
|
||||
return getBundle(xmlID);
|
||||
}),
|
||||
loadJS: memoize(async function (ressource) {
|
||||
if (ressource.match(/\/static(\/\S+\/|\/)libs?/)) {
|
||||
console.log(
|
||||
"%c[assets] fetch (mock) JS ressource: " + ressource,
|
||||
"color: #66e; font-weight: bold;"
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
console.log(
|
||||
"%c[assets] fetch JS ressource: " + ressource,
|
||||
"color: #66e; font-weight: bold;"
|
||||
);
|
||||
return loadJS(ressource);
|
||||
}),
|
||||
loadCSS: memoize(async function (ressource) {
|
||||
if (ressource.match(/\/static(\/\S+\/|\/)libs?/)) {
|
||||
console.log(
|
||||
"%c[assets] fetch (mock) CSS ressource: " + ressource,
|
||||
"color: #66e; font-weight: bold;"
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
console.log(
|
||||
"%c[assets] fetch CSS ressource: " + ressource,
|
||||
"color: #66e; font-weight: bold;"
|
||||
);
|
||||
return loadCSS(ressource);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function patchEventBus() {
|
||||
patchWithCleanup(EventBus.prototype, {
|
||||
addEventListener() {
|
||||
super.addEventListener(...arguments);
|
||||
registerCleanup(() => this.removeEventListener(...arguments));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupTests() {
|
||||
// uncomment to debug memory leaks in qunit suite
|
||||
// if (window.gc) {
|
||||
// let memoryBeforeModule;
|
||||
// QUnit.moduleStart(({ tests }) => {
|
||||
// if (tests.length) {
|
||||
// window.gc();
|
||||
// memoryBeforeModule = window.performance.memory.usedJSHeapSize;
|
||||
// }
|
||||
// });
|
||||
// QUnit.moduleDone(({ name }) => {
|
||||
// if (memoryBeforeModule) {
|
||||
// window.gc();
|
||||
// const afterGc = window.performance.memory.usedJSHeapSize;
|
||||
// console.log(
|
||||
// `MEMINFO - After suite "${name}" - after gc: ${afterGc} delta: ${
|
||||
// afterGc - memoryBeforeModule
|
||||
// }`
|
||||
// );
|
||||
// memoryBeforeModule = null;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
QUnit.testStart(() => {
|
||||
prepareRegistriesWithCleanup();
|
||||
forceLocaleAndTimezoneWithCleanup();
|
||||
cleanLoadedLanguages();
|
||||
patchBrowserWithCleanup();
|
||||
registerCleanup(router.cancelPushes);
|
||||
patchCookie();
|
||||
patchBodyAddEventListener();
|
||||
patchEventBus();
|
||||
patchOdoo();
|
||||
patchSessionInfo();
|
||||
patchOwlApp();
|
||||
});
|
||||
|
||||
await whenReady();
|
||||
patchAssets();
|
||||
|
||||
// make sure images do not trigger a GET on the server
|
||||
new MutationObserver((mutations) => {
|
||||
const nodes = mutations.flatMap(({ target }) => {
|
||||
if (target.nodeName === "IMG" || target.nodeName === "IFRAME") {
|
||||
return target;
|
||||
}
|
||||
return [
|
||||
...target.getElementsByTagName("img"),
|
||||
...target.getElementsByTagName("iframe"),
|
||||
];
|
||||
});
|
||||
for (const node of nodes) {
|
||||
const src = node.getAttribute("src");
|
||||
if (src && src !== "about:blank") {
|
||||
node.dataset.src = src;
|
||||
if (node.nodeName === "IMG") {
|
||||
node.removeAttribute("src");
|
||||
} else {
|
||||
node.setAttribute("src", "about:blank");
|
||||
}
|
||||
node.dispatchEvent(new Event("load"));
|
||||
}
|
||||
}
|
||||
}).observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributeFilter: ["src"],
|
||||
});
|
||||
}
|
||||
685
odoo-bringout-oca-ocb-web/web/static/tests/legacy/utils.js
Normal file
685
odoo-bringout-oca-ocb-web/web/static/tests/legacy/utils.js
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
/** @odoo-module alias=@web/../tests/utils default=false */
|
||||
|
||||
import { isVisible } from "@web/core/utils/ui";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
import {
|
||||
click as webClick,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
triggerEvents as webTriggerEvents,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
|
||||
/**
|
||||
* Create a fake object 'dataTransfer', linked to some files,
|
||||
* which is passed to drag and drop events.
|
||||
*
|
||||
* @param {Object[]} files
|
||||
* @returns {Object}
|
||||
*/
|
||||
function createFakeDataTransfer(files) {
|
||||
return {
|
||||
dropEffect: "all",
|
||||
effectAllowed: "all",
|
||||
files,
|
||||
items: [],
|
||||
types: ["Files"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then clicks on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
* @param {boolean} [options.shiftKey]
|
||||
*/
|
||||
export async function click(selector, options = {}) {
|
||||
const { shiftKey } = options;
|
||||
delete options.shiftKey;
|
||||
await contains(selector, { click: { shiftKey }, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then dragenters `files` on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {Object[]} files
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function dragenterFiles(selector, files, options) {
|
||||
await contains(selector, { dragenterFiles: files, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then dragovers `files` on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {Object[]} files
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function dragoverFiles(selector, files, options) {
|
||||
await contains(selector, { dragoverFiles: files, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then drops `files` on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {Object[]} files
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function dropFiles(selector, files, options) {
|
||||
await contains(selector, { dropFiles: files, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then inputs `files` on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {Object[]} files
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function inputFiles(selector, files, options) {
|
||||
await contains(selector, { inputFiles: files, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then pastes `files` on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {Object[]} files
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function pasteFiles(selector, files, options) {
|
||||
await contains(selector, { pasteFiles: files, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then focuses on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function focus(selector, options) {
|
||||
await contains(selector, { setFocus: true, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then inserts the given `content`.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {string} content
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
* @param {boolean} [options.replace=false]
|
||||
*/
|
||||
export async function insertText(selector, content, options = {}) {
|
||||
const { replace = false } = options;
|
||||
delete options.replace;
|
||||
await contains(selector, { ...options, insertText: { content, replace } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then sets its `scrollTop` to the given value.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {number|"bottom"} scrollTop
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function scroll(selector, scrollTop, options) {
|
||||
await contains(selector, { setScroll: scrollTop, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until exactly one element matching the given `selector` is present in
|
||||
* `options.target` and then triggers `event` on it.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {(import("@web/../tests/helpers/utils").EventType|[import("@web/../tests/helpers/utils").EventType, EventInit])[]} events
|
||||
* @param {ContainsOptions} [options] forwarded to `contains`
|
||||
*/
|
||||
export async function triggerEvents(selector, events, options) {
|
||||
await contains(selector, { triggerEvents: events, ...options });
|
||||
}
|
||||
|
||||
function log(ok, message) {
|
||||
if (window.QUnit) {
|
||||
QUnit.assert.ok(ok, message);
|
||||
} else {
|
||||
if (ok) {
|
||||
console.log(message);
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hasUsedContainsPositively = false;
|
||||
if (window.QUnit) {
|
||||
QUnit.testStart(() => (hasUsedContainsPositively = false));
|
||||
}
|
||||
/**
|
||||
* @typedef {[string, ContainsOptions]} ContainsTuple tuple representing params of the contains
|
||||
* function, where the first element is the selector, and the second element is the options param.
|
||||
* @typedef {Object} ContainsOptions
|
||||
* @property {ContainsTuple} [after] if provided, the found element(s) must be after the element
|
||||
* matched by this param.
|
||||
* @property {ContainsTuple} [before] if provided, the found element(s) must be before the element
|
||||
* matched by this param.
|
||||
* @property {Object} [click] if provided, clicks on the first found element
|
||||
* @property {ContainsTuple|ContainsTuple[]} [contains] if provided, the found element(s) must
|
||||
* contain the provided sub-elements.
|
||||
* @property {number} [count=1] numbers of elements to be found to declare the contains check
|
||||
* as successful. Elements are counted after applying all other filters.
|
||||
* @property {Object[]} [dragenterFiles] if provided, dragenters the given files on the found element
|
||||
* @property {Object[]} [dragoverFiles] if provided, dragovers the given files on the found element
|
||||
* @property {Object[]} [dropFiles] if provided, drops the given files on the found element
|
||||
* @property {Object[]} [inputFiles] if provided, inputs the given files on the found element
|
||||
* @property {{content:string, replace:boolean}} [insertText] if provided, adds to (or replace) the
|
||||
* value of the first found element by the given content.
|
||||
* @property {ContainsTuple} [parent] if provided, the found element(s) must have as
|
||||
* parent the node matching the parent parameter.
|
||||
* @property {Object[]} [pasteFiles] if provided, pastes the given files on the found element
|
||||
* @property {number|"bottom"} [scroll] if provided, the scrollTop of the found element(s)
|
||||
* must match.
|
||||
* Note: when using one of the scrollTop options, it is advised to ensure the height is not going
|
||||
* to change soon, by checking with a preceding contains that all the expected elements are in DOM.
|
||||
* @property {boolean} [setFocus] if provided, focuses the first found element.
|
||||
* @property {boolean} [shadowRoot] if provided, targets the shadowRoot of the found elements.
|
||||
* @property {number|"bottom"} [setScroll] if provided, sets the scrollTop on the first found
|
||||
* element.
|
||||
* @property {HTMLElement} [target=getFixture()]
|
||||
* @property {string[]} [triggerEvents] if provided, triggers the given events on the found element
|
||||
* @property {string} [text] if provided, the textContent of the found element(s) or one of their
|
||||
* descendants must match. Use `textContent` option for a match on the found element(s) only.
|
||||
* @property {string} [textContent] if provided, the textContent of the found element(s) must match.
|
||||
* Prefer `text` option for a match on the found element(s) or any of their descendants, usually
|
||||
* allowing for a simpler and less specific selector.
|
||||
* @property {string} [value] if provided, the input value of the found element(s) must match.
|
||||
* Note: value changes are not observed directly, another mutation must happen to catch them.
|
||||
* @property {boolean} [visible] if provided, the found element(s) must be (in)visible
|
||||
*/
|
||||
class Contains {
|
||||
/**
|
||||
* @param {string} selector
|
||||
* @param {ContainsOptions} [options={}]
|
||||
*/
|
||||
constructor(selector, options = {}) {
|
||||
this.selector = selector;
|
||||
this.options = options;
|
||||
this.options.count ??= 1;
|
||||
this.options.targetParam = this.options.target;
|
||||
this.options.target ??= getFixture();
|
||||
let selectorMessage = `${this.options.count} of "${this.selector}"`;
|
||||
if (this.options.visible !== undefined) {
|
||||
selectorMessage = `${selectorMessage} ${
|
||||
this.options.visible ? "visible" : "invisible"
|
||||
}`;
|
||||
}
|
||||
if (this.options.targetParam) {
|
||||
selectorMessage = `${selectorMessage} inside a specific target`;
|
||||
}
|
||||
if (this.options.parent) {
|
||||
selectorMessage = `${selectorMessage} inside a specific parent`;
|
||||
}
|
||||
if (this.options.contains) {
|
||||
selectorMessage = `${selectorMessage} with a specified sub-contains`;
|
||||
}
|
||||
if (this.options.text !== undefined) {
|
||||
selectorMessage = `${selectorMessage} with text "${this.options.text}"`;
|
||||
}
|
||||
if (this.options.textContent !== undefined) {
|
||||
selectorMessage = `${selectorMessage} with textContent "${this.options.textContent}"`;
|
||||
}
|
||||
if (this.options.value !== undefined) {
|
||||
selectorMessage = `${selectorMessage} with value "${this.options.value}"`;
|
||||
}
|
||||
if (this.options.scroll !== undefined) {
|
||||
selectorMessage = `${selectorMessage} with scroll "${this.options.scroll}"`;
|
||||
}
|
||||
if (this.options.after !== undefined) {
|
||||
selectorMessage = `${selectorMessage} after a specified element`;
|
||||
}
|
||||
if (this.options.before !== undefined) {
|
||||
selectorMessage = `${selectorMessage} before a specified element`;
|
||||
}
|
||||
this.selectorMessage = selectorMessage;
|
||||
if (this.options.contains && !Array.isArray(this.options.contains[0])) {
|
||||
this.options.contains = [this.options.contains];
|
||||
}
|
||||
if (this.options.count) {
|
||||
hasUsedContainsPositively = true;
|
||||
} else if (!hasUsedContainsPositively) {
|
||||
throw new Error(
|
||||
`Starting a test with "contains" of count 0 for selector "${this.selector}" is useless because it might immediately resolve. Start the test by checking that an expected element actually exists.`
|
||||
);
|
||||
}
|
||||
/** @type {string} */
|
||||
this.successMessage = undefined;
|
||||
/** @type {function} */
|
||||
this.executeError = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts this contains check, either immediately resolving if there is a
|
||||
* match, or registering appropriate listeners and waiting until there is a
|
||||
* match or a timeout (resolving or rejecting respectively).
|
||||
*
|
||||
* Success or failure messages will be logged with QUnit as well.
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
run() {
|
||||
this.done = false;
|
||||
this.def = makeDeferred();
|
||||
this.scrollListeners = new Set();
|
||||
this.onScroll = () => this.runOnce("after scroll");
|
||||
if (!this.runOnce("immediately")) {
|
||||
this.timer = setTimeout(
|
||||
() => this.runOnce("Timeout of 5 seconds", { crashOnFail: true }),
|
||||
5000
|
||||
);
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
try {
|
||||
this.runOnce("after mutations");
|
||||
} catch (e) {
|
||||
this.def.reject(e); // prevents infinite loop in case of programming error
|
||||
}
|
||||
});
|
||||
this.observer.observe(this.options.target, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
registerCleanup(() => {
|
||||
if (!this.done) {
|
||||
this.runOnce("Test ended", { crashOnFail: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs this contains check once, immediately returning the result (or
|
||||
* undefined), and possibly resolving or rejecting the main promise
|
||||
* (and printing QUnit log) depending on options.
|
||||
* If undefined is returned it means the check was not successful.
|
||||
*
|
||||
* @param {string} whenMessage
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.crashOnFail=false]
|
||||
* @param {boolean} [options.executeOnSuccess=true]
|
||||
* @returns {HTMLElement[]|undefined}
|
||||
*/
|
||||
runOnce(whenMessage, { crashOnFail = false, executeOnSuccess = true } = {}) {
|
||||
const res = this.select();
|
||||
if (res?.length === this.options.count || crashOnFail) {
|
||||
// clean before doing anything else to avoid infinite loop due to side effects
|
||||
this.observer?.disconnect();
|
||||
clearTimeout(this.timer);
|
||||
for (const el of this.scrollListeners ?? []) {
|
||||
el.removeEventListener("scroll", this.onScroll);
|
||||
}
|
||||
this.done = true;
|
||||
}
|
||||
if (res?.length === this.options.count) {
|
||||
this.successMessage = `Found ${this.selectorMessage} (${whenMessage})`;
|
||||
if (executeOnSuccess) {
|
||||
this.executeAction(res[0]);
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
this.executeError = () => {
|
||||
let message = `Failed to find ${this.selectorMessage} (${whenMessage}).`;
|
||||
message = res
|
||||
? `${message} Found ${res.length} instead.`
|
||||
: `${message} Parent not found.`;
|
||||
if (this.parentContains) {
|
||||
if (this.parentContains.successMessage) {
|
||||
log(true, this.parentContains.successMessage);
|
||||
} else {
|
||||
this.parentContains.executeError();
|
||||
}
|
||||
}
|
||||
log(false, message);
|
||||
this.def?.reject(new Error(message));
|
||||
for (const childContains of this.childrenContains || []) {
|
||||
if (childContains.successMessage) {
|
||||
log(true, childContains.successMessage);
|
||||
} else {
|
||||
childContains.executeError();
|
||||
}
|
||||
}
|
||||
};
|
||||
if (crashOnFail) {
|
||||
this.executeError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the action(s) given to this constructor on the found element,
|
||||
* prints the success messages, and resolves the main deferred.
|
||||
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
executeAction(el) {
|
||||
let message = this.successMessage;
|
||||
if (this.options.click) {
|
||||
message = `${message} and clicked it`;
|
||||
webClick(el, undefined, {
|
||||
mouseEventInit: this.options.click,
|
||||
skipDisabledCheck: true,
|
||||
skipVisibilityCheck: true,
|
||||
});
|
||||
}
|
||||
if (this.options.dragenterFiles) {
|
||||
message = `${message} and dragentered ${this.options.dragenterFiles.length} file(s)`;
|
||||
const ev = new Event("dragenter", { bubbles: true });
|
||||
Object.defineProperty(ev, "dataTransfer", {
|
||||
value: createFakeDataTransfer(this.options.dragenterFiles),
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
}
|
||||
if (this.options.dragoverFiles) {
|
||||
message = `${message} and dragovered ${this.options.dragoverFiles.length} file(s)`;
|
||||
const ev = new Event("dragover", { bubbles: true });
|
||||
Object.defineProperty(ev, "dataTransfer", {
|
||||
value: createFakeDataTransfer(this.options.dragoverFiles),
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
}
|
||||
if (this.options.dropFiles) {
|
||||
message = `${message} and dropped ${this.options.dropFiles.length} file(s)`;
|
||||
const ev = new Event("drop", { bubbles: true });
|
||||
Object.defineProperty(ev, "dataTransfer", {
|
||||
value: createFakeDataTransfer(this.options.dropFiles),
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
}
|
||||
if (this.options.inputFiles) {
|
||||
message = `${message} and inputted ${this.options.inputFiles.length} file(s)`;
|
||||
// could not use _createFakeDataTransfer as el.files assignation will only
|
||||
// work with a real FileList object.
|
||||
const dataTransfer = new window.DataTransfer();
|
||||
for (const file of this.options.inputFiles) {
|
||||
dataTransfer.items.add(file);
|
||||
}
|
||||
el.files = dataTransfer.files;
|
||||
/**
|
||||
* Changing files programatically is not supposed to trigger the event but
|
||||
* it does in Chrome versions before 73 (which is on runbot), so in that
|
||||
* case there is no need to make a manual dispatch, because it would lead to
|
||||
* the files being added twice.
|
||||
*/
|
||||
const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
||||
const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false;
|
||||
if (!chromeVersion || chromeVersion >= 73) {
|
||||
el.dispatchEvent(new Event("change"));
|
||||
}
|
||||
}
|
||||
if (this.options.insertText !== undefined) {
|
||||
message = `${message} and inserted text "${this.options.insertText.content}" (replace: ${this.options.insertText.replace})`;
|
||||
el.focus();
|
||||
if (this.options.insertText.replace) {
|
||||
el.value = "";
|
||||
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace" }));
|
||||
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Backspace" }));
|
||||
el.dispatchEvent(new window.InputEvent("input"));
|
||||
}
|
||||
for (const char of this.options.insertText.content) {
|
||||
el.value += char;
|
||||
el.dispatchEvent(new window.KeyboardEvent("keydown", { key: char }));
|
||||
el.dispatchEvent(new window.KeyboardEvent("keyup", { key: char }));
|
||||
el.dispatchEvent(new window.InputEvent("input"));
|
||||
}
|
||||
el.dispatchEvent(new window.InputEvent("change"));
|
||||
}
|
||||
if (this.options.pasteFiles) {
|
||||
message = `${message} and pasted ${this.options.pasteFiles.length} file(s)`;
|
||||
const ev = new Event("paste", { bubbles: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: createFakeDataTransfer(this.options.pasteFiles),
|
||||
});
|
||||
el.dispatchEvent(ev);
|
||||
}
|
||||
if (this.options.setFocus) {
|
||||
message = `${message} and focused it`;
|
||||
el.focus();
|
||||
}
|
||||
if (this.options.setScroll !== undefined) {
|
||||
message = `${message} and set scroll to "${this.options.setScroll}"`;
|
||||
el.scrollTop =
|
||||
this.options.setScroll === "bottom" ? el.scrollHeight : this.options.setScroll;
|
||||
}
|
||||
if (this.options.triggerEvents) {
|
||||
message = `${message} and triggered "${this.options.triggerEvents.join(", ")}" events`;
|
||||
webTriggerEvents(el, null, this.options.triggerEvents, {
|
||||
skipVisibilityCheck: true,
|
||||
});
|
||||
}
|
||||
if (this.parentContains) {
|
||||
log(true, this.parentContains.successMessage);
|
||||
}
|
||||
log(true, message);
|
||||
for (const childContains of this.childrenContains) {
|
||||
log(true, childContains.successMessage);
|
||||
}
|
||||
this.def?.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the found element(s) according to this constructor setup.
|
||||
* If undefined is returned it means the parent cannot be found
|
||||
*
|
||||
* @returns {HTMLElement[]|undefined}
|
||||
*/
|
||||
select() {
|
||||
const target = this.selectParent();
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const baseRes = [...target.querySelectorAll(this.selector)]
|
||||
.map((el) => (this.options.shadowRoot ? el.shadowRoot : el))
|
||||
.filter((el) => el);
|
||||
/** @type {Contains[]} */
|
||||
this.childrenContains = [];
|
||||
const res = baseRes.filter((el, currentIndex) => {
|
||||
let condition =
|
||||
(this.options.textContent === undefined ||
|
||||
el.textContent.trim() === this.options.textContent) &&
|
||||
(this.options.value === undefined || el.value === this.options.value) &&
|
||||
(this.options.scroll === undefined ||
|
||||
(this.options.scroll === "bottom"
|
||||
? Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) <= 1
|
||||
: Math.abs(el.scrollTop - this.options.scroll) <= 1));
|
||||
if (condition && this.options.text !== undefined) {
|
||||
if (
|
||||
el.textContent.trim() !== this.options.text &&
|
||||
[...el.querySelectorAll("*")].every(
|
||||
(el) => el.textContent.trim() !== this.options.text
|
||||
)
|
||||
) {
|
||||
condition = false;
|
||||
}
|
||||
}
|
||||
if (condition && this.options.contains) {
|
||||
for (const param of this.options.contains) {
|
||||
const childContains = new Contains(param[0], { ...param[1], target: el });
|
||||
if (
|
||||
!childContains.runOnce(`as child of el ${currentIndex + 1})`, {
|
||||
executeOnSuccess: false,
|
||||
})
|
||||
) {
|
||||
condition = false;
|
||||
}
|
||||
this.childrenContains.push(childContains);
|
||||
}
|
||||
}
|
||||
if (condition && this.options.visible !== undefined) {
|
||||
if (isVisible(el) !== this.options.visible) {
|
||||
condition = false;
|
||||
}
|
||||
}
|
||||
if (condition && this.options.after) {
|
||||
const afterContains = new Contains(this.options.after[0], {
|
||||
...this.options.after[1],
|
||||
target,
|
||||
});
|
||||
const afterEl = afterContains.runOnce(`as "after"`, {
|
||||
executeOnSuccess: false,
|
||||
})?.[0];
|
||||
if (
|
||||
!afterEl ||
|
||||
!(el.compareDocumentPosition(afterEl) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||
) {
|
||||
condition = false;
|
||||
}
|
||||
this.childrenContains.push(afterContains);
|
||||
}
|
||||
if (condition && this.options.before) {
|
||||
const beforeContains = new Contains(this.options.before[0], {
|
||||
...this.options.before[1],
|
||||
target,
|
||||
});
|
||||
const beforeEl = beforeContains.runOnce(`as "before"`, {
|
||||
executeOnSuccess: false,
|
||||
})?.[0];
|
||||
if (
|
||||
!beforeEl ||
|
||||
!(el.compareDocumentPosition(beforeEl) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
) {
|
||||
condition = false;
|
||||
}
|
||||
this.childrenContains.push(beforeContains);
|
||||
}
|
||||
return condition;
|
||||
});
|
||||
if (
|
||||
this.options.scroll !== undefined &&
|
||||
this.scrollListeners &&
|
||||
baseRes.length === this.options.count &&
|
||||
res.length !== this.options.count
|
||||
) {
|
||||
for (const el of baseRes) {
|
||||
if (!this.scrollListeners.has(el)) {
|
||||
this.scrollListeners.add(el);
|
||||
el.addEventListener("scroll", this.onScroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the found element that should act as the target (parent) for the
|
||||
* main selector.
|
||||
* If undefined is returned it means the parent cannot be found.
|
||||
*
|
||||
* @returns {HTMLElement|undefined}
|
||||
*/
|
||||
selectParent() {
|
||||
if (this.options.parent) {
|
||||
this.parentContains = new Contains(this.options.parent[0], {
|
||||
...this.options.parent[1],
|
||||
target: this.options.target,
|
||||
});
|
||||
return this.parentContains.runOnce(`as parent`, { executeOnSuccess: false })?.[0];
|
||||
}
|
||||
return this.options.target;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until `count` elements matching the given `selector` are present in
|
||||
* `options.target`.
|
||||
*
|
||||
* @param {string} selector
|
||||
* @param {ContainsOptions} [options]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function contains(selector, options) {
|
||||
await new Contains(selector, options).run();
|
||||
}
|
||||
|
||||
const stepState = {
|
||||
expectedSteps: null,
|
||||
deferred: null,
|
||||
timeout: null,
|
||||
currentSteps: [],
|
||||
|
||||
clear() {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
this.deferred = null;
|
||||
this.currentSteps = [];
|
||||
this.expectedSteps = null;
|
||||
},
|
||||
|
||||
check({ crashOnFail = false } = {}) {
|
||||
const success =
|
||||
this.expectedSteps.length === this.currentSteps.length &&
|
||||
this.expectedSteps.every((s, i) => s === this.currentSteps[i]);
|
||||
if (!success && !crashOnFail) {
|
||||
return;
|
||||
}
|
||||
QUnit.config.current.assert.verifySteps(this.expectedSteps);
|
||||
if (success) {
|
||||
this.deferred.resolve();
|
||||
} else {
|
||||
this.deferred.reject(new Error("Steps do not match."));
|
||||
}
|
||||
this.clear();
|
||||
},
|
||||
};
|
||||
|
||||
if (window.QUnit) {
|
||||
QUnit.testStart(() =>
|
||||
registerCleanup(() => {
|
||||
if (stepState.expectedSteps) {
|
||||
stepState.check({ crashOnFail: true });
|
||||
} else {
|
||||
stepState.clear();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate the completion of a test step. This step must then be verified by
|
||||
* calling `assertSteps`.
|
||||
*
|
||||
* @param {string} step
|
||||
*/
|
||||
export function step(step) {
|
||||
stepState.currentSteps.push(step);
|
||||
QUnit.config.current.assert.step(step);
|
||||
if (stepState.expectedSteps) {
|
||||
stepState.check();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given steps to be executed or for the timeout to be reached.
|
||||
*
|
||||
* @param {string[]} steps
|
||||
*/
|
||||
export function assertSteps(steps) {
|
||||
if (stepState.expectedSteps) {
|
||||
stepState.check({ crashOnFail: true });
|
||||
}
|
||||
stepState.expectedSteps = steps;
|
||||
stepState.deferred = makeDeferred();
|
||||
stepState.timeout = setTimeout(() => stepState.check({ crashOnFail: true }), 2000);
|
||||
stepState.check();
|
||||
return stepState.deferred;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}"`);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
/** @odoo-module alias=@web/../tests/webclient/helpers default=false */
|
||||
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { popoverService } from "@web/core/popover/popover_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { effectService } from "@web/core/effects/effect_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { registerCleanup } from "../helpers/cleanup";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import {
|
||||
fakeTitleService,
|
||||
fakeCompanyService,
|
||||
makeFakePwaService,
|
||||
makeFakeLocalizationService,
|
||||
makeFakeHTTPService,
|
||||
makeFakeBarcodeService,
|
||||
} from "../helpers/mock_services";
|
||||
import { getFixture, mount, nextTick } from "../helpers/utils";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { commandService } from "@web/core/commands/command_service";
|
||||
import { CustomFavoriteItem } from "@web/search/custom_favorite_item/custom_favorite_item";
|
||||
import { overlayService } from "@web/core/overlay/overlay_service";
|
||||
|
||||
import { Component, onMounted, xml } from "@odoo/owl";
|
||||
import { fieldService } from "@web/core/field_service";
|
||||
import { nameService } from "@web/core/name_service";
|
||||
import { datetimePickerService } from "@web/core/datetime/datetimepicker_service";
|
||||
|
||||
const actionRegistry = registry.category("actions");
|
||||
const serviceRegistry = registry.category("services");
|
||||
const favoriteMenuRegistry = registry.category("favoriteMenu");
|
||||
|
||||
/**
|
||||
* Builds the required registries for tests using a WebClient.
|
||||
* We use a default version of each required registry item.
|
||||
* If the registry already contains one of those items,
|
||||
* the existing one is kept (it means it has been added in the test
|
||||
* directly, e.g. to have a custom version of the item).
|
||||
*/
|
||||
export function setupWebClientRegistries() {
|
||||
const favoriveMenuItems = {
|
||||
"custom-favorite-item": {
|
||||
value: { Component: CustomFavoriteItem, groupNumber: 3 },
|
||||
options: { sequence: 0 },
|
||||
},
|
||||
};
|
||||
for (const [key, { value, options }] of Object.entries(favoriveMenuItems)) {
|
||||
if (!favoriteMenuRegistry.contains(key)) {
|
||||
favoriteMenuRegistry.add(key, value, options);
|
||||
}
|
||||
}
|
||||
const services = {
|
||||
action: () => actionService,
|
||||
barcode: () => makeFakeBarcodeService(),
|
||||
command: () => commandService,
|
||||
dialog: () => dialogService,
|
||||
effect: () => effectService,
|
||||
field: () => fieldService,
|
||||
hotkey: () => hotkeyService,
|
||||
http: () => makeFakeHTTPService(),
|
||||
pwa: () => makeFakePwaService(),
|
||||
localization: () => makeFakeLocalizationService(),
|
||||
menu: () => menuService,
|
||||
name: () => nameService,
|
||||
notification: () => notificationService,
|
||||
orm: () => ormService,
|
||||
overlay: () => overlayService,
|
||||
popover: () => popoverService,
|
||||
title: () => fakeTitleService,
|
||||
ui: () => uiService,
|
||||
view: () => viewService,
|
||||
company: () => fakeCompanyService,
|
||||
datetime_picker: () => datetimePickerService,
|
||||
};
|
||||
for (const serviceName in services) {
|
||||
if (!serviceRegistry.contains(serviceName)) {
|
||||
serviceRegistry.add(serviceName, services[serviceName]());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method create a web client instance properly configured.
|
||||
*
|
||||
* Note that the returned web client will be automatically cleaned up after the
|
||||
* end of the test.
|
||||
*
|
||||
* @param {*} params
|
||||
*/
|
||||
export async function createWebClient(params) {
|
||||
setupWebClientRegistries();
|
||||
|
||||
params.serverData = params.serverData || {};
|
||||
const mockRPC = params.mockRPC || undefined;
|
||||
const env = await makeTestEnv({
|
||||
serverData: params.serverData,
|
||||
mockRPC,
|
||||
});
|
||||
|
||||
const WebClientClass = params.WebClientClass || WebClient;
|
||||
const target = params && params.target ? params.target : getFixture();
|
||||
const wc = await mount(WebClientClass, target, { env });
|
||||
odoo.__WOWL_DEBUG__ = { root: wc };
|
||||
target.classList.add("o_web_client"); // necessary for the stylesheet
|
||||
registerCleanup(() => {
|
||||
target.classList.remove("o_web_client");
|
||||
});
|
||||
// Wait for visual changes caused by a potential loadState
|
||||
await nextTick();
|
||||
// wait for BlankComponent
|
||||
await nextTick();
|
||||
// wait for the regular rendering
|
||||
await nextTick();
|
||||
return wc;
|
||||
}
|
||||
|
||||
export function doAction(env, ...args) {
|
||||
if (env instanceof Component) {
|
||||
env = env.env;
|
||||
}
|
||||
return env.services.action.doAction(...args);
|
||||
}
|
||||
|
||||
export function getActionManagerServerData() {
|
||||
// additional basic client action
|
||||
class TestClientAction extends Component {
|
||||
static template = xml`
|
||||
<div class="test_client_action">
|
||||
ClientAction_<t t-esc="props.action.params?.description"/>
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onMounted(() =>
|
||||
this.env.config.setDisplayName(`Client action ${this.props.action.id}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
actionRegistry.add("__test__client__action__", TestClientAction);
|
||||
|
||||
const menus = {
|
||||
root: { id: "root", children: [0, 1, 2], name: "root", appID: "root" },
|
||||
// id:0 is a hack to not load anything at webClient mount
|
||||
0: { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" },
|
||||
1: { id: 1, children: [], name: "App1", appID: 1, actionID: 1001, xmlid: "menu_1" },
|
||||
2: { id: 2, children: [], name: "App2", appID: 2, actionID: 1002, xmlid: "menu_2" },
|
||||
};
|
||||
const actionsArray = [
|
||||
{
|
||||
id: 1,
|
||||
xml_id: "action_1",
|
||||
name: "Partners Action 1",
|
||||
res_model: "partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[1, "kanban"]],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
xml_id: "action_2",
|
||||
type: "ir.actions.server",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
xml_id: "action_3",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
mobile_view_mode: "kanban",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[1, "kanban"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
xml_id: "action_4",
|
||||
name: "Partners Action 4",
|
||||
res_model: "partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[1, "kanban"],
|
||||
[2, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
xml_id: "action_5",
|
||||
name: "Create a Partner",
|
||||
res_model: "partner",
|
||||
target: "new",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
xml_id: "action_6",
|
||||
name: "Partner",
|
||||
res_id: 2,
|
||||
res_model: "partner",
|
||||
target: "inline",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
xml_id: "action_7",
|
||||
name: "Some Report",
|
||||
report_name: "some_report",
|
||||
report_type: "qweb-pdf",
|
||||
type: "ir.actions.report",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
xml_id: "action_8",
|
||||
name: "Favorite Ponies",
|
||||
res_model: "pony",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
xml_id: "action_9",
|
||||
name: "A Client Action",
|
||||
tag: "ClientAction",
|
||||
type: "ir.actions.client",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
type: "ir.actions.act_window_close",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
xml_id: "action_11",
|
||||
name: "Another Report",
|
||||
report_name: "another_report",
|
||||
report_type: "qweb-pdf",
|
||||
type: "ir.actions.report",
|
||||
close_on_report_download: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
xml_id: "action_12",
|
||||
name: "Some HTML Report",
|
||||
report_name: "some_report",
|
||||
report_type: "qweb-html",
|
||||
type: "ir.actions.report",
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
name: "Partner",
|
||||
res_id: 2,
|
||||
res_model: "partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[666, "form"]],
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
name: "Create a Partner",
|
||||
res_model: "partner",
|
||||
target: "new",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[3, "form"]],
|
||||
},
|
||||
{
|
||||
id: 26,
|
||||
xml_id: "action_26",
|
||||
name: "Partner",
|
||||
res_model: "partner",
|
||||
target: "new",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "list"]],
|
||||
},
|
||||
{
|
||||
id: 27,
|
||||
xml_id: "action_27",
|
||||
name: "Partners Action 27",
|
||||
res_model: "partner",
|
||||
mobile_view_mode: "kanban",
|
||||
type: "ir.actions.act_window",
|
||||
path: "partners",
|
||||
views: [
|
||||
[false, "list"],
|
||||
[1, "kanban"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 28,
|
||||
xml_id: "action_28",
|
||||
name: "Partners Action 28",
|
||||
res_model: "partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[1, "kanban"],
|
||||
[2, "list"],
|
||||
[false, "form"],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 1" },
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 2" },
|
||||
},
|
||||
{
|
||||
xmlId: "wowl.client_action",
|
||||
id: 1099,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "xmlId" },
|
||||
},
|
||||
];
|
||||
const actions = {};
|
||||
actionsArray.forEach((act) => {
|
||||
actions[act.xmlId || act.id] = act;
|
||||
});
|
||||
const archs = {
|
||||
// kanban views
|
||||
"partner,1,kanban":
|
||||
'<kanban><templates><t t-name="card">' +
|
||||
'<field name="foo"/>' +
|
||||
"</t></templates></kanban>",
|
||||
// list views
|
||||
"partner,false,list": '<list><field name="foo"/></list>',
|
||||
"partner,2,list": '<list limit="3"><field name="foo"/></list>',
|
||||
"pony,false,list": '<list><field name="name"/></list>',
|
||||
// form views
|
||||
"partner,false,form":
|
||||
"<form>" +
|
||||
"<header>" +
|
||||
'<button name="object" string="Call method" type="object"/>' +
|
||||
'<button name="4" string="Execute action" type="action"/>' +
|
||||
"</header>" +
|
||||
"<group>" +
|
||||
'<field name="display_name"/>' +
|
||||
'<field name="foo"/>' +
|
||||
"</group>" +
|
||||
"</form>",
|
||||
"partner,3,form": `
|
||||
<form>
|
||||
<footer>
|
||||
<button class="btn-primary" string="Save" special="save"/>
|
||||
</footer>
|
||||
</form>`,
|
||||
"partner,666,form": `<form>
|
||||
<header></header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button class="oe_stat_button" type="action" name="1" icon="fa-star" context="{'default_partner': id}">
|
||||
<field string="Partners" name="o2m" widget="statinfo"/>
|
||||
</button>
|
||||
</div>
|
||||
<field name="display_name"/>
|
||||
</sheet>
|
||||
</form>`,
|
||||
"pony,false,form": "<form>" + '<field name="name"/>' + "</form>",
|
||||
// search views
|
||||
"partner,false,search": '<search><field name="foo" string="Foo"/></search>',
|
||||
"partner,4,search":
|
||||
"<search>" +
|
||||
'<filter name="bar" help="Bar" domain="[(\'bar\', \'=\', 1)]"/>' +
|
||||
"</search>",
|
||||
"pony,false,search": "<search></search>",
|
||||
};
|
||||
const models = {
|
||||
partner: {
|
||||
fields: {
|
||||
id: { string: "Id", type: "integer" },
|
||||
foo: { string: "Foo", type: "char" },
|
||||
bar: { string: "Bar", type: "many2one", relation: "partner" },
|
||||
o2m: {
|
||||
string: "One2Many",
|
||||
type: "one2many",
|
||||
relation: "partner",
|
||||
relation_field: "bar",
|
||||
},
|
||||
m2o: { string: "Many2one", type: "many2one", relation: "partner" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "First record", foo: "yop", bar: 2, o2m: [2, 3], m2o: 3 },
|
||||
{
|
||||
id: 2,
|
||||
display_name: "Second record",
|
||||
foo: "blip",
|
||||
bar: 1,
|
||||
o2m: [1, 4, 5],
|
||||
m2o: 3,
|
||||
},
|
||||
{ id: 3, display_name: "Third record", foo: "gnap", bar: 1, o2m: [], m2o: 1 },
|
||||
{ id: 4, display_name: "Fourth record", foo: "plop", bar: 2, o2m: [], m2o: 1 },
|
||||
{ id: 5, display_name: "Fifth record", foo: "zoup", bar: 2, o2m: [], m2o: 1 },
|
||||
],
|
||||
},
|
||||
pony: {
|
||||
fields: {
|
||||
id: { string: "Id", type: "integer" },
|
||||
name: { string: "Name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 4, name: "Twilight Sparkle" },
|
||||
{ id: 6, name: "Applejack" },
|
||||
{ id: 9, name: "Fluttershy" },
|
||||
],
|
||||
},
|
||||
};
|
||||
return {
|
||||
models,
|
||||
views: archs,
|
||||
actions,
|
||||
menus,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue