19.0 vanilla

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

View file

@ -1,5 +1,4 @@
import { after, destroy, getFixture } from "@odoo/hoot";
import { queryFirst, queryOne } from "@odoo/hoot-dom";
import { after, destroy, getFixture, queryFirst, queryOne } from "@odoo/hoot";
import { App, Component, xml } from "@odoo/owl";
import { appTranslateFn } from "@web/core/l10n/translation";
import { MainComponentsContainer } from "@web/core/main_components_container";
@ -14,7 +13,7 @@ import {
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
/**
* @typedef {import("@odoo/hoot-dom").Target} Target
* @typedef {import("@odoo/hoot").Target} Target
* @typedef {import("@odoo/owl").Component} Component
* @typedef {import("@web/env").OdooEnv} OdooEnv
*
@ -183,3 +182,32 @@ export async function mountWithCleanup(ComponentClass, options) {
return component;
}
export async function waitUntilIdle(apps = [...App.apps]) {
const isOwlIdle = () => apps.every((app) => app.scheduler.tasks.size === 0);
if (isOwlIdle()) {
return Promise.resolve();
}
return new Promise((resolve) => {
function cleanup() {
for (const cb of unpatch) {
cb();
}
unpatch = [];
}
after(cleanup);
let unpatch = apps.map((app) =>
patch(app.scheduler, {
processTasks() {
super.processTasks();
if (isOwlIdle()) {
cleanup();
resolve();
}
},
})
);
});
}

View file

@ -1,5 +1,9 @@
import { after, afterEach } from "@odoo/hoot";
import {
advanceFrame,
advanceTime,
after,
afterEach,
animationFrame,
check,
clear,
click,
@ -20,20 +24,19 @@ import {
select,
uncheck,
waitFor,
} from "@odoo/hoot-dom";
import { advanceFrame, advanceTime, animationFrame } from "@odoo/hoot-mock";
} from "@odoo/hoot";
import { hasTouch } from "@web/core/browser/feature_detection";
/**
* @typedef {import("@odoo/hoot-dom").DragHelpers} DragHelpers
* @typedef {import("@odoo/hoot-dom").DragOptions} DragOptions
* @typedef {import("@odoo/hoot-dom").FillOptions} FillOptions
* @typedef {import("@odoo/hoot-dom").InputValue} InputValue
* @typedef {import("@odoo/hoot-dom").KeyStrokes} KeyStrokes
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
* @typedef {import("@odoo/hoot-dom").Position} Position
* @typedef {import("@odoo/hoot-dom").QueryOptions} QueryOptions
* @typedef {import("@odoo/hoot-dom").Target} Target
* @typedef {import("@odoo/hoot").DragHelpers} DragHelpers
* @typedef {import("@odoo/hoot").DragOptions} DragOptions
* @typedef {import("@odoo/hoot").FillOptions} FillOptions
* @typedef {import("@odoo/hoot").InputValue} InputValue
* @typedef {import("@odoo/hoot").KeyStrokes} KeyStrokes
* @typedef {import("@odoo/hoot").PointerOptions} PointerOptions
* @typedef {import("@odoo/hoot").Position} Position
* @typedef {import("@odoo/hoot").QueryOptions} QueryOptions
* @typedef {import("@odoo/hoot").Target} Target
*
* @typedef {DragOptions & {
* initialPointerMoveDistance?: number;
@ -50,7 +53,7 @@ import { hasTouch } from "@web/core/browser/feature_detection";
/**
* @template T
* @typedef {import("@odoo/hoot-dom").MaybePromise<T>} MaybePromise
* @typedef {T | PromiseLike<T>} MaybePromise
*/
/**
@ -122,12 +125,18 @@ const waitForTouchDelay = async (delay) => {
}
};
let unconsumedContains = [];
/** @type {(() => any) | null} */
let cancelCurrentDragSequence = null;
/** @type {Target[]} */
const unconsumedContains = [];
afterEach(() => {
afterEach(async () => {
if (cancelCurrentDragSequence) {
await cancelCurrentDragSequence();
}
if (unconsumedContains.length) {
const targets = unconsumedContains.map(String).join(", ");
unconsumedContains = [];
unconsumedContains.length = 0;
throw new Error(
`called 'contains' on "${targets}" without any action: use 'waitFor' if no interaction is intended`
);
@ -200,11 +209,11 @@ export function contains(target, options) {
* @returns {Promise<DragHelpers>}
*/
drag: async (options) => {
consumeContains();
/** @type {typeof cancel} */
const cancelWithDelay = async (options) => {
await cancel(options);
await advanceFrame();
cancelCurrentDragSequence = null;
};
/** @type {typeof drop} */
@ -214,6 +223,7 @@ export function contains(target, options) {
}
await drop();
await advanceFrame();
cancelCurrentDragSequence = null;
};
/** @type {typeof moveTo} */
@ -224,6 +234,11 @@ export function contains(target, options) {
return helpersWithDelay;
};
consumeContains();
await cancelCurrentDragSequence?.();
cancelCurrentDragSequence = cancelWithDelay;
const { cancel, drop, moveTo } = await drag(nodePromise, options);
const helpersWithDelay = {
cancel: cancelWithDelay,
@ -244,6 +259,9 @@ export function contains(target, options) {
*/
dragAndDrop: async (target, dropOptions, dragOptions) => {
consumeContains();
await cancelCurrentDragSequence?.();
const [from, to] = await Promise.all([nodePromise, waitFor(target)]);
const { drop, moveTo } = await drag(from, dragOptions);

View file

@ -1,13 +1,7 @@
import {
animationFrame,
queryAll,
queryAllAttributes,
queryAllTexts,
queryOne,
} from "@odoo/hoot-dom";
import { animationFrame, queryAll, queryAllAttributes, queryAllTexts, queryOne } from "@odoo/hoot";
import { getDropdownMenu } from "./component_test_helpers";
import { contains } from "./dom_test_helpers";
import { buildSelector } from "./view_test_helpers";
import { getDropdownMenu } from "./component_test_helpers";
/**
* @param {number} [columnIndex=0]

View file

@ -1,6 +1,6 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { mockLocation } from "@odoo/hoot-mock";
import { mockLocation } from "@odoo/hoot";
//-----------------------------------------------------------------------------
// Internal

View file

@ -0,0 +1,46 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { after } from "@odoo/hoot";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {string} name
* @param {OdooModuleFactory} factory
*/
export function mockFunctionsFactory(name, { fn }) {
return (...args) => {
function clearMemoizeCaches() {
for (const cache of memoizeCaches) {
cache.clear();
}
}
function mockMemoize(func) {
const cache = new Map();
memoizeCaches.push(cache);
const funcName = func.name ? func.name + " (memoized)" : "memoized";
return {
[funcName](...args) {
if (!cache.size) {
after(cache.clear.bind(cache));
}
if (!cache.has(args[0])) {
cache.set(args[0], func(...args));
}
return cache.get(...args);
},
}[funcName];
}
const functionsModule = fn(...args);
const memoizeCaches = [];
functionsModule.memoize = mockMemoize;
functionsModule.clearMemoizeCaches = clearMemoizeCaches;
return functionsModule;
};
}

View file

@ -8,18 +8,22 @@ export function mockIndexedDB(_name, { fn }) {
this.mockIndexedDB = {};
}
write(table, key, value) {
async write(table, key, value) {
if (!(table in this.mockIndexedDB)) {
this.mockIndexedDB[table] = {};
}
this.mockIndexedDB[table][key] = value;
}
read(table, key) {
return Promise.resolve(this.mockIndexedDB[table]?.[key]);
async read(table, key) {
return this.mockIndexedDB[table]?.[key];
}
invalidate(tables = null) {
async deleteDatabase() {
this.mockIndexedDB = {};
}
async invalidate(tables = null) {
if (tables) {
tables = typeof tables === "string" ? [tables] : tables;
for (const table of tables) {

View file

@ -1,3 +1,4 @@
import { deepCopy } from "@web/core/utils/objects";
import { MockServerError } from "./mock_server_utils";
/**
@ -70,7 +71,7 @@ function makeFieldGenerator(type, { aggregator, requiredKeys = [] } = {}) {
for (const key of requiredKeys) {
if (!(key in field)) {
throw new MockServerError(
`missing key "${key}" in ${type || "generic"} field definition`
`Missing key "${key}" in ${type || "generic"} field definition`
);
}
}
@ -92,6 +93,21 @@ const R_ENDS_WITH_ID = /_id(s)?$/i;
const R_LOWER_FOLLOWED_BY_UPPER = /([a-z])([A-Z])/g;
const R_SPACE_OR_UNDERSCORE = /[\s_]+/g;
/**
* @param {Record<string, FieldDefinition & MockFieldProperties>} fields
*/
export function copyFields(fields) {
const fieldsCopy = {};
for (const [fieldName, field] of Object.entries(fields)) {
const fieldCopy = {};
for (const [property, value] of Object.entries(field)) {
fieldCopy[property] = typeof value === "object" ? deepCopy(value) : value;
}
fieldsCopy[fieldName] = fieldCopy;
}
return fieldsCopy;
}
/**
* @param {FieldDefinition & MockFieldProperties} field
*/

View file

@ -25,6 +25,7 @@ const {
DEFAULT_RELATIONAL_FIELD_VALUES,
DEFAULT_SELECTION_FIELD_VALUES,
S_FIELD,
copyFields,
isComputed,
} = fields;
@ -172,7 +173,7 @@ function createRawInstance(ModelClass) {
* @param {string} fieldName
*/
function fieldNotFoundError(modelName, fieldName, consequence) {
let message = `cannot find a definition for field "${fieldName}" in model "${modelName}"`;
let message = `Cannot find a definition for field "${fieldName}" in model "${modelName}"`;
if (consequence) {
message += `: ${consequence}`;
}
@ -292,12 +293,11 @@ function getModelDefinition(previous, constructor) {
}
}
for (const [key, map] of INHERITED_OBJECT_KEYS) {
for (const subKey in previous[key]) {
const previousValue = map ? map(previous[key]) : previous[key];
for (const subKey in previousValue) {
// Assign only if empty
if (isEmptyValue(model[key][subKey])) {
model[key][subKey] = map
? map(previous[key][subKey])
: previous[key][subKey];
model[key][subKey] = previousValue[subKey];
}
}
}
@ -542,9 +542,6 @@ function isValidFieldValue(record, fieldDef) {
case "text": {
return typeof value === "string";
}
case "json": {
return typeof value === "string" || typeof value === "object";
}
case "boolean": {
return typeof value === "boolean";
}
@ -764,8 +761,8 @@ function orderByField(model, orderBy, records) {
}
case "many2one":
case "many2one_reference": {
v1 &&= valuesMap.get(v1[0]);
v2 &&= valuesMap.get(v2[0]);
v1 &&= valuesMap.get(v1[0] ?? v1);
v2 &&= valuesMap.get(v2[0] ?? v2);
break;
}
case "many2many":
@ -797,7 +794,7 @@ function orderByField(model, orderBy, records) {
} else {
if (!["boolean", "number", "string"].includes(typeof v1) || typeof v1 !== typeof v2) {
throw new MockServerError(
`cannot order by field "${fieldNameSpec}" in model "${
`Cannot order by field "${fieldNameSpec}" in model "${
model._name
}": values must be of the same primitive type (got ${typeof v1} and ${typeof v2})`
);
@ -830,7 +827,7 @@ function parseView(model, params) {
const { arch } = params;
const level = params.level || 0;
const editable = params.editable || true;
const fields = deepCopy(model._fields);
const fields = copyFields(model._fields);
const { _onChanges } = model;
const fieldNodes = {};
@ -934,7 +931,7 @@ function parseView(model, params) {
for (const [name, node] of Object.entries(groupbyNodes)) {
const field = fields[name];
if (!isM2OField(field)) {
throw new MockServerError("cannot group: 'groupby' can only target many2one fields");
throw new MockServerError("Cannot group: 'groupby' can only target many2one fields");
}
field.views = {};
const coModel = getRelation(field);
@ -1258,7 +1255,7 @@ function updateComodelRelationalFields(model, record, originalRecord) {
function validateFieldDefinition(fieldName, fieldDef) {
if (fieldDef[S_FIELD] && fieldDef.name) {
throw new MockServerError(
`cannot set the name of field "${fieldName}" from its definition: got "${fieldDef.name}"`
`Cannot set the name of field "${fieldName}" from its definition: got "${fieldDef.name}"`
);
}
delete fieldDef[S_FIELD];
@ -1271,7 +1268,7 @@ function validateFieldDefinition(fieldName, fieldDef) {
* @param {number | false} viewId
*/
function viewNotFoundError(modelName, viewType, viewId, consequence) {
let message = `cannot find an arch for view "${viewType}" with ID ${JSON.stringify(
let message = `Cannot find an arch for view "${viewType}" with ID ${JSON.stringify(
viewId
)} in model "${modelName}"`;
if (consequence) {
@ -1389,7 +1386,7 @@ const DATETIME_FORMAT = {
};
const INHERITED_OBJECT_KEYS = [
["_computes", null],
["_fields", deepCopy],
["_fields", copyFields],
["_onChanges", null],
["_toolbar", deepCopy],
["_views", null],
@ -1756,7 +1753,7 @@ export class Model extends Array {
const ids = [];
for (const values of allValues) {
if ("id" in values) {
throw new MockServerError(`cannot create a record with a given ID value`);
throw new MockServerError(`Cannot create a record with a given ID value`);
}
const record = { id: this._getNextId() };
ids.push(record.id);
@ -1798,7 +1795,7 @@ export class Model extends Array {
} else {
if (!(field.type in DEFAULT_FIELD_VALUES)) {
throw new MockServerError(
`missing default value for field type "${field.type}"`
`Missing default value for field type "${field.type}"`
);
}
result[fieldName] = DEFAULT_FIELD_VALUES[field.type]();
@ -1863,10 +1860,10 @@ export class Model extends Array {
}
const [, fieldName, func] = fspec.match(R_AGGREGATE_FUNCTION);
if (func && !(func in AGGREGATOR_FUNCTIONS)) {
throw new MockServerError(`invalid aggregation function "${func}"`);
throw new MockServerError(`Invalid aggregation function "${func}"`);
}
if (!this._fields[fieldName]) {
throw new MockServerError(`invalid field in "${fspec}"`);
throw new MockServerError(`Invalid field in "${fspec}"`);
}
return { fieldName, func, name: fspec };
});
@ -2330,7 +2327,7 @@ export class Model extends Array {
const supportedTypes = ["many2one", "selection"];
if (!supportedTypes.includes(field.type)) {
throw new MockServerError(
`only category types ${supportedTypes.join(" and ")} are supported, got "${
`Only category types ${supportedTypes.join(" and ")} are supported, got "${
field.type
}"`
);
@ -2471,7 +2468,7 @@ export class Model extends Array {
const supportedTypes = ["many2many", "many2one", "selection"];
if (!supportedTypes.includes(field.type)) {
throw new MockServerError(
`only filter types ${supportedTypes} are supported, got "${field.type}"`
`Only filter types ${supportedTypes} are supported, got "${field.type}"`
);
}
let modelDomain = kwargs.search_domain || [];
@ -3089,7 +3086,7 @@ export class Model extends Array {
const fieldDef = this._fields[fieldName];
if (!isValidFieldValue(record, fieldDef)) {
throw new MockServerError(
`invalid value for field "${fieldName}" on ${getRecordQualifier(
`Invalid value for field "${fieldName}" on ${getRecordQualifier(
record
)} in model "${this._name}": expected "${fieldDef.type}" and got: ${
record[fieldName]
@ -3383,7 +3380,7 @@ export class Model extends Array {
for (const id of ids) {
if (!id) {
throw new MockServerError(
`cannot read: falsy ID value would result in an access error on the actual server`
`Cannot read: falsy ID value would result in an access error on the actual server`
);
}
const record = modelMap[this._name][id];
@ -3617,7 +3614,7 @@ export class Model extends Array {
ids = [...command[2]];
} else {
throw new MockServerError(
`command "${JSON.stringify(
`Command "${JSON.stringify(
value
)}" is not supported by the MockServer on field "${fieldName}" in model "${
this._name
@ -3635,7 +3632,7 @@ export class Model extends Array {
continue;
}
throw new MockServerError(
`invalid ID "${JSON.stringify(
`Invalid ID "${JSON.stringify(
value
)}" for a many2one on field "${fieldName}" in model "${this._name}"`
);

View file

@ -1,12 +1,6 @@
import { ServerModel } from "../mock_model";
import { getKwArgs } from "../mock_server_utils";
import { ensureArray } from "@web/core/utils/arrays";
/**
* @template T
* @typedef {import("@web/../tests/web_test_helpers").KwArgs<T>} KwArgs
*/
const ORM_AUTOMATIC_FIELDS = new Set([
"create_date",
"create_uid",
@ -17,7 +11,6 @@ const ORM_AUTOMATIC_FIELDS = new Set([
]);
export class ResUsersSettings extends ServerModel {
// TODO: merge this class with mail models
_name = "res.users.settings";
/** @param {number|number[]} userIdOrIds */
@ -36,11 +29,6 @@ export class ResUsersSettings extends ServerModel {
* @param {string[]} [fields_to_format]
*/
res_users_settings_format(id, fields_to_format) {
const kwargs = getKwArgs(arguments, "id", "fields_to_format");
id = kwargs.id;
delete kwargs.id;
fields_to_format = kwargs.fields_to_format;
const [settings] = this.browse(id);
const filterPredicate = fields_to_format
? ([fieldName]) => fields_to_format.includes(fieldName)
@ -55,14 +43,8 @@ export class ResUsersSettings extends ServerModel {
/**
* @param {number | Iterable<number>} idOrIds
* @param {Object} newSettings
* @param {KwArgs<{ new_settings }>} [kwargs]
*/
set_res_users_settings(idOrIds, new_settings) {
const kwargs = getKwArgs(arguments, "idOrIds", "new_settings");
idOrIds = kwargs.idOrIds;
delete kwargs.idOrIds;
new_settings = kwargs.new_settings || {};
const [id] = ensureArray(idOrIds);
const [oldSettings] = this.browse(id);
const changedSettings = {};

View file

@ -4,13 +4,15 @@ import {
createJobScopedGetter,
expect,
getCurrent,
mockFetch,
mockLocation,
mockWebSocket,
registerDebugInfo,
} from "@odoo/hoot";
import { mockFetch, mockWebSocket } from "@odoo/hoot-mock";
import { rpc, RPCError } from "@web/core/network/rpc";
import { makeErrorFromResponse, rpc, RPCError } from "@web/core/network/rpc";
import { RPCCache } from "@web/core/network/rpc_cache";
import { ensureArray, isIterable } from "@web/core/utils/arrays";
import { isObject } from "@web/core/utils/objects";
import { RPCCache } from "@web/core/network/rpc_cache";
import { hashCode } from "@web/core/utils/strings";
import { serverState } from "../mock_server_state.hoot";
import { fetchModelDefinitions, globalCachedFetch, registerModelToFetch } from "../module_set.hoot";
@ -71,7 +73,7 @@ const { DateTime } = luxon;
* pure?: boolean;
* }} RouteOptions
*
* @typedef {`/${string}`} RoutePath
* @typedef {`${string}/${string}`} RoutePath
*
* @typedef {{
* actions?: Partial<MockServer["actions"]>;
@ -86,7 +88,7 @@ const { DateTime } = luxon;
* translations?: Record<string, string>;
* }} ServerParams
*
* @typedef {import("@odoo/hoot-mock").ServerWebSocket} ServerWebSocket
* @typedef {import("@odoo/hoot").ServerWebSocket} ServerWebSocket
*
* @typedef {string | Iterable<string> | RegExp} StringMatcher
*
@ -189,6 +191,27 @@ function getCurrentMockServer() {
return mockServers.get(test.run);
}
/**
* @param {RequestInit} init
*/
function getJsonRpcParams({ headers, body }) {
if (headers.get("Content-Type") !== "application/json" || typeof body !== "string") {
return null;
}
try {
const parsedParams = JSON.parse(body);
return {
id: parsedParams.id,
jsonrpc: parsedParams.jsonrpc,
};
} catch {
return {
id: nextJsonRpcId++,
jsonrpc: "2.0",
};
}
}
/**
* @param {MockServer["_models"]}
* @returns {MockServerEnvironment}
@ -226,12 +249,9 @@ function match(target, matchers) {
* @param {string} modelName
*/
function modelNotFoundError(modelName, consequence) {
let message = `cannot find a definition for model "${modelName}"`;
if (consequence) {
message += `: ${consequence}`;
}
message += ` (did you forget to use \`defineModels()?\`)`;
return new MockServerError(message);
return new MockServerError(
`Cannot find a definition for model "${modelName}": ${consequence} (did you forget to use \`defineModels()?\`)`
);
}
/**
@ -344,16 +364,22 @@ const ROOT_MENU = {
appID: "root",
};
const R_DATASET_ROUTE = /\/web\/dataset\/call_(button|kw)\/[\w.-]+\/(?<step>\w+)/;
const R_ROUTE_PARAM = /<((?<type>\w+):)?(?<name>[\w-]+)>/g;
const R_WILDCARD = /\*+/g;
/** Providing handlers for internal URLs (blob and data) is **optional** */
const INTERNAL_URL_PROTOCOLS = ["blob:", "data:"];
const R_DATASET_ROUTE = /\/web\/dataset\/call_(?:button|kw)\/[\w.-]+\/(?<step>\w+)/;
const R_ROUTE_PARAM = /<(?:(?<type>\w+):)?(?<name>[\w-]+)>/g;
const R_URL_SPECIAL_CHARACTERS = /[.$+()]/g;
const R_WEBCLIENT_ROUTE = /(?<step>\/web\/webclient\/\w+)/;
const R_WILDCARD = /\*+/g;
/** @type {WeakMap<() => any, MockServer>} */
const mockServers = new WeakMap();
/** @type {WeakSet<typeof Model>} */
const seenModels = new WeakSet();
let nextJsonRpcId = 1e9;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
@ -420,7 +446,7 @@ export class MockServer {
_ormListeners = [];
/**
* @private
* @type {[RegExp[], RouteCallback, RouteOptions][]}
* @type {[[RegExp, boolean][], RouteCallback, RouteOptions][]}
*/
_routes = [];
/**
@ -520,27 +546,21 @@ export class MockServer {
// Set default routes
this._onRoute(["/web/action/load"], this.loadAction);
this._onRoute(["/web/action/load_breadcrumbs"], this.loadActionBreadcrumbs);
this._onRoute(["/web/bundle/<string:bundle_name>"], this.loadBundle, {
pure: true,
});
this._onRoute(["/web/dataset/call_kw", "/web/dataset/call_kw/<path:path>"], this.callKw, {
final: true,
});
this._onRoute(["/web/bundle/<string:bundle_name>"], this.loadBundle);
this._onRoute(
["/web/dataset/call_button", "/web/dataset/call_button/<path:path>"],
[
"/web/dataset/call_kw",
"/web/dataset/call_kw/<path:path>",
"/web/dataset/call_button",
"/web/dataset/call_button/<path:path>",
],
this.callKw,
{ final: true }
);
this._onRoute(["/web/dataset/resequence"], this.resequence);
this._onRoute(["/web/image/<string:model>/<int:id>/<string:field>"], this.loadImage, {
pure: true,
});
this._onRoute(["/web/webclient/load_menus"], this.loadMenus, {
pure: true,
});
this._onRoute(["/web/webclient/translations"], this.loadTranslations, {
pure: true,
});
this._onRoute(["/web/image/<string:model>/<int:id>/<string:field>"], this.loadImage);
this._onRoute(["/web/webclient/load_menus"], this.loadMenus);
this._onRoute(["/web/webclient/translations"], this.loadTranslations);
// Register ambiant parameters
await this.configure(getCurrentParams());
@ -583,7 +603,7 @@ export class MockServer {
}
}
throw new MockServerError(`unimplemented ORM method: ${modelName}.${method}`);
throw new MockServerError(`Unimplemented ORM method: ${modelName}.${method}`);
}
/**
@ -624,14 +644,19 @@ export class MockServer {
/**
* @private
* @param {string} route
* @param {URL} url
*/
_findRouteListeners(route) {
_findRouteListeners(url) {
// "blob:" and "data:" URLs do not have 'search' and 'hash' parameters
const fullRoute = INTERNAL_URL_PROTOCOLS.includes(url.protocol)
? url.href
: url.origin + url.pathname;
/** @type {[RouteCallback, Record<string, string>, RouteOptions][]} */
const listeners = [];
for (const [routeRegexes, callback, options] of this._routes) {
for (const regex of routeRegexes) {
const argsMatch = route.match(regex);
for (const [regex, partialMatch] of routeRegexes) {
const routePart = partialMatch ? url.pathname : fullRoute;
const argsMatch = routePart.match(regex);
if (argsMatch) {
listeners.unshift([callback, argsMatch.groups, options]);
}
@ -721,7 +746,7 @@ export class MockServer {
default: {
if (!(action.type in ACTION_TYPES)) {
throw new MockServerError(
`invalid action type "${action.type}" in action ${id}`
`Invalid action type "${action.type}" in action ${id}`
);
}
}
@ -747,54 +772,81 @@ export class MockServer {
/**
* @private
* @param {string} url
* @param {string | URL} input
* @param {RequestInit} init
*/
async _handleRequest(url, init) {
const method = init?.method?.toUpperCase() || (init?.body ? "POST" : "GET");
const request = new Request(url, { method, ...(init || {}) });
async _handleRequest(input, init) {
const request = new Request(input, init);
const url = new URL(request.url);
let jsonRpcParams = getJsonRpcParams(init);
let error = null;
let result = null;
const route = new URL(request.url).pathname;
const listeners = this._findRouteListeners(route);
if (!listeners.length) {
throw new MockServerError(`unimplemented server route: ${route}`);
const listeners = this._findRouteListeners(url);
if (!listeners.length && !INTERNAL_URL_PROTOCOLS.includes(url.protocol)) {
if (url.origin === mockLocation.origin) {
error = new MockServerError(`Unimplemented server route: ${url.pathname}`);
} else {
error = new MockServerError(
`Unimplemented server external URL: ${url.origin + url.pathname}`
);
}
} else {
for (const [callback, routeParams, { final, pure }] of listeners) {
try {
const callbackResult = await callback.call(this, request, routeParams);
if (result instanceof Error) {
error = callbackResult;
} else {
result = callbackResult;
}
} catch (err) {
error = err instanceof Error ? err : new Error(err);
}
if (final || error || (result !== null && result !== undefined)) {
if (pure || result instanceof Response) {
jsonRpcParams = null;
}
break;
}
}
}
let result = null;
for (const [callback, routeParams, routeOptions] of listeners) {
const { final, pure } = routeOptions;
try {
result = await callback.call(this, request, routeParams);
} catch (error) {
if (pure) {
throw error;
}
result = error instanceof Error ? error : new Error(error);
}
if (final || (result !== null && result !== undefined)) {
if (pure) {
return result;
}
if (result instanceof RPCError) {
return { error: result, result: null };
}
if (result instanceof Error) {
return {
error: {
code: 418,
data: result,
message: result.message,
type: result.name,
},
result: null,
// We have several scenarios at this point:
//
// - either the request is considered to be a JSON-RPC:
// -> the response is formatted accordingly (i.e. { error, result })
//
// - in other cases:
// -> the response is returned or thrown as-is.
if (jsonRpcParams) {
if (error) {
if (error instanceof RPCError) {
jsonRpcParams.error = { ...error };
} else {
jsonRpcParams.error = {
...makeErrorFromResponse({
code: 200,
data: {
name: error.name,
message: error.message,
subType: error.type,
},
message: error.message,
type: error.name,
}),
};
}
return { error: null, result };
return jsonRpcParams;
} else {
jsonRpcParams.result = result;
return jsonRpcParams;
}
} else if (error) {
throw error;
} else {
return result;
}
// There was a matching controller that wasn't call_kw but it didn't return anything: treat it as JSON
return { error: null, result };
}
/**
@ -874,7 +926,7 @@ export class MockServer {
if (model._rec_name) {
if (!(model._rec_name in model._fields)) {
throw new MockServerError(
`invalid _rec_name "${model._rec_name}" on model "${model._name}": field does not exist`
`Invalid _rec_name "${model._rec_name}" on model "${model._name}": field does not exist`
);
}
} else if ("name" in model._fields) {
@ -894,7 +946,7 @@ export class MockServer {
Object.setPrototypeOf(Object.getPrototypeOf(model), existingModel);
} else if (model._name in this.env) {
throw new MockServerError(
`cannot register model "${model._name}": a server environment property with the same name already exists`
`Cannot register model "${model._name}": a server environment property with the same name already exists`
);
}
@ -955,7 +1007,7 @@ export class MockServer {
computeFn = model[computeFn];
if (typeof computeFn !== "function") {
throw new MockServerError(
`could not find compute function "${computeFn}" on model "${model._name}"`
`Could not find compute function "${computeFn}" on model "${model._name}"`
);
}
}
@ -975,7 +1027,7 @@ export class MockServer {
for (const fieldName in record) {
if (!(fieldName in model._fields)) {
throw new MockServerError(
`unknown field "${fieldName}" on ${getRecordQualifier(
`Unknown field "${fieldName}" on ${getRecordQualifier(
record
)} in model "${model._name}"`
);
@ -984,7 +1036,7 @@ export class MockServer {
if (record.id) {
if (seenIds.has(record.id)) {
throw new MockServerError(
`duplicate ID ${record.id} in model "${model._name}"`
`Duplicate ID ${record.id} in model "${model._name}"`
);
}
seenIds.add(record.id);
@ -1059,7 +1111,9 @@ export class MockServer {
const model = ensureArray(args.pop() || "*");
if (typeof callback !== "function") {
throw new Error(`onRpc: expected callback to be a function, got: ${callback}`);
throw new MockServerError(
`onRpc: expected callback to be a function, got: ${callback}`
);
}
this._ormListeners.push([model, method, callback]);
@ -1074,6 +1128,9 @@ export class MockServer {
_onRoute(routes, callback, options) {
const routeRegexes = routes.map((route) => {
const regexString = route
// Only replace special RegExp character that can also be included
// in valid URLs
.replaceAll(R_URL_SPECIAL_CHARACTERS, "\\$&")
// Replace parameters by regex notation and store their names
.replaceAll(R_ROUTE_PARAM, (...args) => {
const { name, type } = args.pop();
@ -1081,7 +1138,7 @@ export class MockServer {
})
// Replace glob wildcards by regex wildcard
.replaceAll(R_WILDCARD, ".*");
return new RegExp(`^${regexString}$`, "i");
return [new RegExp(`^${regexString}$`, "i"), route.startsWith("/")];
});
this._routes.push([routeRegexes, callback, options || {}]);
@ -1118,7 +1175,7 @@ export class MockServer {
const ormArgs = [];
const routeArgs = [];
for (const val of ensureArray(args.shift())) {
if (typeof val === "string" && val.startsWith("/")) {
if (typeof val === "string" && val.includes("/")) {
routeArgs.push(val);
} else {
ormArgs.push(val);
@ -1191,11 +1248,13 @@ export class MockServer {
}
} else if (model) {
if (!resId) {
throw new Error("Actions with a 'model' should also have a 'resId'");
throw new MockServerError("Actions with a 'model' should also have a 'resId'");
}
displayName = this.env[model].browse(resId)[0].display_name;
} else {
throw new Error("Actions should have either an 'action' (ID or path) or a 'model'");
throw new MockServerError(
"Actions should have either an 'action' (ID or path) or a 'model'"
);
}
return { display_name: displayName };
});
@ -1258,7 +1317,7 @@ export class MockServer {
}
const missingMenuIds = [...allChildIds].filter((id) => !(id in menuDict));
if (missingMenuIds.length) {
throw new MockServerError(`missing menu ID(s): ${missingMenuIds.join(", ")}`);
throw new MockServerError(`Missing menu ID(s): ${missingMenuIds.join(", ")}`);
}
return menuDict;
}

View file

@ -1,5 +1,18 @@
import { makeErrorFromResponse } from "@web/core/network/rpc";
/**
* @typedef {{
* code?: number;
* context?: import("@web/core/context").Context;
* description?: string;
* message?: string;
* subType?: string;
* errorName?: string;
* type?: string;
* args?: unknown[];
* }} ServerErrorInit
*/
/**
* @template T
* @typedef {import("./mock_server").KwArgs<T>} KwArgs
@ -46,7 +59,7 @@ export function getKwArgs(allArgs, ...argNames) {
const args = [...allArgs];
const kwargs = args.at(-1)?.[KWARGS_SYMBOL] ? args.pop() : makeKwArgs({});
if (args.length > argNames.length) {
throw new MockServerError("more positional arguments than there are given argument names");
throw new MockServerError("More positional arguments than there are given argument names");
}
for (let i = 0; i < args.length; i++) {
if (args[i] !== null && args[i] !== undefined) {
@ -71,7 +84,7 @@ export function getRecordQualifier(record) {
}
/**
* @param {Record<string, string | any>} params
* @param {ServerErrorInit} params
*/
export function makeServerError({
code,
@ -85,15 +98,16 @@ export function makeServerError({
} = {}) {
return makeErrorFromResponse({
code: code || 0,
message: message || "Odoo Server Error",
data: {
name: errorName || `odoo.exceptions.${type || "UserError"}`,
debug: "traceback",
arguments: args || [],
context: context || {},
subType,
message: description,
message: description || message,
},
message: message || "Odoo Server Error",
type: "server",
});
}

View file

@ -49,6 +49,7 @@ const makeSession = ({
user_companies: {
allowed_companies: Object.fromEntries(companies.map((company) => [company.id, company])),
current_company: companies[0]?.id,
disallowed_ancestor_companies: {},
},
user_context: {
...userContext,

View file

@ -1,11 +1,21 @@
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { describe, dryRun, globals, start, stop } from "@odoo/hoot";
import { Deferred, delay } from "@odoo/hoot-dom";
import { watchAddedNodes, watchKeys, watchListeners } from "@odoo/hoot-mock";
import {
Deferred,
delay,
describe,
dryRun,
globals,
start,
stop,
watchAddedNodes,
watchKeys,
watchListeners,
} from "@odoo/hoot";
import { mockBrowserFactory } from "./mock_browser.hoot";
import { mockCurrencyFactory } from "./mock_currency.hoot";
import { mockFunctionsFactory } from "./mock_functions.hoot";
import { mockIndexedDB } from "./mock_indexed_db.hoot";
import { mockSessionFactory } from "./mock_session.hoot";
import { makeTemplateFactory } from "./mock_templates.hoot";
@ -510,6 +520,7 @@ const MODULE_MOCKS_BY_NAME = new Map([
["@web/core/currency", mockCurrencyFactory],
["@web/core/templates", makeTemplateFactory],
["@web/core/user", mockUserFactory],
["@web/core/utils/functions", mockFunctionsFactory],
["@web/session", mockSessionFactory],
]);
const MODULE_MOCKS_BY_REGEX = new Map([

View file

@ -1,5 +1,4 @@
import { after } from "@odoo/hoot";
import { onTimeZoneChange } from "@odoo/hoot-mock";
import { after, onTimeZoneChange } from "@odoo/hoot";
import { patch } from "@web/core/utils/patch";
const { FixedOffsetZone, IANAZone, Settings } = luxon;

View file

@ -1,4 +1,4 @@
import { queryAll, queryAllTexts, queryOne, queryText } from "@odoo/hoot-dom";
import { queryAll, queryAllTexts, queryOne, queryText } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { findComponent, mountWithCleanup } from "./component_test_helpers";
import { contains } from "./dom_test_helpers";
@ -113,14 +113,14 @@ export async function mountWithSearch(componentConstructor, searchProps = {}, co
* @param {string} label
*/
export async function toggleMenu(label) {
await contains(`button.o-dropdown:contains(/^${label}$/i)`).click();
await contains(`button.o-dropdown:text(${label})`).click();
}
/**
* @param {string} label
*/
export async function toggleMenuItem(label) {
const target = queryOne`.o_menu_item:contains(/^${label}$/i)`;
const target = queryOne`.o_menu_item:text(${label})`;
if (target.classList.contains("dropdown-toggle")) {
await contains(target).hover();
} else {
@ -133,8 +133,8 @@ export async function toggleMenuItem(label) {
* @param {string} optionLabel
*/
export async function toggleMenuItemOption(itemLabel, optionLabel) {
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
const target = queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root });
const { parentElement: root } = queryOne`.o_menu_item:text(${itemLabel})`;
const target = queryOne(`.o_item_option:text(${optionLabel})`, { root });
if (target.classList.contains("dropdown-toggle")) {
await contains(target).hover();
} else {
@ -146,7 +146,7 @@ export async function toggleMenuItemOption(itemLabel, optionLabel) {
* @param {string} label
*/
export function isItemSelected(label) {
return queryOne`.o_menu_item:contains(/^${label}$/i)`.classList.contains("selected");
return queryOne`.o_menu_item:text(${label})`.classList.contains("selected");
}
/**
@ -154,8 +154,8 @@ export function isItemSelected(label) {
* @param {string} optionLabel
*/
export function isOptionSelected(itemLabel, optionLabel) {
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
return queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root }).classList.contains(
const { parentElement: root } = queryOne`.o_menu_item:text(${itemLabel})`;
return queryOne(`.o_item_option:text(${optionLabel})`, { root }).classList.contains(
"selected"
);
}
@ -217,7 +217,7 @@ export async function toggleFavoriteMenu() {
*/
export async function editFavorite(text) {
await ensureSearchBarMenu();
await contains(`.o_favorite_menu .o_menu_item:contains(/^${text}$/i) i.fa-pencil`, {
await contains(`.o_favorite_menu .o_menu_item:text(${text}) i.fa-pencil`, {
visible: false,
}).click();
}
@ -260,7 +260,7 @@ export function getFacetTexts() {
*/
export async function removeFacet(label) {
await ensureSearchView();
await contains(`.o_searchview_facet:contains(/^${label}$/i) .o_facet_remove`).click();
await contains(`.o_searchview_facet:text(${label}) .o_facet_remove`).click();
}
/**

View file

@ -15,7 +15,6 @@ function beforeFocusRequired(test) {
definePreset("desktop", {
icon: "fa-desktop",
label: "Desktop",
platform: "linux",
size: [1366, 768],
tags: ["-mobile"],
touch: false,
@ -23,7 +22,6 @@ definePreset("desktop", {
definePreset("mobile", {
icon: "fa-mobile font-bold",
label: "Mobile",
platform: "android",
size: [375, 667],
tags: ["-desktop"],
touch: true,

View file

@ -1,10 +1,9 @@
import { queryFirst } from "@odoo/hoot-dom";
import { advanceTime } from "@odoo/hoot-mock";
import { advanceTime, queryFirst } from "@odoo/hoot";
import { contains } from "./dom_test_helpers";
/**
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
* @typedef {import("@odoo/hoot-dom").Target} Target
* @typedef {import("@odoo/hoot").PointerOptions} PointerOptions
* @typedef {import("@odoo/hoot").Target} Target
*/
/**

View file

@ -1,13 +1,17 @@
import { after, expect, getFixture } from "@odoo/hoot";
import {
after,
animationFrame,
click,
Deferred,
expect,
formatXml,
getFixture,
queryAll,
queryAllTexts,
queryFirst,
runAllTimers,
} from "@odoo/hoot-dom";
import { animationFrame, Deferred, tick } from "@odoo/hoot-mock";
tick,
} from "@odoo/hoot";
import { Component, onMounted, useSubEnv, xml } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { MainComponentsContainer } from "@web/core/main_components_container";
@ -20,6 +24,11 @@ import { registerInlineViewArchs } from "./mock_server/mock_model";
/**
* @typedef {import("@web/views/view").Config} Config
*
* @typedef {{
* value?: string;
* index?: number;
* }} EditSelectMenuParams
*
* @typedef {ViewProps & {
* archs?: Record<string, string>
* config?: Config;
@ -37,12 +46,7 @@ import { registerInlineViewArchs } from "./mock_server/mock_model";
* text?: string;
* }} SelectorOptions
*
* * @typedef {{
* value?: string;
* index?: number;
* }} EditSelectMenuParams
*
* @typedef {import("@odoo/hoot-dom").FormatXmlOptions} FormatXmlOptions
* @typedef {import("@odoo/hoot").FormatXmlOptions} FormatXmlOptions
* @typedef {import("@web/views/view").ViewProps} ViewProps
* @typedef {import("./mock_server/mock_model").ViewType} ViewType
*/

View file

@ -1,4 +1,4 @@
import { animationFrame } from "@odoo/hoot-mock";
import { animationFrame } from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { WebClient } from "@web/webclient/webclient";

View file

@ -89,6 +89,49 @@ test("can be rendered", async () => {
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
});
// TODO: Hoot dispatches "change"/"blur" in the wrong order vs browser.
test.todo("select option with onChange", async () => {
class Parent extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete value="state.value" sources="sources" onChange.bind="onChange" />`;
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("/contactus", this.onSelect.bind(this)),
item("/contactus-thank-you", this.onSelect.bind(this)),
]);
onChange({ inputValue, isOptionSelected }) {
expect.step(`isOptionSelected:${isOptionSelected}`);
if (isOptionSelected) {
return;
}
this.state.value = inputValue;
}
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
await contains(".o-autocomplete input").edit("/", { confirm: false });
await runAllTimers();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
await runAllTimers();
expect.verifySteps(["isOptionSelected:true", "/contactus"]);
await contains(".o-autocomplete input").edit("hello", { confirm: "false" });
expect.verifySteps(["isOptionSelected:false"]);
await contains(document.body).click();
expect(".o-autocomplete input").toHaveValue("hello");
});
test("select option", async () => {
class Parent extends Component {
static components = { AutoComplete };
@ -573,7 +616,7 @@ test("correct sequence of blur, focus and select", async () => {
await contains(".o-autocomplete input").edit("", { confirm: false });
await runAllTimers();
await contains(document.body).click();
expect.verifySteps(["blur", "change"]);
expect.verifySteps(["change", "blur"]);
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
});
@ -647,6 +690,60 @@ test("tab and shift+tab close the dropdown", async () => {
expect(dropdown).not.toHaveCount();
});
test("Clicking away selects the first option when selectOnBlur is true", async () => {
class Parent extends Component {
static template = xml`<AutoComplete value="state.value" sources="sources" selectOnBlur="true"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
const input = ".o-autocomplete input";
await contains(input).click();
expect(".o-autocomplete--dropdown-menu").toBeVisible();
queryFirst(input).blur();
await animationFrame();
expect(input).toHaveValue("World");
expect.verifySteps(["World"]);
});
test("selectOnBlur doesn't interfere with selecting by mouse clicking", async () => {
class Parent extends Component {
static template = xml`<AutoComplete value="state.value" sources="sources" selectOnBlur="true"/>`;
static components = { AutoComplete };
static props = [];
state = useState({ value: "" });
sources = buildSources(() => [
item("World", this.onSelect.bind(this)),
item("Hello", this.onSelect.bind(this)),
]);
onSelect(option) {
this.state.value = option.label;
expect.step(option.label);
}
}
await mountWithCleanup(Parent);
const input = ".o-autocomplete input";
await contains(input).click();
await contains(".o-autocomplete--dropdown-item:last").click();
expect(input).toHaveValue("Hello");
expect.verifySteps(["Hello"]);
});
test("autocomplete scrolls when moving with arrows", async () => {
class Parent extends Component {
static template = xml`
@ -836,3 +933,32 @@ test("items are selected only when the mouse moves, not just on enter", async ()
"ui-state-active"
);
});
test("do not attempt to scroll if element is null", async () => {
const def = new Deferred();
class Parent extends Component {
static template = xml`<AutoComplete value="''" sources="sources" />`;
static components = { AutoComplete };
static props = [];
sources = [
buildSources(async () => {
await def;
return [item("delayed one"), item("delayed two"), item("delayed three")];
}),
buildSources(Array.from(Array(20)).map((_, index) => item(`item ${index}`))),
].flat();
}
await mountWithCleanup(Parent);
queryOne(`.o-autocomplete input`).focus();
queryOne(`.o-autocomplete input`).click();
await animationFrame();
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
expect(".o-autocomplete .dropdown-item").toHaveCount(21);
expect(".o-autocomplete .dropdown-item:eq(0)").toHaveClass("o_loading");
def.resolve();
await animationFrame();
expect(".o-autocomplete .dropdown-item").toHaveCount(23); // + 3 items - loading
});

View file

@ -1,6 +1,6 @@
import { test, expect } from "@odoo/hoot";
import { press, click, animationFrame, queryOne } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { press, click, animationFrame, queryOne, manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
import { Component, xml, useState } from "@odoo/owl";
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { ColorPicker, DEFAULT_COLORS } from "@web/core/color_picker/color_picker";
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
@ -230,6 +230,42 @@ test("custom color picker sets default color as selected", async () => {
expect("input.o_hex_input").toHaveValue("#FF0000");
});
test("should preserve color slider when picking max lightness color", async () => {
class TestColorPicker extends Component {
static template = xml`
<div style="width: 222px">
<CustomColorPicker selectedColor="state.color" onColorPreview.bind="onColorChange" onColorSelect.bind="onColorChange"/>
</div>`;
static components = { CustomColorPicker };
static props = ["*"];
setup() {
this.state = useState({
color: "#FFFF00",
});
}
onColorChange({ cssColor }) {
this.state.color = cssColor;
}
}
await mountWithCleanup(TestColorPicker);
const colorPickerArea = queryOne(".o_color_pick_area");
const colorPickerRect = colorPickerArea.getBoundingClientRect();
const clientX = colorPickerRect.left + colorPickerRect.width / 2;
const clientY = colorPickerRect.top; // Lightness 100%
manuallyDispatchProgrammaticEvent(colorPickerArea, "pointerdown", {
clientX,
clientY,
});
manuallyDispatchProgrammaticEvent(colorPickerArea, "pointerup", {
clientX,
clientY,
});
await animationFrame();
expect(colorPickerArea).toHaveStyle({ backgroundColor: "rgb(255, 255, 0)" });
});
test("custom color picker change color on click in hue slider", async () => {
await mountWithCleanup(CustomColorPicker, { props: { selectedColor: "#FF0000" } });
expect("input.o_hex_input").toHaveValue("#FF0000");
@ -269,3 +305,25 @@ test("can register an extra tab", async () => {
expect(".o_font_color_selector>p:last-child").toHaveText("Color picker extra tab");
registry.category("color_picker_tabs").remove("web.extra");
});
test("should mark default color as selected when it is selected", async () => {
defineStyle(`
:root {
--900: #212527;
}
`);
await mountWithCleanup(ColorPicker, {
props: {
state: {
selectedColor: "#212527",
defaultTab: "custom",
},
getUsedCustomColors: () => [],
applyColor() {},
applyColorPreview() {},
applyColorResetPreview() {},
colorPrefix: "",
},
});
expect(".o_color_button[data-color='900']").toHaveClass("selected");
});

View file

@ -1291,16 +1291,21 @@ test("bold the searchValue on the commands with special char", async () => {
const action = () => {};
const providers = [
{
namespace: "/",
provide: () => [
{
name: "Test&",
action,
},
{
name: "Research & Development",
action,
},
],
},
];
const config = {
searchValue: "&",
searchValue: "/",
providers,
};
getService("dialog").add(CommandPalette, {
@ -1308,9 +1313,29 @@ test("bold the searchValue on the commands with special char", async () => {
});
await animationFrame();
expect(".o_command_palette").toHaveCount(1);
expect(".o_command").toHaveCount(1);
expect(queryAllTexts(".o_command")).toEqual(["Test&"]);
expect(queryAllTexts(".o_command .fw-bolder")).toEqual(["&"]);
expect(".o_command").toHaveCount(2);
expect(queryAllTexts(".o_command")).toEqual(["Test&", "Research & Development"]);
expect(queryAllTexts(".o_command .fw-bolder")).toEqual([]);
await click(".o_command_palette_search input");
await edit("/a");
await runAllTimers();
expect(".o_command").toHaveCount(2);
expect(
queryAll(".o_command").map((command) =>
queryAllTexts(".o_command_name .fw-bolder", { root: command })
)
).toEqual([[], ["a"]]);
await click(".o_command_palette_search input");
await edit("/&");
await runAllTimers();
expect(".o_command").toHaveCount(2);
expect(
queryAll(".o_command").map((command) =>
queryAllTexts(".o_command_name .fw-bolder", { root: command })
)
).toEqual([["&"], ["&"]]);
});
test("bold the searchValue on the commands with accents", async () => {

View file

@ -5,6 +5,7 @@ import { Component, reactive, useState, xml } from "@odoo/owl";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
import { usePopover } from "@web/core/popover/popover_hook";
const { DateTime } = luxon;
@ -143,3 +144,55 @@ test("value is not updated if it did not change", async () => {
expect(getShortDate(pickerProps.value)).toBe("2023-07-07");
expect.verifySteps(["2023-07-07"]);
});
test("close popover when owner component is unmounted", async() => {
class Child extends Component {
static components = { DateTimeInput };
static props = [];
static template = xml`
<div>
<input type="text" class="datetime_hook_input" t-ref="start-date"/>
</div>
`;
setup() {
useDateTimePicker({
createPopover: usePopover,
pickerProps: {
value: [false, false],
type: "date",
range: true,
}
});
}
}
const { resolve: hidePopover, promise } = Promise.withResolvers();
class DateTimeToggler extends Component {
static components = { Child };
static props = [];
static template = xml`<Child t-if="!state.hidden"/>`;
setup() {
this.state = useState({
hidden: false,
});
promise.then(() => {
this.state.hidden = true;
});
}
}
await mountWithCleanup(DateTimeToggler);
await click("input.datetime_hook_input");
await animationFrame();
expect(".o_datetime_picker").toHaveCount(1);
// we can't simply add a button because `useClickAway` will be triggered, thus closing the popover properly
hidePopover();
await animationFrame();
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
});

View file

@ -0,0 +1,56 @@
import { CopyButton } from "@web/core/copy_button/copy_button";
import { browser } from "@web/core/browser/browser";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { beforeEach, expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
beforeEach(() => {
patchWithCleanup(browser.navigator.clipboard, {
async writeText(text) {
expect.step(`writeText: ${text}`);
},
async write(object) {
expect.step(
`write: {${Object.entries(object)
.map(([k, v]) => k + ": " + v)
.join(", ")}}`
);
},
});
});
test("copies a string to the clipboard", async () => {
await mountWithCleanup(CopyButton, { props: { content: "content to copy" } });
await click(".o_clipboard_button");
expect.verifySteps(["writeText: content to copy"]);
});
test("copies an object to the clipboard", async () => {
await mountWithCleanup(CopyButton, { props: { content: { oneKey: "oneValue" } } });
await click(".o_clipboard_button");
expect.verifySteps(["write: {oneKey: oneValue}"]);
});
test("does not submit forms", async () => {
class Parent extends Component {
static props = ["*"];
static components = { CopyButton };
static template = xml`
<form t-on-submit="this.onSubmit">
<CopyButton content="'some text'"/>
<!-- note that type="submit" is implicit on the following button -->
<button class="submit-button"/>
</form>
`;
onSubmit(ev) {
ev.preventDefault();
expect.step("form submit");
}
}
await mountWithCleanup(Parent);
await click(".o_clipboard_button");
expect.verifySteps(["writeText: some text"]);
await click(".submit-button");
expect.verifySteps(["form submit"]);
});

View file

@ -13,7 +13,7 @@ import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
test("simple rendering", async () => {
expect.assertions(8);
expect.assertions(7);
class Parent extends Component {
static components = { Dialog };
static template = xml`
@ -33,10 +33,7 @@ test("simple rendering", async () => {
expect(".o_dialog main").toHaveCount(1, { message: "a dialog has always a main node" });
expect("main").toHaveText("Hello!");
expect(".o_dialog footer").toHaveCount(1, { message: "the footer is rendered by default" });
expect(".o_dialog footer button").toHaveCount(1, {
message: "the footer is rendered with a single button 'Ok' by default",
});
expect("footer button").toHaveText("Ok");
expect(".o_dialog footer:visible").toHaveCount(0, { message: "the footer is hidden if empty" });
});
test("hotkeys work on dialogs", async () => {
@ -45,9 +42,15 @@ test("hotkeys work on dialogs", async () => {
static template = xml`
<Dialog title="'Wow(l) Effect'">
Hello!
<t t-set-slot="footer">
<button t-on-click="onClickOk">Ok</button>
</t>
</Dialog>
`;
static props = ["*"];
onClickOk() {
expect.step("clickOk");
}
}
await makeDialogMockEnv({
dialogData: {
@ -65,7 +68,7 @@ test("hotkeys work on dialogs", async () => {
// Same effect as clicking on the Ok button
await keyDown("control+enter");
await keyUp("ctrl+enter");
expect.verifySteps(["close"]);
expect.verifySteps(["clickOk"]);
});
test("simple rendering with two dialogs", async () => {
@ -143,30 +146,6 @@ test("click on the button x triggers the close and dismiss defined by a Child co
expect.verifySteps(["dismiss", "close"]);
});
test("click on the default footer button triggers the service close", async () => {
expect.assertions(2);
class Parent extends Component {
static template = xml`
<Dialog>
Hello!
</Dialog>
`;
static props = ["*"];
static components = { Dialog };
}
await makeDialogMockEnv({
dialogData: {
close: () => expect.step("close"),
dismiss: () => expect.step("dismiss"),
},
});
await mountWithCleanup(Parent);
expect(".o_dialog").toHaveCount(1);
await contains(".o_dialog footer button").click();
expect.verifySteps(["close"]);
});
test("render custom footer buttons is possible", async () => {
expect.assertions(2);
class SimpleButtonsDialog extends Component {

View file

@ -1,4 +1,4 @@
import { test, expect, beforeEach, describe } from "@odoo/hoot";
import { test, expect, beforeEach } from "@odoo/hoot";
import { click, press, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
@ -8,8 +8,6 @@ import { usePopover } from "@web/core/popover/popover_hook";
import { useAutofocus } from "@web/core/utils/hooks";
import { MainComponentsContainer } from "@web/core/main_components_container";
describe.current.tags("desktop");
beforeEach(async () => {
await mountWithCleanup(MainComponentsContainer);
});
@ -25,7 +23,7 @@ test("Simple rendering with a single dialog", async () => {
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Welcome");
await click(".o_dialog footer button");
await click(".o_dialog button");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});
@ -68,7 +66,7 @@ test("rendering with two dialogs", async () => {
await animationFrame();
expect(".o_dialog").toHaveCount(2);
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
await click(".o_dialog footer button");
await click(".o_dialog button");
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Sauron");
@ -174,7 +172,7 @@ test("Interactions between multiple dialogs", async () => {
expect(res.active).toEqual([false, true]);
expect(res.names).toEqual(["Hello", "Sauron"]);
await click(".o_dialog:not(.o_inactive_modal) footer button");
await click(".o_dialog:not(.o_inactive_modal) button");
await animationFrame();
expect(".o_dialog").toHaveCount(1);
@ -182,7 +180,7 @@ test("Interactions between multiple dialogs", async () => {
expect(res.active).toEqual([true]);
expect(res.names).toEqual(["Hello"]);
await click("footer button");
await click(".o_dialog:not(.o_inactive_modal) button");
await animationFrame();
expect(".o_dialog").toHaveCount(0);
});

View file

@ -787,7 +787,11 @@ test("don't close dropdown outside the active element", async () => {
expect(".modal-dialog").toHaveCount(1);
expect(DROPDOWN_MENU).toHaveCount(1);
await click(".modal-dialog .btn-primary");
if (getMockEnv().isSmall) {
await click(".modal-dialog .oi-arrow-left");
} else {
await click(".modal-dialog .btn-close");
}
await animationFrame();
expect(".modal-dialog").toHaveCount(0);
expect(DROPDOWN_MENU).toHaveCount(1);

View file

@ -193,3 +193,23 @@ test("Upload button is disabled if attachment upload is not finished", async ()
message: "the upload button should be enabled for upload",
});
});
test("support preprocessing of files via props", async () => {
await createFileInput({
props: {
onWillUploadFiles(files) {
expect.step(files[0].name);
return files;
},
},
mockPost: (route, params) => {
return JSON.stringify([{ name: params.ufile[0].name }]);
},
});
await contains(".o_file_input input", { visible: false }).click();
await setInputFiles([new File(["test"], "fake_file.txt", { type: "text/plain" })]);
await animationFrame();
expect.verifySteps(["fake_file.txt"]);
});

View file

@ -1,4 +1,4 @@
import { expect, test } from "@odoo/hoot";
import { expect, test, waitFor } from "@odoo/hoot";
import {
contains,
getService,
@ -61,13 +61,12 @@ test("upload renders new component(s)", async () => {
test("upload end removes component", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => true);
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await animationFrame();
fileUploadService.uploads[1].xhr.dispatchEvent(new Event("load"));
await animationFrame();
expect(".file_upload").toHaveCount(0);
expect(".o_notification").toHaveCount(0);
});
test("upload error removes component", async () => {
@ -125,3 +124,49 @@ test("upload updates on progress", async () => {
await animationFrame();
expect(".file_upload_progress_text_right").toHaveText("(350/500MB)");
});
test("handles error", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => {
throw new Error("Boom");
});
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(An error occured while uploading)");
});
test("handles http not success", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => new Response("<p>Boom HTML</p>", { status: 500 }));
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(Boom HTML)");
});
test("handles jsonrpc error", async () => {
// https://www.jsonrpc.org/specification#error_object
await mountWithCleanup(Parent);
onRpc("/test/", () => ({
error: {
message: "Boom JSON",
},
}));
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(Boom JSON)");
});
test("handles Odoo's jsonrpc error", async () => {
await mountWithCleanup(Parent);
onRpc("/test/", () => ({
error: {
data: {
name: "ValidationError",
message: "Boom Odoo",
},
},
}));
const fileUploadService = await getService("file_upload");
fileUploadService.upload("/test/", []);
await waitFor(".o_notification:has(.bg-danger):contains(ValidationError: Boom Odoo)");
});

View file

@ -1,10 +1,11 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { animationFrame, mockFetch } from "@odoo/hoot-mock";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import {
contains,
makeMockEnv,
mountWithCleanup,
onRpc,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
@ -34,8 +35,8 @@ test("Installation page displays the app info correctly", async () => {
},
});
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return {
icons: [
{
@ -73,7 +74,7 @@ test("Installation page displays the app info correctly", async () => {
expect("button.btn-primary").toHaveCount(1);
expect("button.btn-primary").toHaveText("Install");
await contains(".fa-pencil").click();
await contains("input").edit("<Otto&");
await contains("input").edit("<Otto&", { confirm: "blur" });
expect.verifySteps(["URL replace"]);
});
@ -81,8 +82,8 @@ test("Installation page displays the error message when browser is not supported
delete browser.BeforeInstallPromptEvent;
await makeMockEnv();
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return {
icons: [
{

View file

@ -1,5 +1,6 @@
/* eslint no-restricted-syntax: 0 */
import { after, describe, expect, test } from "@odoo/hoot";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import {
defineParams,
makeMockEnv,
@ -10,9 +11,8 @@ import {
serverState,
} from "@web/../tests/web_test_helpers";
import { _t as basic_t, translatedTerms, translationLoaded } from "@web/core/l10n/translation";
import { session } from "@web/session";
import { IndexedDB } from "@web/core/utils/indexed_db";
import { animationFrame, Deferred } from "@odoo/hoot-mock";
import { session } from "@web/session";
import { Component, markup, xml } from "@odoo/owl";
const { DateTime } = luxon;
@ -51,6 +51,7 @@ async function mockLang(lang) {
await makeMockEnv();
}
test.tags("headless");
test("lang is given by the user context", async () => {
onRpc("/web/webclient/translations", (request) => {
const urlParams = new URLSearchParams(new URL(request.url).search);
@ -60,6 +61,7 @@ test("lang is given by the user context", async () => {
expect.verifySteps(["fr_FR"]);
});
test.tags("headless");
test("lang is given by an attribute on the DOM root node", async () => {
serverState.lang = null;
onRpc("/web/webclient/translations", (request) => {
@ -74,20 +76,17 @@ test("lang is given by an attribute on the DOM root node", async () => {
expect.verifySteps(["fr_FR"]);
});
test.tags("headless");
test("url is given by the session", async () => {
expect.assertions(1);
patchWithCleanup(session, {
translationURL: "/get_translations",
});
onRpc(
"/get_translations",
function (request) {
expect(request.url).toInclude("/get_translations");
return this.loadTranslations(request);
},
{ pure: true }
);
onRpc("/get_translations", function (request) {
expect.step("/get_translations");
return this.loadTranslations(request);
});
await makeMockEnv();
expect.verifySteps(["/get_translations"]);
});
test("can translate a text node", async () => {
@ -340,56 +339,67 @@ test("can lazy translate", async () => {
expect("#main").toHaveText("Bonjour");
});
test.tags("headless");
test("luxon is configured in the correct lang", async () => {
await mockLang("fr_BE");
expect(DateTime.utc(2021, 12, 10).toFormat("MMMM")).toBe("décembre");
});
test.tags("headless");
test("arabic has the correct numbering system (generic)", async () => {
await mockLang("ar_001");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
});
test.tags("headless");
test("arabic has the correct numbering system (Algeria)", async () => {
await mockLang("ar_DZ");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test.tags("headless");
test("arabic has the correct numbering system (Lybia)", async () => {
await mockLang("ar_LY");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test.tags("headless");
test("arabic has the correct numbering system (Morocco)", async () => {
await mockLang("ar_MA");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test.tags("headless");
test("arabic has the correct numbering system (Saudi Arabia)", async () => {
await mockLang("ar_SA");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("١٠/١٢/٢٠٢١ ١٢:٠٠:٠٠");
});
test.tags("headless");
test("arabic has the correct numbering system (Tunisia)", async () => {
await mockLang("ar_TN");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("10/12/2021 12:00:00");
});
test.tags("headless");
test("bengalese has the correct numbering system", async () => {
await mockLang("bn");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("১০/১২/২০২১ ১২::");
});
test.tags("headless");
test("punjabi (gurmukhi) has the correct numbering system", async () => {
await mockLang("pa_IN");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("/੧੨/੨੦੨੧ ੧੨::");
});
test.tags("headless");
test("tamil has the correct numbering system", async () => {
await mockLang("ta");
expect(DateTime.utc(2021, 12, 10).toFormat("dd/MM/yyyy hh:mm:ss")).toBe("௧௦/௧௨/௨௦௨௧ ௧௨::");
});
test.tags("headless");
test("_t fills the format specifiers in translated terms with its extra arguments", async () => {
patchTranslations({
web: {
@ -400,6 +410,7 @@ test("_t fills the format specifiers in translated terms with its extra argument
expect(translatedStr).toBe("Échéance dans 513 jours");
});
test.tags("headless");
test("_t fills the format specifiers in translated terms with formatted lists", async () => {
await mockLang("fr_FR");
patchTranslations({
@ -418,6 +429,7 @@ test("_t fills the format specifiers in translated terms with formatted lists",
expect(translatedStr2).toBe("Échéance dans 30, 60 et 90 jours pour Mitchell");
});
test.tags("headless");
test("_t fills the format specifiers in lazy translated terms with its extra arguments", async () => {
translatedTerms[translationLoaded] = false;
const translatedStr = _t("Due in %s days", 513);
@ -429,6 +441,7 @@ test("_t fills the format specifiers in lazy translated terms with its extra arg
expect(translatedStr.toString()).toBe("Échéance dans 513 jours");
});
describe.tags("headless");
describe("_t with markups", () => {
test("non-markup values are escaped", () => {
translatedTerms[translationLoaded] = true;

View file

@ -946,3 +946,39 @@ test("showDebugInput = false", async () => {
await openModelFieldSelectorPopover();
expect(".o_model_field_selector_debug").toHaveCount(0);
});
test("models with a m2o of the same name should show the correct page data", async () => {
class Cat extends models.Model {
cat_name = fields.Char();
link = fields.Many2one({ relation: "dog" });
}
class Dog extends models.Model {
dog_name = fields.Char();
link = fields.Many2one({ relation: "fish" });
}
class Fish extends models.Model {
fish_name = fields.Char();
}
defineModels([Cat, Dog, Fish]);
await mountWithCleanup(ModelFieldSelector, {
props: {
readonly: false,
path: "link",
resModel: "cat",
},
});
await openModelFieldSelectorPopover();
await contains(".o_model_field_selector_popover_relation_icon").click();
expect(getDisplayedFieldNames()).toEqual([
"Created on",
"Display name",
"Dog name",
"Id",
"Last Modified on",
"Link",
]);
});

View file

@ -215,3 +215,15 @@ test("model_selector: with an initial value", async () => {
await mountModelSelector(["model.1", "model.2", "model.3"], "Model 1");
expect(".o-autocomplete--input").toHaveValue("Model 1");
});
test("model_selector: autofocus", async () => {
await mountWithCleanup(ModelSelector, {
props: {
models: ["model.1"],
autofocus: true,
onModelSelected: () => {},
},
});
const input = queryAll("input.o-autocomplete--input")[0];
expect(input).toBe(document.activeElement);
});

View file

@ -112,3 +112,21 @@ test("test name_and_signature widget update signmode with onSignatureChange prop
await contains(".o_web_sign_draw_button").click();
expect(currentSignMode).toBe("draw");
});
test("test name_and_signature widget with non-breaking spaces", async function () {
const props = {
signature: { name: "Non Breaking Spaces" },
};
const res = await mountWithCleanup(NameAndSignature, { props });
expect(res.getCleanedName()).toBe("Non Breaking Spaces");
});
test("test name_and_signature widget with non-breaking spaces and initials mode", async function () {
const props = {
signature: { name: "Non Breaking Spaces" },
signatureType: "initial",
};
const res = await mountWithCleanup(NameAndSignature, { props });
expect(res.getCleanedName()).toBe("N.B.S.");
});

View file

@ -1,5 +1,5 @@
import { Component, onMounted, useState, xml } from "@odoo/owl";
import { Navigator, useNavigation } from "@web/core/navigation/navigation";
import { ACTIVE_ELEMENT_CLASS, Navigator, useNavigation } from "@web/core/navigation/navigation";
import { useAutofocus } from "@web/core/utils/hooks";
import { describe, destroy, expect, test } from "@odoo/hoot";
import {
@ -312,3 +312,91 @@ test("non-navigable dom update does NOT cause re-focus", async () => {
expect(".test-non-navigable").toHaveCount(1);
expect(".one").not.toBeFocused();
});
test("mousehover only set active if navigation is availible", async () => {
class Parent extends Component {
static props = [];
static template = xml`
<div class="container" t-ref="containerRef">
<button class="o-navigable one">target one</button>
<button class="o-navigable two">target two</button>
</div>
`;
setup() {
this.navigation = useNavigation("containerRef");
}
}
const component = await mountWithCleanup(Parent);
expect(".one").not.toBeFocused();
expect(".two").not.toBeFocused();
expect(component.navigation.activeItem).toBe(null);
await hover(".one");
expect(component.navigation.activeItem).toBe(null);
await hover(".two");
expect(component.navigation.activeItem).toBe(null);
await click(".one");
expect(".one").toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem.target).toBe(queryOne(".one"));
await hover(".two");
expect(".one").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem.target).toBe(queryOne(".two"));
});
test("active item is unset when focusing out", async () => {
class Parent extends Component {
static props = [];
static template = xml`
<button class="outside">outside</button>
<div class="container" t-ref="containerRef">
<button class="o-navigable one">target one</button>
<button class="o-navigable two">target two</button>
</div>
`;
setup() {
this.navigation = useNavigation("containerRef");
}
}
const component = await mountWithCleanup(Parent);
await click(".one");
expect(".one").toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem.target).toEqual(queryOne(".one"));
await click(".outside");
expect(".one").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(".two").not.toHaveClass(ACTIVE_ELEMENT_CLASS);
expect(component.navigation.activeItem).toBe(null);
});
test("set focused element as active item", async () => {
class Parent extends Component {
static props = [];
static template = xml`
<div class="container" t-ref="containerRef">
<input class="o-navigable one" id="input" t-ref="autofocus"/>
<button class="o-navigable two">target two</button>
<button class="o-navigable three">target three</button>
</div>
`;
setup() {
this.inputRef = useAutofocus();
this.navigation = useNavigation("containerRef");
}
}
const component = await mountWithCleanup(Parent);
expect(component.inputRef.el).toBeFocused();
expect(component.navigation.activeItem).not.toBeEmpty();
expect(component.navigation.activeItem.el).toBe(component.inputRef.el);
});

View file

@ -138,10 +138,12 @@ test("trigger a ConnectionLostError when response isn't json parsable", async ()
test("rpc can send additional headers", async () => {
mockFetch((url, settings) => {
expect(settings.headers).toEqual({
"Content-Type": "application/json",
Hello: "World",
});
expect(settings.headers).toEqual(
new Headers([
["Content-Type", "application/json"],
["Hello", "World"],
])
);
return { result: true };
});
await rpc("/test/", null, { headers: { Hello: "World" } });

View file

@ -1,17 +1,24 @@
import { expect, test } from "@odoo/hoot";
import { Deferred, microTick } from "@odoo/hoot-mock";
import { Deferred, describe, expect, microTick, test, tick } from "@odoo/hoot";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { RPCCache } from "@web/core/network/rpc_cache";
import { IDBQuotaExceededError, IndexedDB } from "@web/core/utils/indexed_db";
const symbol = Symbol("Promise");
const S_PENDING = Symbol("Promise");
/**
* @param {Promise<any>} promise
*/
function promiseState(promise) {
return Promise.race([promise, Promise.resolve(symbol)]).then(
(value) => (value === symbol ? { status: "pending" } : { status: "fulfilled", value }),
return Promise.race([promise, Promise.resolve(S_PENDING)]).then(
(value) => (value === S_PENDING ? { status: "pending" } : { status: "fulfilled", value }),
(reason) => ({ status: "rejected", reason })
);
}
describe.current.tags("headless");
test("RamCache: can cache a simple call", async () => {
// The fist call to rpcCache.read will save the result on the RamCache.
// The fist call to rpcCache.read saves the result on the RamCache.
// Each next call will retrive the ram cache independently, without executing the fallback
const rpcCache = new RPCCache(
"mockRpc",
@ -86,7 +93,7 @@ test("PersistentCache: can cache a simple call", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const def = new Deferred();
@ -231,7 +238,7 @@ test("IndexedDB Crypt: can cache a simple call", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const def = new Deferred();
@ -326,7 +333,7 @@ test("update callback - Disk Value", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const def = new Deferred();
@ -448,7 +455,7 @@ test("Ram value shouldn't change (update the IndexedDB response)", async () => {
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
@ -533,7 +540,7 @@ test("Changing the result shouldn't force the call to callback with hasChanged (
value: { test: 123 },
});
// Simulate a reload (Clear the Ram Cache)
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
@ -563,11 +570,235 @@ test("Changing the result shouldn't force the call to callback with hasChanged (
def.resolve({ test: 123 });
});
test("DiskCache: multiple consecutive calls, call once fallback", async () => {
// The fist call to rpcCache.read will save the promise to the Ram Cache.
// Each next call (before the end of the first call) will retrive the promise of the first call
// without executing the fallback
// the callback of each call is executed.
test("RamCache (no update): consecutive calls (success)", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
rpcCache
.read("table", "key", () => def)
.then((r) => {
expect.step(`first prom resolved with ${r}`);
});
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.then((r) => {
expect.step(`second prom resolved with ${r}`);
});
def.resolve("some value");
await tick();
expect.verifySteps([
"first prom resolved with some value",
"second prom resolved with some value",
]);
});
test("RamCache (no update): consecutive calls and rejected promise", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
rpcCache
.read("table", "key", () => def)
.catch((e) => {
expect.step(`first prom rejected ${e.message}`);
});
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.catch((e) => {
expect.step(`second prom rejected ${e.message}`);
});
def.reject(new Error("boom"));
await tick();
expect.verifySteps(["first prom rejected boom", "second prom rejected boom"]);
});
test("RamCache: pending request and call to invalidate", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
rpcCache
.read("table", "key", () => {
expect.step("fallback first call");
return def;
})
.then((r) => {
expect.step(`first prom resolved with ${r}`);
});
rpcCache.invalidate();
rpcCache
.read("table", "key", () => {
expect.step("fallback second call");
return Promise.resolve("another value");
})
.then((r) => {
expect.step(`second prom resolved with ${r}`);
});
def.resolve("some value");
await tick();
expect.verifySteps([
"fallback first call",
"fallback second call",
"second prom resolved with another value",
"first prom resolved with some value",
]);
// call again to ensure that the correct value is stored in the cache
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.then((r) => {
expect.step(`third prom resolved with ${r}`);
});
await tick();
expect.verifySteps(["third prom resolved with another value"]);
});
test("RamCache: pending request and call to invalidate, update callbacks", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
// populate the cache
rpcCache.read("table", "key", () => {
expect.step("first call: fallback");
return Promise.resolve("initial value");
});
await tick();
expect.verifySteps(["first call: fallback"]);
// read cache again, with update callback
const def = new Deferred();
rpcCache
.read(
"table",
"key",
() => {
expect.step("second call: fallback");
return def;
},
{
callback: (newValue) => expect.step(`second call: callback ${newValue}`),
update: "always",
}
)
.then((r) => expect.step(`second call: resolved with ${r}`));
// read it twice, s.t. there's a pending request
rpcCache
.read(
"table",
"key",
() => {
expect.step("should not be called as there's a pending request");
},
{
callback: (newValue) => expect.step(`third call: callback ${newValue}`),
update: "always",
}
)
.then((r) => {
expect.step(`third call: resolved with ${r}`);
});
await tick();
expect.verifySteps([
"second call: fallback",
"second call: resolved with initial value",
"third call: resolved with initial value",
]);
rpcCache.invalidate();
// sanity check to ensure that cache has been invalidated
rpcCache.read("table", "key", () => {
expect.step("fourth call: fallback");
return Promise.resolve("value after invalidation");
});
expect.verifySteps(["fourth call: fallback"]);
// resolve def => update callbacks of requests 2 and 3 must be called
def.resolve("updated value");
await tick();
expect.verifySteps([
"second call: callback updated value",
"third call: callback updated value",
]);
});
test("RamCache: pending request and call to invalidate, update callbacks in error", async () => {
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const defs = [new Deferred(), new Deferred()];
rpcCache
.read("table", "key", () => {
expect.step("first call: fallback (error)");
return defs[0]; // will be rejected
})
.catch((e) => expect.step(`first call: rejected with ${e}`));
// invalidate cache and read again
rpcCache.invalidate();
rpcCache
.read("table", "key", () => {
expect.step("second call: fallback");
return defs[1];
})
.then((r) => expect.step(`second call: resolved with ${r}`));
await tick();
expect.verifySteps(["first call: fallback (error)", "second call: fallback"]);
// reject first def
defs[0].reject("my_error");
await tick();
expect.verifySteps(["first call: rejected with my_error"]);
// read again, should retrieve same prom as second call which is still pending
rpcCache
.read("table", "key", () => expect.step("should not be called"))
.then((r) => expect.step(`third call: resolved with ${r}`));
await tick();
expect.verifySteps([]);
// read again, should retrieve same prom as second call which is still pending (update "always")
rpcCache
.read("table", "key", () => expect.step("should not be called"), { update: "always" })
.then((r) => expect.step(`fourth call: resolved with ${r}`));
await tick();
expect.verifySteps([]);
// resolve second def
defs[1].resolve("updated value");
await tick();
expect.verifySteps([
"second call: resolved with updated value",
"third call: resolved with updated value",
"fourth call: resolved with updated value",
]);
});
test("DiskCache: multiple consecutive calls, empty cache", async () => {
// The fist call to rpcCache.read saves the promise to the RAM cache.
// Each next call (before the end of the first call) retrieves the same result as the first call
// without executing the fallback.
// The callback of each call is executed.
const rpcCache = new RPCCache(
"mockRpc",
@ -586,7 +817,7 @@ test("DiskCache: multiple consecutive calls, call once fallback", async () => {
},
{
callback: () => {
expect.step("callback " + id++);
expect.step(`callback ${++id}`);
},
}
);
@ -595,10 +826,221 @@ test("DiskCache: multiple consecutive calls, call once fallback", async () => {
rpcCacheRead();
rpcCacheRead();
rpcCacheRead();
rpcCacheRead();
expect.verifySteps(["fallback"]);
def.resolve({ test: 123 });
await microTick();
await tick();
expect.verifySteps(["fallback", "callback 0", "callback 1", "callback 2", "callback 3"]);
expect.verifySteps(["callback 1", "callback 2", "callback 3"]);
});
test("DiskCache: multiple consecutive calls, value already in disk cache", async () => {
// The first call to rpcCache.read saves the promise to the RAM cache.
// Each next call (before the end of the first call) retrieves the same result as the first call.
// Each call receives as value the disk value, then each callback is executed.
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
// fill the cache
await rpcCache.read("table", "key", () => Promise.resolve({ test: 123 }), {
type: "disk",
});
await tick();
expect(rpcCache.indexedDB.mockIndexedDB.table.key.ciphertext).toBe(
`encrypted data:{"test":123}`
);
expect(await promiseState(rpcCache.ramCache.ram.table.key)).toEqual({
status: "fulfilled",
value: { test: 123 },
});
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const rpcCacheRead = (id) =>
rpcCache.read(
"table",
"key",
() => {
expect.step(`fallback ${id}`);
return def;
},
{
type: "disk",
callback: (result, hasChanged) => {
expect.step(
`callback ${id}: ${JSON.stringify(result)} ${hasChanged ? "(changed)" : ""}`
);
},
}
);
rpcCacheRead(1).then((result) => expect.step("res call 1: " + JSON.stringify(result)));
await tick();
rpcCacheRead(2).then((result) => expect.step("res call 2: " + JSON.stringify(result)));
await tick();
rpcCacheRead(3).then((result) => expect.step("res call 3: " + JSON.stringify(result)));
await tick();
expect.verifySteps([
"fallback 1",
'res call 1: {"test":123}',
'res call 2: {"test":123}',
'res call 3: {"test":123}',
]);
def.resolve({ test: 456 });
await tick();
expect.verifySteps([
'callback 1: {"test":456} (changed)',
'callback 2: {"test":456} (changed)',
'callback 3: {"test":456} (changed)',
]);
});
test("DiskCache: multiple consecutive calls, fallback fails", async () => {
// The first call to rpcCache.read saves the promise to the RAM cache.
// Each next call (before the end of the first call) retrieves the same result as the first call.
// The fallback fails.
// Each call receives as value the disk value, callbacks aren't executed.
expect.errors(1);
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
// fill the cache
await rpcCache.read("table", "key", () => Promise.resolve({ test: 123 }), {
type: "disk",
});
await tick();
expect(rpcCache.indexedDB.mockIndexedDB.table.key.ciphertext).toBe(
`encrypted data:{"test":123}`
);
expect(await promiseState(rpcCache.ramCache.ram.table.key)).toEqual({
status: "fulfilled",
value: { test: 123 },
});
// simulate a reload (clear ramCache)
rpcCache.ramCache.invalidate();
expect(rpcCache.ramCache.ram).toEqual({});
const rpcCacheRead = (id) =>
rpcCache.read(
"table",
"key",
() => {
expect.step(`fallback ${id}`);
return def;
},
{
type: "disk",
callback: () => {
expect.step("callback (should not be executed)");
},
}
);
rpcCacheRead(1).then((result) => expect.step("res call 1: " + JSON.stringify(result)));
await tick();
rpcCacheRead(2).then((result) => expect.step("res call 2: " + JSON.stringify(result)));
await tick();
rpcCacheRead(3).then((result) => expect.step("res call 3: " + JSON.stringify(result)));
await tick();
expect.verifySteps([
"fallback 1",
'res call 1: {"test":123}',
'res call 2: {"test":123}',
'res call 3: {"test":123}',
]);
def.reject(new Error("my RPCError"));
await tick();
await tick();
expect.verifySteps([]);
expect.verifyErrors(["my RPCError"]);
});
test("DiskCache: multiple consecutive calls, empty cache, fallback fails", async () => {
// The first call to rpcCache.read saves the promise to the RAM cache. That promise will be
// rejected.
// Each next call (before the end of the first call) retrieves the same result as the first call.
// The fallback fails.
// Each call receives the error.
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const def = new Deferred();
const rpcCacheRead = (id) =>
rpcCache.read(
"table",
"key",
() => {
expect.step(`fallback ${id}`);
return def;
},
{
type: "disk",
callback: () => {
expect.step("callback (should not be executed)");
},
}
);
rpcCacheRead(1).catch((error) => expect.step(`error call 1: ${error.message}`));
await tick();
rpcCacheRead(2).catch((error) => expect.step(`error call 2: ${error.message}`));
await tick();
rpcCacheRead(3).catch((error) => expect.step(`error call 3: ${error.message}`));
await tick();
expect.verifySteps(["fallback 1"]);
def.reject(new Error("my RPCError"));
await tick();
expect.verifySteps([
"error call 1: my RPCError",
"error call 2: my RPCError",
"error call 3: my RPCError",
]);
});
test("DiskCache: write throws an IDBQuotaExceededError", async () => {
patchWithCleanup(IndexedDB.prototype, {
deleteDatabase() {
expect.step("delete db");
},
write() {
expect.step("write");
return Promise.reject(new IDBQuotaExceededError());
},
});
const rpcCache = new RPCCache(
"mockRpc",
1,
"85472d41873cdb504b7c7dfecdb8993d90db142c4c03e6d94c4ae37a7771dc5b"
);
const fallback = () => {
expect.step(`fallback`);
return Promise.resolve("value");
};
await rpcCache.read("table", "key", fallback, { type: "disk" });
await expect.waitForSteps(["fallback", "write", "delete db"]);
});

View file

@ -354,3 +354,64 @@ test("icons can be given for each page tab", async () => {
expect(".nav-item:nth-child(3) i").toHaveClass("fa-pencil");
expect(".nav-item:nth-child(3)").toHaveText("page3");
});
test("switch notebook page after async work", async () => {
let { promise, resolve } = Promise.withResolvers();
class Page extends Component {
static template = xml`<h3>Coucou</h3>`;
static props = ["*"];
}
class Parent extends Component {
static template = xml`<Notebook pages="this.pages" onWillActivatePage="() => this.onWillActivatePage()"/>`;
static components = { Notebook };
static props = ["*"];
setup() {
this.pages = [
{
Component: Page,
index: 1,
title: "Page 1",
},
{
Component: Page,
index: 2,
title: "Page 2",
},
];
}
onWillActivatePage() {
return promise;
}
}
await mountWithCleanup(Parent);
const h3Capture1 = queryFirst("h3");
expect(h3Capture1).toBeInstanceOf(HTMLElement);
await click(".o_notebook_headers .nav-item:nth-child(2) a");
await animationFrame();
// async work is not finished
const h3Capture2 = queryFirst("h3");
expect(h3Capture2).toBe(h3Capture1);
resolve(true);
await animationFrame();
// async work completed successfully
const h3Capture3 = queryFirst("h3");
expect(h3Capture3).toBeInstanceOf(HTMLElement);
expect(h3Capture3).not.toBe(h3Capture1);
({ promise, resolve } = Promise.withResolvers());
await click(".o_notebook_headers .nav-item:nth-child(1) a");
await animationFrame();
// async work is not finished
const h3Capture4 = queryFirst("h3");
expect(h3Capture4).toBe(h3Capture3);
resolve(false);
await animationFrame();
// async work resolved with false, preventing the page change
const h3Capture5 = queryFirst("h3");
expect(h3Capture5).toBe(h3Capture3);
});

View file

@ -1,17 +1,28 @@
import { expect, getFixture, test } from "@odoo/hoot";
import { beforeEach, expect, getFixture, test } from "@odoo/hoot";
import { queryOne, queryRect, resize, scroll, waitFor } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, useRef, xml } from "@odoo/owl";
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { Component, useRef, useState, xml } from "@odoo/owl";
import {
contains,
defineStyle,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { Popover } from "@web/core/popover/popover";
import { usePopover } from "@web/core/popover/popover_hook";
import { patch } from "@web/core/utils/patch";
class Content extends Component {
static props = ["*"];
static template = xml`<div id="popover">Popover Content</div>`;
}
beforeEach(() => {
patchWithCleanup(Popover.defaultProps, {
animation: false,
arrow: false,
});
});
test("popover can have custom class", async () => {
await mountWithCleanup(Popover, {
props: {
@ -238,7 +249,7 @@ test("within iframe", async () => {
close: () => {},
target: popoverTarget,
component: Content,
animation: false,
arrow: true,
onPositioned: (_, { direction }) => {
expect.step(direction);
},
@ -262,7 +273,7 @@ test("within iframe", async () => {
await scroll(popoverTarget.ownerDocument.documentElement, { y: 100 }, { scrollable: false });
await animationFrame();
expect.verifySteps(["bottom"]);
expect.verifySteps(["bottom", "bottom"]);
popoverBox = comp.popoverRef.el.getBoundingClientRect();
expectedTop -= 100;
expect(Math.floor(popoverBox.top)).toBe(Math.floor(expectedTop));
@ -359,7 +370,12 @@ test("popover with arrow and onPositioned", async () => {
},
});
expect.verifySteps(["onPositioned (from override)", "onPositioned (from props)"]);
expect.verifySteps([
"onPositioned (from override)",
"onPositioned (from props)", // On mounted
"onPositioned (from override)",
"onPositioned (from props)", // arrow repositionning -> triggers resize observer
]);
expect(".o_popover").toHaveClass("o_popover popover mw-100 bs-popover-auto");
expect(".o_popover").toHaveAttribute("data-popper-placement", "bottom");
expect(".o_popover > .popover-arrow").toHaveClass("position-absolute z-n1");
@ -389,10 +405,50 @@ test("popover closes when navigating", async () => {
expect.verifySteps(["HTML", "close"]);
});
test("popover position is updated when the content dimensions change", async () => {
class DynamicContent extends Component {
setup() {
this.state = useState({
showMore: false,
});
}
static props = ["*"];
static template = xml`<div id="popover">
Click on this <button t-on-click="() => this.state.showMore = true">button</button> to read more
<span t-if="state.showMore">
This tooltip gives your more information on this topic!
</span>
</div>`;
}
await mountWithCleanup(/* xml */ `
<div class="popover-target" style="width: 50px; height: 50px;" />
`);
await mountWithCleanup(Popover, {
props: {
close: () => {},
target: queryOne(".popover-target"),
position: "bottom-start",
component: DynamicContent,
onPositioned() {
expect.step("onPositioned");
},
},
});
expect(".o_popover").toHaveCount(1);
await runAllTimers();
expect.verifySteps(["onPositioned", "onPositioned"]);
await contains("#popover button").click();
expect("#popover span").toHaveCount(1);
await expect.waitForSteps(["onPositioned"]);
});
test("arrow follows target and can get sucked", async () => {
let container;
patch(Popover, { animationTime: 0 });
patch(Popover.prototype, {
patchWithCleanup(Popover.defaultProps, { arrow: true });
patchWithCleanup(Popover.prototype, {
get positioningOptions() {
return {
...super.positioningOptions,
@ -472,3 +528,29 @@ test("arrow follows target and can get sucked", async () => {
expect(".popover-arrow").toHaveClass("sucked");
expect(".popover-arrow").not.toBeVisible();
});
test("popover can animate", async () => {
patchWithCleanup(window.Element.prototype, {
animate() {
expect(this).toHaveClass("o_popover");
expect.step("animated");
return super.animate(...arguments);
},
});
await mountWithCleanup(Popover, {
props: {
close: () => {},
target: getFixture(),
animation: true,
component: Content,
},
});
expect(".o_popover").toHaveCount(1);
await animationFrame();
await runAllTimers();
expect.verifySteps(["animated"]);
});

View file

@ -949,9 +949,10 @@ const CONTAINER_STYLE_MAP = {
w125: { width: "125px" }, // width of popper + 1/2 target
};
function getRepositionTest(from, to, containerStyleChanges) {
function getRepositionTest(from, to, containerStyleChanges, extendedFlipping = false) {
return async () => {
const TestComp = getTestComponent({
extendedFlipping,
position: from,
onPositioned: (el, { direction, variant }) => {
expect.step(`${direction}-${variant}`);
@ -975,156 +976,219 @@ function getRepositionTest(from, to, containerStyleChanges) {
}
// -----------------------------------------------------------------------------
test("reposition from top-start to top", getRepositionTest("top-start", "bottom-start", "top"));
test(
"reposition from top-start to bottom-start",
getRepositionTest("top-start", "bottom-start", "top")
);
test(
"reposition from top-start to bottom-end",
"reposition from top-start to top right",
getRepositionTest("top-start", "bottom-end", "top right")
);
test(
"reposition from top-start to top-start",
"reposition from top-start to slimfit bottom",
getRepositionTest("top-start", "top-start", "slimfit bottom")
);
test("reposition from top-start to top-end", getRepositionTest("top-start", "top-end", "right"));
test("reposition from top-start to right", getRepositionTest("top-start", "top-end", "right"));
// -----------------------------------------------------------------------------
test("reposition from top-middle to top", getRepositionTest("top-middle", "bottom-middle", "top"));
test(
"reposition from top-middle to bottom-middle",
getRepositionTest("top-middle", "bottom-middle", "top")
);
test(
"reposition from top-middle to top-middle",
"reposition from top-middle to slimfit bottom",
getRepositionTest("top-middle", "top-middle", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from top-end to bottom-start",
"reposition from top-end to top left",
getRepositionTest("top-end", "bottom-start", "top left")
);
test("reposition from top-end to bottom-end", getRepositionTest("top-end", "bottom-end", "top"));
test("reposition from top-end to top-start", getRepositionTest("top-end", "top-start", "left"));
test("reposition from top-end to top", getRepositionTest("top-end", "bottom-end", "top"));
test("reposition from top-end to left", getRepositionTest("top-end", "top-start", "left"));
test(
"reposition from top-end to top-end",
"reposition from top-end to slimfit bottom",
getRepositionTest("top-end", "top-end", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test("reposition from left-start to left", getRepositionTest("left-start", "right-start", "left"));
test(
"reposition from left-start to right-start",
getRepositionTest("left-start", "right-start", "left")
);
test(
"reposition from left-start to right-end",
"reposition from left-start to left bottom",
getRepositionTest("left-start", "right-end", "left bottom")
);
test(
"reposition from left-start to left-start",
"reposition from left-start to slimfit top",
getRepositionTest("left-start", "left-start", "slimfit top")
);
test(
"reposition from left-start to left-end",
getRepositionTest("left-start", "left-end", "bottom")
);
test("reposition from left-start to bottom", getRepositionTest("left-start", "left-end", "bottom"));
// -----------------------------------------------------------------------------
test(
"reposition from left-middle to right-middle",
"reposition from left-middle to left",
getRepositionTest("left-middle", "right-middle", "left")
);
test(
"reposition from left-middle to left-middle",
"reposition from left-middle to slimfit bottom",
getRepositionTest("left-middle", "left-middle", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from left-end to right-start",
"reposition from left-end to left top",
getRepositionTest("left-end", "right-start", "left top")
);
test("reposition from left-end to right-end", getRepositionTest("left-end", "right-end", "left"));
test("reposition from left-end to left-start", getRepositionTest("left-end", "left-start", "top"));
test("reposition from left-end to left", getRepositionTest("left-end", "right-end", "left"));
test("reposition from left-end to top", getRepositionTest("left-end", "left-start", "top"));
test(
"reposition from left-end to left-end",
"reposition from left-end to slimfit bottom",
getRepositionTest("left-end", "left-end", "slimfit bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from bottom-start to bottom-start",
"reposition from bottom-start to slimfit top",
getRepositionTest("bottom-start", "bottom-start", "slimfit top")
);
test(
"reposition from bottom-start to bottom-end",
"reposition from bottom-start to right",
getRepositionTest("bottom-start", "bottom-end", "right")
);
test(
"reposition from bottom-start to top-start",
"reposition from bottom-start to bottom",
getRepositionTest("bottom-start", "top-start", "bottom")
);
test(
"reposition from bottom-start to top-end",
"reposition from bottom-start to bottom right",
getRepositionTest("bottom-start", "top-end", "bottom right")
);
// -----------------------------------------------------------------------------
test(
"reposition from bottom-middle to bottom-middle",
"reposition from bottom-middle to slimfit top",
getRepositionTest("bottom-middle", "bottom-middle", "slimfit top")
);
test(
"reposition from bottom-middle to top-middle",
"reposition from bottom-middle to bottom",
getRepositionTest("bottom-middle", "top-middle", "bottom")
);
// -----------------------------------------------------------------------------
test("reposition from bottom-end to left", getRepositionTest("bottom-end", "bottom-start", "left"));
test(
"reposition from bottom-end to bottom-start",
getRepositionTest("bottom-end", "bottom-start", "left")
);
test(
"reposition from bottom-end to bottom-end",
"reposition from bottom-end to slimfit top",
getRepositionTest("bottom-end", "bottom-end", "slimfit top")
);
test(
"reposition from bottom-end to top-start",
"reposition from bottom-end to bottom left",
getRepositionTest("bottom-end", "top-start", "bottom left")
);
test("reposition from bottom-end to top-end", getRepositionTest("bottom-end", "top-end", "bottom"));
test("reposition from bottom-end to bottom", getRepositionTest("bottom-end", "top-end", "bottom"));
// -----------------------------------------------------------------------------
test(
"reposition from right-start to right-start",
"reposition from right-start to slimfit top",
getRepositionTest("right-start", "right-start", "slimfit top")
);
test(
"reposition from right-start to right-end",
"reposition from right-start to bottom",
getRepositionTest("right-start", "right-end", "bottom")
);
test(
"reposition from right-start to left-start",
"reposition from right-start to right",
getRepositionTest("right-start", "left-start", "right")
);
test(
"reposition from right-start to left-end",
"reposition from right-start to right bottom",
getRepositionTest("right-start", "left-end", "right bottom")
);
// -----------------------------------------------------------------------------
test(
"reposition from right-middle to right-middle",
"reposition from right-middle to slimfit bottom",
getRepositionTest("right-middle", "right-middle", "slimfit bottom")
);
test(
"reposition from right-middle to left-middle",
"reposition from right-middle to right",
getRepositionTest("right-middle", "left-middle", "right")
);
// -----------------------------------------------------------------------------
test("reposition from right-end to top", getRepositionTest("right-end", "right-start", "top"));
test(
"reposition from right-end to right-start",
getRepositionTest("right-end", "right-start", "top")
);
test(
"reposition from right-end to right-end",
"reposition from right-end to slimfit bottom",
getRepositionTest("right-end", "right-end", "slimfit bottom")
);
test(
"reposition from right-end to left-start",
"reposition from right-end to right top",
getRepositionTest("right-end", "left-start", "right top")
);
test("reposition from right-end to left-end", getRepositionTest("right-end", "left-end", "right"));
test("reposition from right-end to right", getRepositionTest("right-end", "left-end", "right"));
// Reposition with all flipping directions allowed
test(
"extended reposition from top-start to slimfit bottom",
getRepositionTest("top-start", "center-start", "slimfit bottom", true)
);
test(
"extended reposition from top-start to right",
getRepositionTest("top-start", "top-end", "right", true)
);
test(
"extended reposition from top-middle to slimfit bottom",
getRepositionTest("top-middle", "center-middle", "slimfit bottom", true)
);
test(
"extended reposition from top-end to left",
getRepositionTest("top-end", "top-start", "left", true)
);
test(
"extended reposition from top-end to slimfit bottom",
getRepositionTest("top-end", "center-end", "slimfit bottom", true)
);
test(
"extended reposition from left-start to slimfit top",
getRepositionTest("left-start", "center-start", "slimfit top", true)
);
test(
"extended reposition from left-start to bottom",
getRepositionTest("left-start", "left-end", "bottom", true)
);
test(
"extended reposition from left-middle to slimfit bottom",
getRepositionTest("left-middle", "center-middle", "slimfit bottom", true)
);
test(
"extended reposition from left-end to top",
getRepositionTest("left-end", "left-start", "top", true)
);
test(
"extended reposition from left-end to slimfit bottom",
getRepositionTest("left-end", "center-end", "slimfit bottom", true)
);
test(
"extended reposition from bottom-start to slimfit top",
getRepositionTest("bottom-start", "center-start", "slimfit top", true)
);
test(
"extended reposition from bottom-start to right",
getRepositionTest("bottom-start", "bottom-end", "right", true)
);
test(
"extended reposition from bottom-middle to slimfit top",
getRepositionTest("bottom-middle", "center-middle", "slimfit top", true)
);
test(
"extended reposition from bottom-end to left",
getRepositionTest("bottom-end", "bottom-start", "left", true)
);
test(
"extended reposition from bottom-end to slimfit top",
getRepositionTest("bottom-end", "center-end", "slimfit top", true)
);
test(
"extended reposition from right-start to slimfit top",
getRepositionTest("right-start", "center-start", "slimfit top", true)
);
test(
"extended reposition from right-start to bottom",
getRepositionTest("right-start", "right-end", "bottom", true)
);
test(
"extended reposition from right-middle to slimfit bottom",
getRepositionTest("right-middle", "center-middle", "slimfit bottom", true)
);
test(
"extended reposition from right-end to top",
getRepositionTest("right-end", "right-start", "top", true)
);
test(
"extended reposition from right-end to slimfit bottom",
getRepositionTest("right-end", "center-end", "slimfit bottom", true)
);
function getFittingTest(position, styleAttribute) {
return async () => {

View file

@ -1,6 +1,5 @@
import { describe, expect, getFixture, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { getService, makeMockEnv, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { getService, makeMockEnv, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
@ -17,8 +16,8 @@ const mountManifestLink = (href) => {
test("PWA service fetches the manifest found in the page", async () => {
await makeMockEnv();
mountManifestLink("/web/manifest.webmanifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return { name: "Odoo PWA" };
});
const pwaService = await getService("pwa");
@ -38,8 +37,8 @@ test("PWA installation process", async () => {
browser.BeforeInstallPromptEvent = beforeInstallPromptEvent;
await makeMockEnv();
mountManifestLink("/web/manifest.scoped_app_manifest");
mockFetch((route) => {
expect.step(route);
onRpc("/*", (request) => {
expect.step(new URL(request.url).pathname);
return { name: "My App", scope: "/scoped_app/myApp", start_url: "/scoped_app/myApp" };
});
patchWithCleanup(browser.localStorage, {

View file

@ -88,9 +88,11 @@ test("handles resize handle at start in fixed position", async () => {
x: window.innerWidth - 200,
},
});
expect(resizablePanelEl).toHaveRect({
width: 100 + queryRect(".o_resizable_panel_handle").width / 2,
});
const panelExpectedWidth = 100 + queryRect(".o_resizable_panel_handle").width / 2;
expect(queryRect(resizablePanelEl).width).toBeWithin(
panelExpectedWidth,
panelExpectedWidth + 1
);
});
test("resizing the window adapts the panel", async () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, getFixture, test } from "@odoo/hoot";
import { click, on } from "@odoo/hoot-dom";
import { tick } from "@odoo/hoot-mock";
import { mockMatchMedia, tick } from "@odoo/hoot-mock";
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
@ -1677,6 +1677,24 @@ describe("History", () => {
});
});
describe("Scoped apps", () => {
test("url location is changed to /odoo if the client is not used in a standalone scoped app", async () => {
Object.assign(browser.location, { pathname: "/scoped_app/some-path" });
createRouter();
router.pushState({ app_name: "some_app", path: "scoped_app/some_path" });
await tick();
expect(browser.location.href).toBe("https://www.hoot.test/odoo/some-path?app_name=some_app&path=scoped_app%2Fsome_path");
});
test("url location is preserved as /scoped_app if the client is used in a standalone scoped app", async () => {
mockMatchMedia({ ["display-mode"]: "standalone" });
Object.assign(browser.location, { pathname: "/scoped_app/some-path" });
createRouter();
router.pushState({ app_name: "some_app", path: "scoped_app/some_path" });
await tick();
expect(browser.location.href).toBe("https://www.hoot.test/scoped_app/some-path?app_name=some_app&path=scoped_app%2Fsome_path");
});
})
describe("Retrocompatibility", () => {
test("parse an url with hash (key/values)", async () => {
Object.assign(browser.location, { pathname: "/web" });

View file

@ -729,7 +729,7 @@ test("When multiSelect is enable, value is an array of values, multiple choices
// Select second choice
await open();
expect(".o_select_menu_item:nth-of-type(1).active").toHaveCount(1);
expect(".o_select_menu_item:nth-of-type(1).selected").toHaveCount(1);
await editSelectMenu(".o_select_menu input", { index: 1 });
expect.verifySteps([["a", "b"]]);
@ -737,7 +737,7 @@ test("When multiSelect is enable, value is an array of values, multiple choices
expect(".o_select_menu .o_tag_badge_text").toHaveCount(2);
await open();
expect(".o_select_menu_item.active").toHaveCount(2);
expect(".o_select_menu_item.selected").toHaveCount(2);
});
test("When multiSelect is enable, allow deselecting elements by clicking the selected choices inside the dropdown or by clicking the tags", async () => {
@ -778,7 +778,7 @@ test("When multiSelect is enable, allow deselecting elements by clicking the sel
expect(".o_select_menu .o_tag_badge_text").toHaveText("B");
await open();
expect(".o_select_menu_item.active").toHaveCount(1);
expect(".o_select_menu_item.selected").toHaveCount(1);
await click(".o_tag .o_delete");
await animationFrame();

View file

@ -386,3 +386,40 @@ test("touch rendering - tap-to-show", async () => {
await animationFrame();
expect(".o_popover").toHaveCount(0);
});
test.tags("desktop");
test("tooltip from and to child element", async () => {
class MyComponent extends Component {
static props = ["*"];
static template = xml`
<div class="no-tooltip">space</div>
<div class="p-5" data-tooltip="hello">
<button>Action</button>
</div>`;
}
await mountWithCleanup(MyComponent);
expect(".o_popover").toHaveCount(0);
await pointerDown("div[data-tooltip]");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
const popover = queryOne(".o_popover");
await pointerDown("button");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(1);
expect(queryOne(".o_popover")).toBe(popover);
await pointerDown("div[data-tooltip]");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(queryOne(".o_popover")).toBe(popover);
await pointerDown(".no-tooltip");
await advanceTime(SHOW_AFTER_DELAY);
await advanceTime(OPEN_DELAY);
expect(".o_popover").toHaveCount(0);
});

View file

@ -157,6 +157,29 @@ test("do not become UI active element if no element to focus", async () => {
expect(getService("ui").activeElement).toBe(document);
});
test("become UI active element if no element to focus but the container is focusable", async () => {
class MyComponent extends Component {
static template = xml`
<div>
<h1>My Component</h1>
<input type="text" placeholder="outerUIActiveElement"/>
<div id="idActiveElement" t-ref="delegatedRef" tabindex="-1">
<div>
<span> No focus element </span>
</div>
</div>
</div>
`;
static props = ["*"];
setup() {
useActiveElement("delegatedRef");
}
}
await mountWithCleanup(MyComponent);
expect(getService("ui").activeElement).toBe(queryOne("#idActiveElement"));
});
test("UI active element: trap focus - first or last tabable changes", async () => {
class MyComponent extends Component {
static template = xml`

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "@odoo/hoot";
import { allowTranslations } from "@web/../tests/web_test_helpers";
import { humanSize } from "@web/core/utils/binary";
import { resizeBlobImg } from "@web/core/utils/files";
describe.current.tags("headless");
@ -12,3 +13,35 @@ test("humanSize", () => {
expect(humanSize(2048)).toBe("2.00 Kb");
expect(humanSize(2645000)).toBe("2.52 Mb");
});
test("resize image", async () => {
function buildblobImage(w, h) {
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(200 0 0)";
ctx.fillRect(0, 0, w / 2, h / 2);
canvas.toBlob(resolve);
});
}
function blobTob64(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
resolve(reader.result);
};
});
}
const bigBlobImg = await buildblobImage(256, 256);
const smallBlobImg = await buildblobImage(64, 64);
const resized = await resizeBlobImg(bigBlobImg, { width: 64, height: 64 });
const smallBlobImgB64 = await blobTob64(smallBlobImg);
expect(smallBlobImgB64).not.toBeEmpty();
expect(await blobTob64(resized)).toBe(smallBlobImgB64);
});

View file

@ -433,3 +433,39 @@ test("Focusing is not lost after clicking", async () => {
await contains(".item").click();
expect(".item").toBeFocused();
});
test("allowDisconnected option", async () => {
class List extends Component {
static template = xml`
<div t-ref="root" class="root">
<button class="handle" t-if="state.hasHandle">Handle</button>
<ul class="list list-unstyled m-0 d-flex flex-column">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item w-50 h-100" />
</ul>
</div>`;
static props = ["*"];
setup() {
this.state = useState({ hasHandle: true });
useDraggable({
ref: useRef("root"),
elements: ".handle",
allowDisconnected: true,
onDragStart: () => {
expect.step("start");
this.state.hasHandle = false;
},
onDragEnd: () => expect.step("end"),
onDrop: () => expect.step("drop"), // should be called as allowDisconnected
});
}
}
await mountWithCleanup(List);
const { moveTo, drop } = await contains(".handle").drag();
expect.verifySteps(["start"]);
await animationFrame();
expect(".handle").toHaveCount(0);
await moveTo(".item:nth-child(2)");
await drop();
expect.verifySteps(["drop", "end"]);
});

View file

@ -1,8 +1,6 @@
import { describe, expect, test } from "@odoo/hoot";
import { htmlEscape, markup } from "@odoo/owl";
const Markup = markup().constructor;
import { describe, expect, test } from "@odoo/hoot";
import {
createDocumentFragmentFromContent,
createElementWithContent,
@ -18,6 +16,8 @@ import {
setElementContent,
} from "@web/core/utils/html";
const Markup = markup().constructor;
describe.current.tags("headless");
test("createDocumentFragmentFromContent escapes text", () => {
@ -47,6 +47,9 @@ test("highlightText", () => {
expect(highlightText("b", "abcb", "hl").toString()).toBe(
'a<span class="hl">b</span>c<span class="hl">b</span>'
);
expect(highlightText("b", "abbc", "hl").toString()).toBe(
'a<span class="hl">b</span><span class="hl">b</span>c'
);
expect(highlightText("b", "<p>ab</p>", "hl").toString()).toBe(
'&lt;p&gt;a<span class="hl">b</span>&lt;/p&gt;'
);
@ -111,9 +114,7 @@ test("htmlSprintf escapes list params", () => {
markup`<span>test 1</span>`,
`<span>test 2</span>`
);
expect(res.toString()).toBe(
"<p><span>test 1</span>,&lt;span&gt;test 2&lt;/span&gt;</p>undefined"
);
expect(res.toString()).toBe("<p><span>test 1</span></p>&lt;span&gt;test 2&lt;/span&gt;");
expect(res).toBeInstanceOf(Markup);
});
@ -299,10 +300,10 @@ test("htmlReplaceAll with html/text does not find, keeps all", () => {
test("htmlReplace/htmlReplaceAll only accept functions replacement when search is a RegExp", () => {
expect(() => htmlReplace("test", /test/, "$1")).toThrow(
"htmlReplace: replacement must be a function when search is a RegExp."
"htmlReplace: replacer must be a function when search is a RegExp."
);
expect(() => htmlReplaceAll("test", /test/, "$1")).toThrow(
"htmlReplaceAll: replacement must be a function when search is a RegExp."
"htmlReplaceAll: replacer must be a function when search is a RegExp."
);
});
@ -324,9 +325,9 @@ test("odoomark", () => {
expect(odoomark("**test** something else **test**").toString()).toBe(
"<b>test</b> something else <b>test</b>"
);
expect(odoomark("--test--").toString()).toBe("<span class='text-muted'>test</span>");
expect(odoomark("--test--").toString()).toBe(`<span class="text-muted">test</span>`);
expect(odoomark("--test-- something else --test--").toString()).toBe(
"<span class='text-muted'>test</span> something else <span class='text-muted'>test</span>"
`<span class="text-muted">test</span> something else <span class="text-muted">test</span>`
);
expect(odoomark("`test`").toString()).toBe(
`<span class="o_tag position-relative d-inline-flex align-items-center mw-100 o_badge badge rounded-pill lh-1 o_tag_color_0">test</span>`
@ -337,7 +338,7 @@ test("odoomark", () => {
expect(odoomark("test\ttest2").toString()).toBe(
`test<span style="margin-left: 2em"></span>test2`
);
expect(odoomark("test\ntest2").toString()).toBe("test<br/>test2");
expect(odoomark("test\ntest2").toString()).toBe("test<br>test2");
expect(odoomark("<p>**test**</p>").toString()).toBe("&lt;p&gt;<b>test</b>&lt;/p&gt;");
expect(odoomark(markup`<p>**test**</p>`).toString()).toBe("<p><b>test</b></p>");
});

View file

@ -252,6 +252,42 @@ test("floatIsZero", () => {
});
describe("formatFloat", () => {
test("precision", () => {
patchWithCleanup(localization, {
decimalPoint: ".",
grouping: [3, 0],
thousandsSep: ",",
});
let options = {};
expect(formatFloat(3, options)).toBe("3.00");
expect(formatFloat(3.1, options)).toBe("3.10");
expect(formatFloat(3.12, options)).toBe("3.12");
expect(formatFloat(3.129, options)).toBe("3.13");
options = { digits: [15, 3] };
expect(formatFloat(3, options)).toBe("3.000");
expect(formatFloat(3.1, options)).toBe("3.100");
expect(formatFloat(3.123, options)).toBe("3.123");
expect(formatFloat(3.1239, options)).toBe("3.124");
options = { minDigits: 3 };
expect(formatFloat(0, options)).toBe("0.000");
expect(formatFloat(3, options)).toBe("3.000");
expect(formatFloat(3.1, options)).toBe("3.100");
expect(formatFloat(3.123, options)).toBe("3.123");
expect(formatFloat(3.1239, options)).toBe("3.1239");
expect(formatFloat(3.1231239, options)).toBe("3.123124");
expect(formatFloat(1234567890.1234567890, options)).toBe("1,234,567,890.12346");
options = { minDigits: 3, digits: [15, 4] };
expect(formatFloat(3, options)).toBe("3.000");
expect(formatFloat(3.1, options)).toBe("3.100");
expect(formatFloat(3.123, options)).toBe("3.123");
expect(formatFloat(3.1239, options)).toBe("3.1239");
expect(formatFloat(3.1234567, options)).toBe("3.1235");
});
test("localized", () => {
patchWithCleanup(localization, {
decimalPoint: ".",
@ -343,7 +379,7 @@ describe("formatFloat", () => {
expect(formatFloat(value, options)).toBe(resHuman);
});
Object.assign(options, { humanReadable: false });
Object.assign(options, { humanReadable: false, digits: undefined, minDigits: undefined});
expect(formatFloat(-0.0000001, options)).toBe("0.00");
});
});

View file

@ -11,6 +11,7 @@ test("fuzzyLookup", () => {
{ name: "Jane Yellow" },
{ name: "Brandon Green" },
{ name: "Jérémy Red" },
{ name: "สมศรี จู่โจม" },
];
expect(fuzzyLookup("ba", data, (d) => d.name)).toEqual([
{ name: "Brandon Green" },
@ -25,6 +26,7 @@ test("fuzzyLookup", () => {
{ name: "Jane Yellow" },
]);
expect(fuzzyLookup("", data, (d) => d.name)).toEqual([]);
expect(fuzzyLookup("สมศ", data, (d) => d.name)).toEqual([{ name: "สมศรี จู่โจม" }]);
});
test("fuzzyTest", () => {

View file

@ -628,3 +628,53 @@ test("clone option", async () => {
await contains(".item:first-child").dragAndDrop(".item:nth-child(2)");
expect(".placeholder:not(.item)").toHaveCount(0);
});
test("dragged element is removed from the DOM while being dragged", async () => {
class List extends Component {
static props = ["*"];
static template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="state.items" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
setup() {
this.state = useState({
items: [1, 2, 3],
});
useSortable({
ref: useRef("root"),
elements: ".item",
onDragStart() {
expect.step("start");
},
onDragEnd() {
expect.step("end");
},
onDrop() {
expect.step("drop"); // should not be called
},
});
}
}
const list = await mountWithCleanup(List);
expect(".item:visible").toHaveCount(3);
expect(".o_dragged").toHaveCount(0);
expect.verifySteps([]);
const { drop, moveTo } = await contains(".item:first-child").drag();
expect(".o_dragged").toHaveCount(1);
expect.verifySteps(["start"]);
await moveTo(".item:nth-child(2)");
expect(".o_dragged").toHaveCount(1);
list.state.items = [3, 4];
await animationFrame();
expect(".item:visible").toHaveCount(2);
expect(".o_dragged").toHaveCount(0);
await drop();
expect.verifySteps(["end"]);
});

View file

@ -84,6 +84,7 @@ describe("sprintf", () => {
expect(sprintf("Hello!")).toBe("Hello!");
expect(sprintf("Hello %s!")).toBe("Hello %s!");
expect(sprintf("Hello %(value)s!")).toBe("Hello %(value)s!");
expect(sprintf("Hello %(value)s!", {})).toBe("Hello !");
});
test("properly formats numbers", () => {
@ -108,6 +109,16 @@ describe("sprintf", () => {
const vals = { one: _t("one"), two: _t("two") };
expect(sprintf("Hello %(two)s %(one)s", vals)).toBe("Hello två en");
});
test("supports escaped '%' signs", () => {
expect(sprintf("Escape %s", "%s")).toBe("Escape %s");
expect(sprintf("Escape %%s", "this!")).toBe("Escape %s");
expect(sprintf("Escape %%%s", "this!")).toBe("Escape %this!");
expect(sprintf("Escape %%%%s!", "this")).toBe("Escape %%s!");
expect(sprintf("Escape %s%s", "this!")).toBe("Escape this!");
expect(sprintf("Escape %%s%s", "this!")).toBe("Escape %sthis!");
expect(sprintf("Escape %foo!", "this")).toBe("Escape %foo!");
});
});
test("capitalize", () => {

View file

@ -1,9 +1,10 @@
import { expect, test } from "@odoo/hoot";
import { expect, onError, test } from "@odoo/hoot";
import { on } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { Component, useRef, xml } from "@odoo/owl";
import { contains, getMockEnv, mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useDraggable } from "@web/core/utils/draggable";
test("contains: all actions", async () => {
class Container extends Component {
@ -103,3 +104,58 @@ test("contains: all actions", async () => {
expect.verifySteps(events);
}
});
test("only one drag sequence is allowed at a time", async () => {
expect.assertions(3);
await mountWithCleanup(
class extends Component {
static components = {};
static props = {};
static template = xml`
<ul t-ref="list">
<li>First item</li>
<li>Second item</li>
</ul>
`;
setup() {
useDraggable({
ref: useRef("list"),
elements: "li",
onDragStart() {
expect.step("dragstart");
},
onDragEnd() {
if (throwOnDragEnd) {
throw new Error("dragend error");
} else {
expect.step("dragend");
}
},
onDrop() {
throw new Error("should not call drop");
},
});
}
}
);
let throwOnDragEnd = false;
await contains("li:first").drag();
expect.verifySteps(["dragstart"]);
await contains("li:last").drag();
expect.verifySteps(["dragend", "dragstart"]);
throwOnDragEnd = true;
onError((ev) => {
ev.preventDefault();
expect(ev.error).toMatch("dragend error", {
message: "drag sequence should be automatically canceled after test",
});
});
});

View file

@ -158,10 +158,11 @@ describe("level 1", () => {
await makeMockEnv();
await expect(
getService("orm").searchRead("oui", [], ["id", "name", "age", "surname"])
getService("orm").searchRead("oui", [], ["id", "name", "age", "surname", "create_date"])
).resolves.toEqual([
{
id: 1,
create_date: luxon.DateTime.utc().toSQL().slice(0, 19),
name: "John Doe",
age: 42,
surname: "doedoe",

View file

@ -10,6 +10,8 @@ import {
} from "@web/../tests/web_test_helpers";
import { localization } from "@web/core/l10n/localization";
import { ConnectionLostError, rpc } from "@web/core/network/rpc";
class Partner extends models.Model {
_name = "res.partner";
@ -152,15 +154,15 @@ defineModels([Partner, Bar, Foo]);
* kwargs: Record<string, any>;
* [key: string]: any;
* }} params
* @returns
*/
const ormRequest = async (params) => {
const response = await fetch(`/web/dataset/call_kw/${params.model}/${params.method}`, {
function fetchCallKw(params) {
return fetch(`/web/dataset/call_kw/${params.model}/${params.method}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: nextJsonRpcId++,
jsonrpc: "2.0",
method: "call",
params: {
@ -170,6 +172,19 @@ const ormRequest = async (params) => {
},
}),
});
}
/**
* @param {{
* model: string;
* method: string;
* args: any[];
* kwargs: Record<string, any>;
* [key: string]: any;
* }} params
*/
const ormRequest = async (params) => {
const response = await fetchCallKw(params);
const { error, result } = await response.json();
if (error) {
console.error(error);
@ -178,6 +193,17 @@ const ormRequest = async (params) => {
return result;
};
/**
* Minimal parameters to have a request considered as a JSON-RPC request
*/
const JSON_RPC_BASIC_PARAMS = {
body: "{}",
headers: {
["Content-Type"]: "application/json",
},
};
let nextJsonRpcId = 0;
describe.current.tags("headless");
test("onRpc: normal result", async () => {
@ -189,35 +215,17 @@ test("onRpc: normal result", async () => {
expect(response).toBeInstanceOf(Response);
await expect(response.json()).resolves.toEqual({ result: "result", error: null });
await expect(response.text()).resolves.toBe("result");
});
test("onRpc: error handling", async () => {
class CustomError extends Error {
name = "CustomError";
}
onRpc("/boom", () => {
throw new CustomError("boom");
throw new Error("boom");
});
await makeMockServer();
const response = await fetch("/boom");
expect(response).toBeInstanceOf(Response);
await expect(response.json()).resolves.toEqual({
result: null,
error: {
code: 418,
data: {
name: "CustomError",
},
message: "boom",
type: "CustomError",
},
});
await expect(fetch("/boom")).rejects.toThrow("boom");
});
test("onRpc: pure, normal result", async () => {
@ -246,6 +254,97 @@ test("onRpc: pure, error handling", async () => {
await expect(fetch("/boom")).rejects.toThrow("boom");
});
test("onRpc: JSON-RPC normal result", async () => {
onRpc("/get_result", () => "get_result value");
await makeMockServer();
const response = await fetch("/get_result", JSON_RPC_BASIC_PARAMS);
expect(response).toBeInstanceOf(Response);
const result = await response.json();
expect(result).toMatchObject({
result: "get_result value",
});
expect(result).not.toInclude("error");
});
test("onRpc: JSON-RPC error handling", async () => {
class CustomError extends Error {
name = "CustomError";
}
onRpc("/boom", () => {
throw new CustomError("boom");
});
await makeMockServer();
const response = await fetch("/boom", JSON_RPC_BASIC_PARAMS);
expect(response).toBeInstanceOf(Response);
const result = await response.json();
expect(result).not.toInclude("result");
expect(result).toMatchObject({
error: {
code: 200,
data: {
name: "CustomError",
message: "boom",
},
message: "boom",
type: "server",
},
});
});
test("rpc: calls on mock server", async () => {
onRpc("/route", () => "pure route response");
onRpc("http://pure.route.com/", () => "external route response");
onRpc("/boom", () => {
throw new Error("Boom");
});
onRpc(
"/boom/pure",
() => {
throw new Error("Pure boom");
},
{ pure: true }
);
await makeMockServer();
await expect(rpc("/route")).resolves.toBe("pure route response");
await expect(rpc("http://pure.route.com/")).resolves.toBe("external route response");
await expect(rpc("/boom")).rejects.toThrow("RPC_ERROR: Boom");
await expect(rpc("/boom/pure")).rejects.toThrow(ConnectionLostError);
// MockServer error handling with 'rpc'
await expect(rpc("/unknown/route")).rejects.toThrow(
"Unimplemented server route: /unknown/route"
);
await expect(rpc("https://unknown.route")).rejects.toThrow(
"Unimplemented server external URL: https://unknown.route"
);
await expect(
rpc("/web/dataset/call_kw/fake.model/fake_method", {
model: "fake.model",
method: "fake_method",
})
).rejects.toThrow(
`Cannot find a definition for model "fake.model": could not get model from server environment`
);
});
test("performRPC: custom response", async () => {
const customResponse = new Response("{}", { status: 418 });
onRpc(() => customResponse);
await makeMockServer();
await expect(fetchCallKw({})).resolves.toBe(customResponse);
});
test("performRPC: search with active_test=false", async () => {
await makeMockServer();
const result = await ormRequest({

View file

@ -254,8 +254,8 @@ describe("RPC calls", () => {
test("'web_read_group': 2 groups", async () => {
const server = new DeterministicSampleServer("hobbit", fields.hobbit);
const existingGroups = [
{ profession: "gardener", count: 0 }, // fake group
{ profession: "adventurer", count: 0 }, // fake group
{ profession: "gardener", count: 0, __records: [] }, // fake group
{ profession: "adventurer", count: 0, __records: [] }, // fake group
];
server.setExistingGroups(existingGroups);
const result = await server.mockRpc({
@ -276,9 +276,9 @@ describe("RPC calls", () => {
test("'web_read_group': all groups", async () => {
const server = new DeterministicSampleServer("hobbit", fields.hobbit);
const existingGroups = [
{ profession: "gardener", count: 0 }, // fake group
{ profession: "brewer", count: 0 }, // fake group
{ profession: "adventurer", count: 0 }, // fake group
{ profession: "gardener", count: 0, __records: [] }, // fake group
{ profession: "brewer", count: 0, __records: [] }, // fake group
{ profession: "adventurer", count: 0, __records: [] }, // fake group
];
server.setExistingGroups(existingGroups);
const result = await server.mockRpc({

View file

@ -1079,6 +1079,29 @@ describe("waitFor...", () => {
await click(".test");
expect.verifySteps(["before", "in catch", "updatecontent"]);
});
test("waitFor support promise is 'undefined'", async () => {
class Test extends Interaction {
static selector = ".test";
dynamicContent = {
_root: { "t-on-click": this.onClick },
};
async onClick() {
await this.waitFor(undefined);
expect.step("clicked");
}
updateContent() {
expect.step("updatecontent");
super.updateContent();
}
}
await startInteraction(Test, TemplateTest);
expect.verifySteps([]);
await click(".test");
expect.verifySteps(["clicked", "updatecontent"]);
});
});
describe("waitForTimeout", () => {
@ -1903,6 +1926,41 @@ describe("t-att and t-out", () => {
expect("span").not.toHaveAttribute("animal");
expect("span").toHaveAttribute("egg", "mysterious");
});
test("t-out-... resets at stop", async () => {
class Test extends Interaction {
static selector = "span";
dynamicContent = {
_root: { "t-out": () => "colibri" },
};
}
const { core } = await startInteraction(Test, TemplateTest);
expect("span").toHaveText("colibri");
core.stopInteractions();
expect("span").toHaveText("coucou");
});
test("t-out-... restores all values on stop", async () => {
class Test extends Interaction {
static selector = "div";
dynamicContent = {
span: { "t-out": () => "colibri" },
};
}
const { core } = await startInteraction(
Test,
`
<div>
<span>penguin</span>
<span>ostrich</span>
</div>
`
);
expect("span").toHaveText("colibri");
core.stopInteractions();
expect("span:first").toHaveText("penguin");
expect("span:last").toHaveText("ostrich");
});
});
describe("components", () => {
@ -2436,23 +2494,64 @@ describe("locked", () => {
expect(queryFirst("span")).toBe(null);
});
test("locked add a loading icon when the execution takes more than 400ms", async () => {
class Test extends Interaction {
static selector = ".test";
dynamicContent = {
button: {
"t-on-click": this.locked(this.onClickLong, true),
},
};
async onClickLong() {
await new Promise((resolve) => setTimeout(resolve, 5000));
describe("loading effect", () => {
let handlerDuration = 5000;
beforeEach(async () => {
class Test extends Interaction {
static selector = ".test";
dynamicContent = {
button: {
"t-on-click": this.locked(this.onClickLong, true),
},
};
async onClickLong() {
await new Promise((resolve) => setTimeout(resolve, handlerDuration));
expect.step("handler done");
}
}
}
await startInteraction(Test, TemplateTestDoubleButton);
expect(queryFirst("span")).toBe(null);
await click("button");
await advanceTime(500);
expect(queryFirst("span")).not.toBe(null);
await startInteraction(Test, TemplateTestDoubleButton, {
waitForStart: false,
});
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if ([...m.addedNodes].some((node) => node.tagName === "SPAN")) {
expect.step("loading added");
}
}
});
observer.observe(queryFirst("button"), { childList: true });
});
test("should be added when the handler takes more than 400ms", async () => {
expect("span.fa-spin").toHaveCount(0);
await click("button");
// Advance time more than the debounce delay of makeButtonHandler
// (400ms) but less than the handler duration.
await advanceTime(500);
expect("span.fa-spin").toHaveCount(1);
expect.verifySteps(["loading added"]);
await advanceTime(handlerDuration);
expect.verifySteps(["handler done"]);
});
test("should never be added when the handler takes less than 400ms", async () => {
handlerDuration = 100;
expect("span.fa-spin").toHaveCount(0);
await click("button");
// Advance time more than the handler duration but less than the
// debounce delay of makeButtonHandler (400ms).
await advanceTime(200);
expect.verifySteps(["handler done"]);
await advanceTime(1000);
expect("span.fa-spin").toHaveCount(0);
expect.verifySteps([], {
message:
"Loading effect should never be added in the DOM for handlers shorter than 400ms",
});
});
});
test("locked automatically binds functions", async () => {

View file

@ -160,6 +160,33 @@ test("start interactions with selectorHas", async () => {
expect(core.interactions[0].interaction.el).toBe(queryOne(".test:has(.inner)"));
});
test("stop interactions with selectorHas", async () => {
class Test extends Interaction {
static selector = ".test";
static selectorHas = ".inner";
start() {
expect.step("start");
}
destroy() {
expect.step("destroy");
}
}
const { core } = await startInteraction(
Test,
`
<div class="test"><div class="inner"></div><div class="other"></div></div>
`
);
expect.verifySteps(["start"]);
queryOne(".inner").remove();
core.stopInteractions(queryOne(".other")); // on sub-element of the Interaction root
expect.verifySteps(["destroy"]);
});
test("start interactions even if there is a crash when evaluating selectorNotHas", async () => {
class Boom extends Interaction {
static selector = ".test";
@ -216,6 +243,30 @@ test("start interactions with selectorNotHas", async () => {
expect(core.interactions[0].interaction.el).toBe(queryOne(".test:not(:has(.inner))"));
});
test("stop interactions with selectorNotHas", async () => {
class Test extends Interaction {
static selector = ".test";
static selectorNotHas = ".inner";
start() {
expect.step("start");
}
destroy() {
expect.step("destroy");
}
}
const { core } = await startInteraction(Test, `<div class="test"></div>`);
expect.verifySteps(["start"]);
const div = document.createElement("div");
div.className = "inner";
queryOne(".test").appendChild(div);
core.stopInteractions(div); // on sub-element of the Interaction root (added node)
expect.verifySteps(["destroy"]);
});
test("recover from error as much as possible when applying dynamiccontent", async () => {
let a = "a";
let b = "b";

View file

@ -1,5 +1,6 @@
import { expect, test } from "@odoo/hoot";
import {
advanceTime,
clear,
click,
edit,
@ -12,7 +13,7 @@ import {
queryFirst,
runAllTimers,
} from "@odoo/hoot-dom";
import { Deferred, animationFrame, mockTimeZone } from "@odoo/hoot-mock";
import { Deferred, animationFrame, mockTimeZone, mockTouch } from "@odoo/hoot-mock";
import { Component, onWillUpdateProps, xml } from "@odoo/owl";
import {
SELECTORS,
@ -46,7 +47,7 @@ import {
validateSearch,
} from "@web/../tests/web_test_helpers";
import { cookie } from "@web/core/browser/cookie";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { SearchBar, DROPDOWN_CLOSE_DELAY } from "@web/search/search_bar/search_bar";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
class Partner extends models.Model {
name = fields.Char();
@ -215,6 +216,45 @@ test("navigation with facets (2)", async () => {
expect(queryFirst`.o_searchview .o_searchview_facet:nth-child(1)`).toBeFocused();
});
test.tags("desktop");
test("navigation should move forward from search bar filter", async () => {
await mountWithSearch(SearchBar, {
resModel: "partner",
searchMenuTypes: ["groupBy"],
searchViewId: false,
context: { search_default_date_group_by: 1 },
});
expect(`.o_searchview .o_searchview_facet`).toHaveCount(1);
expect(queryFirst`.o_searchview input`).toBeFocused();
// press tab to navigate forward to the toggler
await keyDown("Tab");
await animationFrame();
expect(queryFirst`.o_searchview_dropdown_toggler`).toBeFocused();
});
test.tags("desktop");
test("navigation should move backward from search bar filter", async () => {
await mountWithSearch(SearchBar, {
resModel: "partner",
searchMenuTypes: ["groupBy"],
searchViewId: false,
context: { search_default_date_group_by: 1 },
});
expect(`.o_searchview .o_searchview_facet`).toHaveCount(1);
expect(queryFirst`.o_searchview input`).toBeFocused();
// press shift+tab to navigate backward to the search icon button
await keyDown("Shift");
await press("Tab");
await animationFrame();
await press("Tab");
await animationFrame();
expect(queryFirst`.d-print-none.btn`).toBeFocused();
});
test.tags("mobile");
test("search input is focused when being toggled", async () => {
class Parent extends Component {
@ -241,6 +281,18 @@ test("search input is focused when being toggled", async () => {
expect(queryFirst`.o_searchview input`).toBeFocused();
});
test.tags("desktop");
test("search input is not focused on larger touch devices", async () => {
mockTouch(true);
await mountWithSearch(SearchBar, {
resModel: "partner",
searchMenuTypes: [],
searchViewId: false,
});
expect(".o_searchview input").toHaveCount(1);
expect(".o_searchview input").not.toBeFocused();
});
test("search date and datetime fields. Support of timezones", async () => {
mockTimeZone(6);
@ -483,6 +535,44 @@ test("update suggested filters in autocomplete menu with Japanese IME", async ()
);
});
test("intermediate Backspace events from iOS Korean IME shouldn't close autocomplete", async () => {
// This test simulates the behavior of the iOS Korean IME during composition.
// On iOS, `isComposing` is not set, but the IME sends a Backspace before
// rewriting the composing syllable. Our component (SearchBar) must handle
// this without closing the autocomplete dropdown.
// Typing 'ㄱ' followed by 'ㅏ' produces the precomposed syllable '가'.
const COMPOSED_SYLLABLE = "가";
await mountWithSearch(SearchBar, {
resModel: "partner",
searchMenuTypes: [],
searchViewId: false,
});
await click(".o_searchview input");
// User types the initial consonant 'ㄱ'
await press("ㄱ");
// Wait search autocomplete
await animationFrame();
// User types the second character 'ㅏ'
// iOS sends Backspace to remove previous char and inserts the precomposed syllable
await press("Backspace");
await press("가");
// Autocomplete should remain open even after composition with backspace.
await animationFrame();
await advanceTime(DROPDOWN_CLOSE_DELAY);
await animationFrame();
expect(queryFirst`.o_searchview input`).toHaveValue(COMPOSED_SYLLABLE);
expect(`.o_searchview_autocomplete .o-dropdown-item:first`).toHaveText(
`Search Foo for: ${COMPOSED_SYLLABLE}`
);
});
test("open search view autocomplete on paste value using mouse", async () => {
await mountWithSearch(SearchBar, {
resModel: "partner",
@ -1028,6 +1118,8 @@ test("search a property", async () => {
// search for a partner, and expand the many2many property
await contains(`.o_searchview_input`).clear();
// wait for autocomplete to close to make sure it updates its state
await advanceTime(DROPDOWN_CLOSE_DELAY);
await editSearch("Bo");
await contains(".o_expand").click();
await contains(".o_searchview_autocomplete .o-dropdown-item:nth-child(3) .o_expand").click();
@ -1927,3 +2019,21 @@ test("no crash when search component is destroyed with input", async () => {
await runAllTimers();
expect(".o_form_view").toHaveCount(1);
});
test("search on full query without waiting for display synchronisation", async () => {
/* Typically a barcode scan where the dropdown display doesn't have the time to update */
const searchBar = await mountWithSearch(SearchBar, {
resModel: "partner",
searchMenuTypes: [],
searchViewId: false,
});
await editSearch("01234");
expect(".o-dropdown-item:first").toHaveText("Search Foo for: 01234");
await press("5");
expect(".o-dropdown-item:first").toHaveText("Search Foo for: 01234");
await press("6");
expect(".o-dropdown-item:first").toHaveText("Search Foo for: 01234");
await keyDown("Enter");
expect(searchBar.env.searchModel.domain).toEqual([["foo", "ilike", "0123456"]]);
});

View file

@ -17,6 +17,7 @@ const FAKE_RECORD = {
isTimeHidden: false,
rawRecord: {
name: "Meeting",
description: "<p>Test description</p>",
},
};
@ -41,6 +42,7 @@ test(`mount a CalendarCommonPopover`, async () => {
expect(`.popover-header`).toHaveText("Meeting");
expect(`.list-group`).toHaveCount(2);
expect(`.list-group.o_cw_popover_fields_secondary`).toHaveCount(1);
expect(`.list-group.o_cw_popover_fields_secondary div[name="description"]`).toHaveClass("text-wrap");
expect(`.card-footer .o_cw_popover_edit`).toHaveCount(1);
expect(`.card-footer .o_cw_popover_delete`).toHaveCount(1);
});

View file

@ -143,6 +143,7 @@ export const FAKE_FIELDS = {
default: 1,
},
name: { string: "Name", type: "char" },
description: { string: "Description", type: "html" },
start_date: { string: "Start Date", type: "date" },
stop_date: { string: "Stop Date", type: "date" },
start: { string: "Start Datetime", type: "datetime" },
@ -189,6 +190,12 @@ export const FAKE_MODEL = {
"event",
"calendar"
),
description: Field.parseFieldNode(
createElement("field", { name: "description" , class: "text-wrap"}),
{ event: { fields: FAKE_FIELDS } },
"event",
"calendar"
),
},
activeFields: {
name: {
@ -198,6 +205,13 @@ export const FAKE_MODEL = {
required: false,
onChange: false,
},
description: {
context: "{}",
invisible: false,
readonly: false,
required: false,
onChange: false,
},
},
rangeEnd: DEFAULT_DATE.endOf("month"),
rangeStart: DEFAULT_DATE.startOf("month"),

View file

@ -1445,7 +1445,6 @@ test(`create event with timezone in week mode European locale`, async () => {
</calendar>
`,
});
await selectTimeRange("2016-12-13 08:00:00", "2016-12-13 10:00:00");
expect(`.fc-event-main .fc-event-time`).toHaveText("08:00 - 10:00");
@ -1460,6 +1459,23 @@ test(`create event with timezone in week mode European locale`, async () => {
expect(`.fc-event-main`).toHaveCount(0);
});
test(`create multi day event in week mode`, async () => {
mockTimeZone(2);
patchWithCleanup(CalendarCommonRenderer.prototype, {
get options() {
return { ...super.options, selectAllow: () => true };
},
});
await mountView({
resModel: "event",
type: "calendar",
arch: `<calendar date_start="start" date_stop="stop" mode="week"/>`,
});
await selectTimeRange("2016-12-13 11:00:00", "2016-12-14 16:00:00");
expect(`.fc-event-main .fc-event-time`).toHaveText("11:00 - 16:00");
});
test(`default week start (US)`, async () => {
// if not given any option, default week start is on Sunday
mockTimeZone(-7);
@ -2187,6 +2203,51 @@ test(`set filter with many2many field on mobile`, async () => {
expect(`.o_event[data-event-id="5"] .fc-event-main`).toHaveCount(0);
});
test.tags("desktop");
test("many2many filter handles archived records without crashing on desktop", async () => {
CalendarPartner._fields.active = fields.Boolean({ default: true });
CalendarPartner._records.push({
id: 99,
name: "Joni",
active: false,
});
Event._records[0].attendee_ids = [99];
await mountView({
resModel: "event",
type: "calendar",
arch: `
<calendar date_start="start">
<field name="attendee_ids" filters="1"/>
</calendar>
`,
});
expect(`.o_calendar_filter_item`).toHaveCount(4);
});
test.tags("mobile");
test("many2many filter handles archived records without crashing on mobile", async () => {
CalendarPartner._fields.active = fields.Boolean({ default: true });
CalendarPartner._records.push({
id: 99,
name: "Joni",
active: false,
});
Event._records[0].attendee_ids = [99];
await mountView({
resModel: "event",
type: "calendar",
arch: `
<calendar date_start="start">
<field name="attendee_ids" filters="1"/>
</calendar>
`,
});
await contains(`.o_filter`).click();
expect(`.o_calendar_filter_item`).toHaveCount(4);
});
test.tags("desktop");
test(`set filter with one2many field on desktop`, async () => {
Event._fields.attendee_ids = fields.One2many({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -133,7 +133,7 @@ test("properly compile notebook", () => {
const expected = /*xml*/ `
<t t-translation="off">
<div class="o_form_renderer o_form_nosheet" t-att-class="__comp__.props.class" t-attf-class="{{__comp__.props.record.isInEdition ? 'o_form_editable' : 'o_form_readonly'}} d-block {{ __comp__.props.record.dirty ? 'o_form_dirty' : !__comp__.props.record.isNew ? 'o_form_saved' : '' }}" t-ref="compiled_view_root">
<Notebook defaultPage="__comp__.props.record.isNew ? undefined : __comp__.props.activeNotebookPages[0]" onPageUpdate="(page) =&gt; __comp__.props.onNotebookPageChange(0, page)">
<Notebook defaultPage="__comp__.props.record.isNew ? undefined : __comp__.props.activeNotebookPages[0]" onPageUpdate="(page) =&gt; __comp__.props.onNotebookPageChange(0, page)" onWillActivatePage="(page) =&gt; __comp__.onWillChangeNotebookPage?.(0, page)">
<t t-set-slot="page_1" title="\`Page1\`" name="\`p1\`" isVisible="true" fieldnames="[&quot;charfield&quot;]">
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" readonly="__comp__.props.readonly"/>
</t>

View file

@ -353,6 +353,16 @@ test(`button box rendering on big screen`, async () => {
}
});
test(`button box rendering invisible`, async () => {
await mountView({
resModel: "partner",
type: "form",
arch: `<form><div name="button_box" invisible="1"><button id="btn1">MyButton</button></div></form>`,
resId: 2,
});
expect(`.o_control_panel .o_control_panel_actions`).toHaveInnerHTML("");
});
test(`form view gets size class on small and big screens`, async () => {
let uiSize = SIZES.MD;
const bus = new EventBus();
@ -6245,7 +6255,7 @@ test(`onchange returns an error`, async () => {
await contains(`.o_field_widget[name=int_field] input`).edit("64");
expect.verifyErrors(["Some business message"]);
expect(`.modal`).toHaveCount(1);
await waitFor(`.modal`);
expect(`.modal-body`).toHaveText(/Some business message/);
expect(`.o_field_widget[name="int_field"] input`).toHaveValue("9");
@ -9112,6 +9122,7 @@ test(`form view is not broken if save operation fails with redirect warning`, as
test.tags("desktop");
test("Redirect Warning full feature: additional context, action_id, leaving while dirty", async function () {
expect.errors(1);
defineActions([
{
id: 1,
@ -12785,6 +12796,33 @@ test(`cog menu action is executed with up to date context`, async () => {
expect.verifySteps(["doAction y", "doAction z"]);
});
test("CogMenu receives the model in env", async () => {
class CogItem extends Component {
static props = ["*"];
static template = xml`<button class="test-cog" t-on-click="onClick">Test</button>`;
onClick() {
expect.step([`cog clicked`, this.env.model.root.resModel, this.env.model.root.resId]);
}
}
registry.category("cogMenu").add("test-cog", {
Component: CogItem,
isDisplayed: (env) => {
expect.step([`cog displayed`, env.model.root.resModel, env.model.root.resId]);
return true;
},
});
await mountView({
resModel: "partner",
type: "form",
resId: 5,
arch: `<form><field name="display_name"/></form>`,
});
expect.verifySteps([["cog displayed", "partner", 5]]);
await contains(".o_cp_action_menus button").click();
await contains("button.test-cog").click();
expect.verifySteps([["cog clicked", "partner", 5]]);
});
test.tags("mobile");
test(`preserve current scroll position on form view while closing dialog`, async () => {
Partner._views = {

View file

@ -1256,6 +1256,27 @@ test("no content helper after update", async () => {
expect(".abc").toHaveCount(0);
});
test("display the provided no content helper when search has no matching data", async () => {
Foo._records = [];
await mountView({
type: "graph",
resModel: "foo",
noContentHelp: /* xml */ `
<p class="abc">This helper should be displayed</p>
`,
});
expect(".o_graph_canvas_container canvas").toHaveCount(1);
expect(".o_view_nocontent").toHaveCount(0);
await toggleSearchBarMenu();
await toggleMenuItem("color");
expect(".o_graph_canvas_container canvas").toHaveCount(0);
expect(".o_nocontent_help:contains(This helper should be displayed)").toHaveCount(1);
});
test("can reload with other group by", async () => {
const view = await mountView({
type: "graph",
@ -1850,6 +1871,7 @@ test("clicking on bar charts triggers a do_action", async () => {
domain: [["bar", "=", false]],
name: "Foo Analysis",
res_model: "foo",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
views: [
@ -1864,6 +1886,7 @@ test("clicking on bar charts triggers a do_action", async () => {
const view = await mountView({
type: "graph",
resModel: "foo",
searchViewId: 67,
arch: /* xml */ `
<graph string="Foo Analysis">
<field name="bar" />
@ -1889,6 +1912,7 @@ test("middle click on bar charts triggers a do_action", async () => {
domain: [["bar", "=", false]],
name: "Foo Analysis",
res_model: "foo",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
views: [
@ -1903,6 +1927,7 @@ test("middle click on bar charts triggers a do_action", async () => {
const view = await mountView({
type: "graph",
resModel: "foo",
searchViewId: 67,
arch: /* xml */ `
<graph string="Foo Analysis">
<field name="bar" />
@ -1928,6 +1953,7 @@ test("Clicking on bar charts removes group_by and search_default_* context keys"
domain: [["bar", "=", false]],
name: "Foo Analysis",
res_model: "foo",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
views: [
@ -1942,6 +1968,7 @@ test("Clicking on bar charts removes group_by and search_default_* context keys"
const view = await mountView({
type: "graph",
resModel: "foo",
searchViewId: 67,
arch: /* xml */ `
<graph string="Foo Analysis">
<field name="bar" />
@ -1969,6 +1996,7 @@ test("clicking on a pie chart trigger a do_action with correct views", async ()
domain: [["bar", "=", false]],
name: "Foo Analysis",
res_model: "foo",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
views: [
@ -1983,6 +2011,7 @@ test("clicking on a pie chart trigger a do_action with correct views", async ()
const view = await mountView({
type: "graph",
resModel: "foo",
searchViewId: 67,
arch: /* xml */ `
<graph string="Foo Analysis" type="pie">
<field name="bar" />
@ -2017,6 +2046,7 @@ test("middle click on a pie chart trigger a do_action with correct views", async
domain: [["bar", "=", false]],
name: "Foo Analysis",
res_model: "foo",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
views: [
@ -2031,6 +2061,7 @@ test("middle click on a pie chart trigger a do_action with correct views", async
const view = await mountView({
type: "graph",
resModel: "foo",
searchViewId: 67,
arch: /* xml */ `
<graph string="Foo Analysis" type="pie">
<field name="bar" />
@ -2111,6 +2142,24 @@ test("graph view with invisible attribute on field", async () => {
expect(".o_menu_item:contains(Revenue)").toHaveCount(0);
});
test("graph view reserved word", async () => {
// Check that the use of reserved words does not interfere with the view.
Product._records.push({ id: 150, name: "constructor" });
Foo._records.at(-1).product_id = 150;
const view = await mountView({
type: "graph",
resModel: "foo",
arch: /* xml */ `
<graph order="DESC">
<field name="product_id" />
</graph>
`,
});
checkLabels(view, ["xphone", "xpad", "constructor"]);
checkDatasets(view, ["data", "label"], [{ data: [4, 3, 1], label: "Count" }]);
});
test("graph view sort by measure", async () => {
// change last record from foo as there are 4 records count for each product
Product._records.push({ id: 150, name: "zphone" });
@ -2292,8 +2341,6 @@ test("empty graph view with sample data", async () => {
expect(".o_graph_view .o_content").toHaveClass("o_view_sample_data");
expect(".o_view_nocontent").toHaveCount(1);
expect(".ribbon").toHaveCount(1);
expect(".ribbon").toHaveText("SAMPLE DATA");
expect(".o_graph_canvas_container canvas").toHaveCount(1);
await toggleSearchBarMenu();
@ -2301,7 +2348,6 @@ test("empty graph view with sample data", async () => {
expect(".o_graph_view .o_content").not.toHaveClass("o_view_sample_data");
expect(".o_view_nocontent").toHaveCount(0);
expect(".ribbon").toHaveCount(0);
expect(".o_graph_canvas_container canvas").toHaveCount(1);
});
@ -2326,7 +2372,6 @@ test("non empty graph view with sample data", async () => {
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_view_nocontent").toHaveCount(0);
expect(".o_graph_canvas_container canvas").toHaveCount(1);
expect(".ribbon").toHaveCount(0);
await toggleSearchBarMenu();
await toggleMenuItem("False Domain");
@ -2334,7 +2379,6 @@ test("non empty graph view with sample data", async () => {
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_graph_canvas_container canvas").toHaveCount(0);
expect(".o_view_nocontent").toHaveCount(1);
expect(".ribbon").toHaveCount(0);
});
test("empty graph view without sample data after filter", async () => {

View file

@ -70,8 +70,10 @@ import {
patchWithCleanup,
quickCreateKanbanColumn,
quickCreateKanbanRecord,
removeFacet,
serverState,
stepAllNetworkCalls,
switchView,
toggleKanbanColumnActions,
toggleKanbanRecordDropdown,
toggleMenuItem,
@ -88,6 +90,7 @@ import { FileInput } from "@web/core/file_input/file_input";
import { browser } from "@web/core/browser/browser";
import { currencies } from "@web/core/currency";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { SampleServer } from "@web/model/sample_server";
import { KanbanCompiler } from "@web/views/kanban/kanban_compiler";
@ -5806,7 +5809,6 @@ test("delete an empty column, then a column with records.", async () => {
__extra_domain: [["product_id", "=", 7]],
product_id: [7, "empty group"],
__count: 0,
__fold: false,
__records: [],
});
result.length = 3;
@ -6204,7 +6206,6 @@ test("count of folded groups in empty kanban with sample data", async () => {
product_id: [2, "In Progress"],
__count: 0,
__extra_domain: [],
__fold: true,
},
],
length: 2,
@ -7019,8 +7020,6 @@ test("empty kanban with sample data", async () => {
message: "there should be 10 sample records",
});
expect(".o_view_nocontent").toHaveCount(1);
expect(".ribbon").toHaveCount(1);
expect(".ribbon").toHaveText("SAMPLE DATA");
await toggleSearchBarMenu();
await toggleMenuItem("Match nothing");
@ -7028,7 +7027,6 @@ test("empty kanban with sample data", async () => {
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(0);
expect(".o_view_nocontent").toHaveCount(1);
expect(".ribbon").toHaveCount(0);
});
test("empty grouped kanban with sample data and many2many_tags", async () => {
@ -7148,14 +7146,12 @@ test("non empty kanban with sample data", async () => {
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4);
expect(".o_view_nocontent").toHaveCount(0);
expect(".ribbon").toHaveCount(0);
await toggleSearchBarMenu();
await toggleMenuItem("Match nothing");
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(0);
expect(".ribbon").toHaveCount(0);
});
test("empty grouped kanban with sample data: add a column", async () => {
@ -7398,7 +7394,7 @@ test("kanban with sample data grouped by m2o and existing groups", async () => {
__extra_domain: [["product_id", "=", "3"]],
},
],
length: 2,
length: 1,
}));
await mountView({
@ -7422,6 +7418,43 @@ test("kanban with sample data grouped by m2o and existing groups", async () => {
expect(".o_kanban_record").toHaveText("hello");
});
test(`kanban grouped by m2o with sample data with more than 5 real groups`, async () => {
Partner._records = [];
onRpc("web_read_group", () => ({
// simulate 6, empty, real groups
groups: [1, 2, 3, 4, 5, 6].map((id) => ({
__count: 0,
__records: [],
product_id: [id, `Value ${id}`],
__extra_domain: [["product_id", "=", id]],
})),
length: 6,
}));
await mountView({
resModel: "partner",
type: "kanban",
arch: `
<kanban sample="1">
<templates>
<div t-name="card">
<field name="product_id"/>
</div>
</templates>
</kanban>`,
groupBy: ["product_id"],
});
expect(".o_content").toHaveClass("o_view_sample_data");
expect(queryAllTexts(`.o_kanban_group .o_column_title`)).toEqual([
"Value 1",
"Value 2",
"Value 3",
"Value 4",
"Value 5",
"Value 6",
]);
});
test.tags("desktop");
test("bounce create button when no data and click on empty area", async () => {
await mountView({
@ -13714,6 +13747,35 @@ test("selection can be enabled by pressing 'space' key", async () => {
expect(".o_record_selected").toHaveCount(4);
});
test.tags("desktop");
test("selection can be enabled by pressing 'shift + space' key", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
});
expect(".o_selection_box").toHaveCount(0);
await press("ArrowDown");
await keyDown("Shift");
await press("Space");
await animationFrame();
expect(".o_record_selected").toHaveCount(1);
await keyUp("Shift");
await press("ArrowDown");
await press("ArrowDown");
await keyDown("Shift");
await press("Space");
await animationFrame();
expect(".o_record_selected").toHaveCount(3);
});
test.tags("desktop");
test("drag and drop records and quickly open a record", async () => {
Partner._views.kanban = /* xml */ `
@ -13819,9 +13881,9 @@ test("groups will be scrolled to on unfold if outside of viewport", async () =>
"the next group (which is folded) should stick to the right of the screen after the scroll",
});
expect(".o_column_folded:eq(0)").toHaveText("column 7\n(1)");
await contains('.o_kanban_group:contains("column 7\n(1)")').click();
await contains('.o_kanban_group:contains("column 7 (1)")').click();
expect(".o_content").toHaveProperty("scrollLeft", 2154);
({ x, width } = queryRect('.o_kanban_group:contains("column 7\n(1)")'));
({ x, width } = queryRect('.o_kanban_group:contains("column 7 (1)")'));
// TODO JUM: change digits option
expect(x + width).toBeCloseTo(window.innerWidth, {
digits: 0,
@ -13833,7 +13895,7 @@ test("groups will be scrolled to on unfold if outside of viewport", async () =>
expect(".o_content").toHaveProperty("scrollLeft", 3302);
await contains(".o_kanban_group:last").click();
expect(".o_content").toHaveProperty("scrollLeft", 3562);
({ x, width } = queryRect('.o_kanban_group:contains("column 11\n(1)")'));
({ x, width } = queryRect('.o_kanban_group:contains("column 11 (1)")'));
// TODO JUM: change digits option
expect(x + width).toBeCloseTo(window.innerWidth, {
digits: 0,
@ -14618,3 +14680,346 @@ test("Cache: unfolded is now folded", async () => {
expect(getKanbanColumn(1)).toHaveClass("o_column_folded");
expect(queryText(getKanbanColumn(1))).toBe("xmo\n(2)");
});
test.tags("desktop");
test("Cache: kanban view progressbar, filter, open a record, edit, come back", async () => {
// This test encodes a very specify scenario involving a kanban with progressbar, where the
// filter was lost when coming back due to the cache callback, which removed the groups
// information.
Product._records[1].fold = false;
let def;
onRpc("web_read_group", () => def);
Partner._views = {
"kanban,false": `
<kanban default_group_by="product_id" on_create="quick_create" quick_create_view="some_view_ref">
<progressbar field="foo" colors='{"yop": "success", "gnap": "warning", "blip": "danger"}'/>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
"form,false": `<form><field name="product_id" widget="statusbar" options="{'clickable': true}"/></form>`,
"search,false": `<search/>`,
};
defineActions([
{
id: 1,
name: "Partners Action",
res_model: "partner",
views: [
[false, "kanban"],
[false, "form"],
],
search_view_id: [false, "search"],
},
]);
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
expect(".o_kanban_group").toHaveCount(2);
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(2);
// Filter the first column with the progressbar
await contains(".o_column_progress .progress-bar", { root: getKanbanColumn(0) }).click();
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
// Open a record, then go back, s.t. we populate the cache with the current params of the kanban
await contains(".o_kanban_group:eq(1) .o_kanban_record").click();
expect(".o_form_view").toHaveCount(1);
await contains(".o_back_button").click();
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
// Open again and make a change which will have an impact on the kanban, then go back
await contains(".o_kanban_group:eq(1) .o_kanban_record").click();
expect(".o_form_view").toHaveCount(1);
await contains(".o_field_widget[name=product_id] button[data-value='3']").click();
// Slow down the rpc s.t. we first use data from the cache, and then we update
def = new Deferred();
await contains(".o_back_button").click();
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
// Resolve the promise
def.resolve();
await animationFrame();
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
// Open a last time and come back => the filter should still be applied correctly
await contains(".o_kanban_group:eq(1) .o_kanban_record").click();
await contains(".o_back_button").click();
expect(".o_kanban_group:eq(0) .o_kanban_record").toHaveCount(1);
});
test.tags("desktop");
test("scroll position is restored when coming back to kanban view", async () => {
Partner._views = {
kanban: `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="foo"/></list>`,
search: `<search />`,
};
for (let i = 1; i < 10; i++) {
Product._records.push({ id: 100 + i, name: `Product ${i}` });
for (let j = 1; j < 20; j++) {
Partner._records.push({
id: 100 * i + j,
product_id: 100 + i,
foo: `Record ${i}/${j}`,
});
}
}
let def;
onRpc("web_read_group", () => def);
await resize({ width: 800, height: 300 });
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "kanban"],
[false, "list"],
],
context: {
group_by: ["product_id"],
},
});
expect(".o_kanban_view").toHaveCount(1);
// simulate scrolls in the kanban view
queryOne(".o_content").scrollTop = 100;
queryOne(".o_content").scrollLeft = 400;
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
// the kanban is "lazy", so it displays the control panel directly, and the renderer later with
// the data => simulate this and check that the scroll position is correctly restored
def = new Deferred();
await getService("action").switchView("kanban");
expect(".o_kanban_view").toHaveCount(1);
expect(".o_kanban_renderer").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_kanban_renderer").toHaveCount(1);
expect(".o_content").toHaveProperty("scrollTop", 100);
expect(".o_content").toHaveProperty("scrollLeft", 400);
});
test.tags("mobile");
test("scroll position is restored when coming back to kanban view (mobile)", async () => {
Partner._views = {
kanban: `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="foo"/></list>`,
search: `<search />`,
};
for (let i = 1; i < 20; i++) {
Partner._records.push({
id: 100 + i,
foo: `Record ${i}`,
});
}
let def;
onRpc("web_search_read", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "kanban"],
[false, "list"],
],
});
expect(".o_kanban_view").toHaveCount(1);
// simulate a scroll in the kanban view
queryOne(".o_kanban_view").scrollTop = 100;
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
// the kanban is "lazy", so it displays the control panel directly, and the renderer later with
// the data => simulate this and check that the scroll position is correctly restored
def = new Deferred();
await getService("action").switchView("kanban");
expect(".o_kanban_view").toHaveCount(1);
expect(".o_kanban_renderer").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_kanban_renderer").toHaveCount(1);
expect(".o_kanban_view").toHaveProperty("scrollTop", 100);
});
test.tags("mobile");
test("scroll position is restored when coming back to kanban view (grouped, mobile)", async () => {
Partner._views = {
kanban: `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="foo"/></list>`,
search: `<search />`,
};
Partner._records = [];
for (let i = 1; i < 5; i++) {
Product._records.push({ id: 100 + i, name: `Product ${i}` });
for (let j = 1; j < 20; j++) {
Partner._records.push({
id: 100 * i + j,
product_id: 100 + i,
foo: `Record ${i}/${j}`,
});
}
}
let def;
onRpc("web_read_group", () => def);
await resize({ width: 375, height: 667 }); // iphone se
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "kanban"],
[false, "list"],
],
context: {
group_by: ["product_id"],
},
});
expect(".o_kanban_view").toHaveCount(1);
// simulate scrolls in the kanban view
queryOne(".o_kanban_renderer").scrollLeft = 656; // scroll to the third column
queryAll(".o_kanban_group")[2].scrollTop = 200;
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
// the kanban is "lazy", so it displays the control panel directly, and the renderer later with
// the data => simulate this and check that the scroll position is correctly restored
def = new Deferred();
await getService("action").switchView("kanban");
expect(".o_kanban_view").toHaveCount(1);
expect(".o_kanban_renderer").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_kanban_renderer").toHaveCount(1);
expect(".o_kanban_group:eq(2)").toHaveProperty("scrollTop", 200);
expect(".o_kanban_renderer").toHaveProperty("scrollLeft", 656);
});
test.tags("desktop");
test("limit is reset when restoring a view after ungrouping", async () => {
Partner._views["kanban"] = `
<kanban sample="1">
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`;
Partner._views["list"] = '<list><field name="foo"/></list>';
Partner._views.search = `
<search>
<group>
<filter name="foo" string="Foo" context="{'group_by': 'foo'}"/>
</group>
</search>
`;
onRpc("partner", "web_search_read", ({ kwargs }) => {
const { domain, limit } = kwargs;
if (!domain.length) {
expect.step(`limit=${limit}`);
}
});
patchWithCleanup(user, {
hasGroup: () => true,
});
await mountWithCleanup(WebClient);
await getService("action").doAction({
type: "ir.actions.act_window",
id: 450,
xml_id: "action_450",
name: "Partners",
res_model: "partner",
views: [
[false, "kanban"],
[false, "list"],
[false, "form"],
],
context: { search_default_foo: true },
});
await switchView("list");
await removeFacet("Foo");
expect.verifySteps(["limit=80"]);
await switchView("kanban");
expect.verifySteps(["limit=40"]);
});
test.tags("desktop");
test("add o-navigable to buttons with dropdown-item class and view buttons", async () => {
Partner._records.splice(1, 3); // keep one record only
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="menu">
<a role="menuitem" class="dropdown-item">Item</a>
<a role="menuitem" type="set_cover" class="dropdown-item">Item</a>
<a role="menuitem" type="object" class="dropdown-item">Item</a>
</t>
<t t-name="card">
<div/>
</t>
</templates>
</kanban>`,
});
expect(".o-dropdown--menu").toHaveCount(0);
await toggleKanbanRecordDropdown();
expect(".o-dropdown--menu .dropdown-item.o-navigable").toHaveCount(3);
expect(".o-dropdown--menu .dropdown-item.o-navigable.focus").toHaveCount(0);
// Check that navigation is working
await hover(".o-dropdown--menu .dropdown-item.o-navigable");
expect(".o-dropdown--menu .dropdown-item.o-navigable.focus").toHaveCount(1);
await press("arrowdown");
expect(".o-dropdown--menu .dropdown-item.o-navigable:nth-child(2)").toHaveClass("focus");
await press("arrowdown");
expect(".o-dropdown--menu .dropdown-item.o-navigable:nth-child(3)").toHaveClass("focus");
});

View file

@ -1,12 +1,17 @@
import { expect, getFixture, test } from "@odoo/hoot";
import {
animationFrame,
clear,
click,
Deferred,
edit,
expect,
getFixture,
hover,
keyDown,
keyUp,
middleClick,
mockDate,
mockTimeZone,
pointerDown,
pointerUp,
press,
@ -17,17 +22,12 @@ import {
queryOne,
queryRect,
queryText,
runAllTimers,
test,
tick,
unload,
waitFor,
} from "@odoo/hoot-dom";
import {
animationFrame,
Deferred,
mockDate,
mockTimeZone,
runAllTimers,
tick,
} from "@odoo/hoot-mock";
} from "@odoo/hoot";
import { Component, markup, onRendered, onWillStart, useRef, xml } from "@odoo/owl";
import { getPickerCell } from "@web/../tests/core/datetime/datetime_test_helpers";
import {
@ -1906,7 +1906,7 @@ test(`basic grouped list rendering with widget="handle" col`, async () => {
expect(`thead th[data-name=int_field]`).toHaveCount(1);
expect(`tr.o_group_header`).toHaveCount(2);
expect(`th.o_group_name`).toHaveCount(2);
expect(`.o_group_header:eq(0) th`).toHaveCount(3); // group name + colspan 2 + cog placeholder
expect(`.o_group_header:eq(0) th`).toHaveCount(2); // group name + cog placeholder
expect(`.o_group_header:eq(0) .o_list_number`).toHaveCount(0);
});
@ -1938,6 +1938,18 @@ test(`basic grouped list rendering with a date field between two fields with a a
expect(queryAllTexts(`.o_group_header:eq(0) td`)).toEqual(["-4", "", "-4"]);
});
test(`basic grouped list rendering 1 col without selector and with optional field`, async () => {
await mountView({
resModel: "foo",
type: "list",
arch: `<list><field name="foo"/><field name="bar" optional="hidden"/></list>`,
groupBy: ["bar"],
allowSelectors: false,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "1");
});
test(`basic grouped list rendering 1 col without selector`, async () => {
await mountView({
resModel: "foo",
@ -1982,8 +1994,8 @@ test(`basic grouped list rendering 2 cols without selector`, async () => {
groupBy: ["bar"],
allowSelectors: false,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(3);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "1");
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "2");
});
test(`basic grouped list rendering 3 cols without selector`, async () => {
@ -1994,8 +2006,27 @@ test(`basic grouped list rendering 3 cols without selector`, async () => {
groupBy: ["bar"],
allowSelectors: false,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(3);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "2");
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "3");
});
test(`basic grouped list rendering 3 cols without selector and with optional fields`, async () => {
await mountView({
resModel: "foo",
type: "list",
arch: `
<list>
<field name="foo"/>
<field name="bar"/>
<field name="text"/>
<field name="date" optional="hidden"/>
</list>
`,
groupBy: ["bar"],
allowSelectors: false,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "3");
});
test.tags("desktop");
@ -2007,8 +2038,8 @@ test(`basic grouped list rendering 2 col with selector on desktop`, async () =>
groupBy: ["bar"],
allowSelectors: true,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(3);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "2");
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "3");
});
test.tags("mobile");
@ -2020,8 +2051,8 @@ test(`basic grouped list rendering 2 col with selector on mobile`, async () => {
groupBy: ["bar"],
allowSelectors: true,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(3);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "1");
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "2");
});
test.tags("desktop");
@ -2034,8 +2065,8 @@ test(`basic grouped list rendering 3 cols with selector on desktop`, async () =>
allowSelectors: true,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(3);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "3");
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "4");
});
test.tags("mobile");
@ -2048,8 +2079,8 @@ test(`basic grouped list rendering 3 cols with selector on mobile`, async () =>
allowSelectors: true,
});
expect(`.o_group_header:eq(0) th`).toHaveCount(3);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "2");
expect(`.o_group_header:eq(0) th`).toHaveCount(2);
expect(`.o_group_header th:eq(0)`).toHaveAttribute("colspan", "3");
});
test.tags("desktop");
@ -4867,6 +4898,22 @@ test(`aggregates are formatted according to field widget`, async () => {
});
});
test(`aggregates of monetary widget with no currency data in grouped list`, async () => {
await mountView({
resModel: "foo",
type: "list",
groupBy: ["bar"],
arch: `
<list>
<field name="qux" widget="monetary" options="{'currency_field': 'currency_id'}" sum="Sum"/>
<field name="currency_id" column_invisible="True"/>
</list>`,
});
expect(`tfoot`).toHaveText("19.40", {
message: "aggregates monetary should still be displayed without currency",
});
});
test(`aggregates of monetary field with no currency field`, async () => {
await mountView({
resModel: "foo",
@ -4971,6 +5018,54 @@ test(`aggregates monetary (currency field in view)`, async () => {
expect(`tfoot`).toHaveText("$ 2,000.00");
});
test(`aggregates monetary (currency field not set)`, async () => {
Foo._fields.amount = fields.Monetary({ currency_field: "currency_test" });
Foo._fields.currency_test = fields.Many2one({ relation: "res.currency" });
Foo._records[0].currency_test = 1;
await mountView({
resModel: "foo",
type: "list",
arch: `
<list>
<field name="amount" widget="monetary" sum="Sum"/>
<field name="currency_test"/>
</list>
`,
});
expect(queryAllTexts(`tbody .o_monetary_cell`)).toEqual([
"$ 1,200.00",
"500.00",
"300.00",
"0.00",
]);
expect(`tfoot`).toHaveText("$ 0.00?");
});
test(`aggregates monetary (currency field not set on first record)`, async () => {
Foo._fields.amount = fields.Monetary({ currency_field: "currency_test" });
Foo._fields.currency_test = fields.Many2one({ relation: "res.currency" });
Foo._records[1].currency_test = 1;
await mountView({
resModel: "foo",
type: "list",
arch: `
<list>
<field name="amount" widget="monetary" sum="Sum"/>
<field name="currency_test"/>
</list>
`,
});
expect(queryAllTexts(`tbody .o_monetary_cell`)).toEqual([
"1,200.00",
"$ 500.00",
"300.00",
"0.00",
]);
expect(`tfoot`).toHaveText("$ 0.00?");
});
test(`aggregates monetary with custom digits (same currency)`, async () => {
Foo._records = Foo._records.map((record) => ({
...record,
@ -7224,8 +7319,6 @@ test(`empty list with sample data`, async () => {
expect(`.o_list_table`).toHaveCount(1);
expect(`.o_data_row`).toHaveCount(10);
expect(`.o_nocontent_help`).toHaveCount(1);
expect(".ribbon").toHaveCount(1);
expect(".ribbon").toHaveText("SAMPLE DATA");
// Check list sample data
expect(`.o_data_row .o_data_cell:eq(0)`).toHaveText("", {
@ -7254,7 +7347,6 @@ test(`empty list with sample data`, async () => {
expect(`.o_list_view .o_content`).not.toHaveClass("o_view_sample_data");
expect(`.o_list_table`).toHaveCount(1);
expect(`.o_nocontent_help`).toHaveCount(1);
expect(".ribbon").toHaveCount(0);
await toggleMenuItem("False Domain");
await toggleMenuItem("True Domain");
@ -7262,7 +7354,6 @@ test(`empty list with sample data`, async () => {
expect(`.o_list_table`).toHaveCount(1);
expect(`.o_data_row`).toHaveCount(4);
expect(`.o_nocontent_help`).toHaveCount(0);
expect(".ribbon").toHaveCount(0);
});
test(`refresh empty list with sample data`, async () => {
@ -7413,7 +7504,6 @@ test(`non empty list with sample data`, async () => {
expect(`.o_list_table`).toHaveCount(1);
expect(`.o_data_row`).toHaveCount(4);
expect(`.o_list_view .o_content`).not.toHaveClass("o_view_sample_data");
expect(".ribbon").toHaveCount(0);
await toggleSearchBarMenu();
await toggleMenuItem("true_domain");
@ -7421,7 +7511,6 @@ test(`non empty list with sample data`, async () => {
expect(`.o_list_table`).toHaveCount(1);
expect(`.o_data_row`).toHaveCount(0);
expect(`.o_list_view .o_content`).not.toHaveClass("o_view_sample_data");
expect(".ribbon").toHaveCount(0);
});
test(`click on header in empty list with sample data`, async () => {
@ -7448,6 +7537,38 @@ test(`click on header in empty list with sample data`, async () => {
});
});
test(`list grouped by m2o with sample data with more than 5 real groups`, async () => {
Foo._records = [];
onRpc("web_read_group", () => ({
// simulate 6, empty, real groups
groups: [1, 2, 3, 4, 5, 6].map((id) => ({
__count: 0,
__records: [],
m2o: [id, `Value ${id}`],
__extra_domain: [["m2o", "=", id]],
})),
length: 6,
}));
await mountView({
resModel: "foo",
type: "list",
arch: `<list sample="1"><field name="foo"/></list>`,
groupBy: ["m2o"],
});
expect(`.o_list_view .o_content`).toHaveClass("o_view_sample_data");
expect(`.o_list_table`).toHaveCount(1);
expect(`.o_group_header`).toHaveCount(6);
expect(queryAllTexts(`.o_group_header`)).toEqual([
"Value 1 (3)",
"Value 2 (3)",
"Value 3 (3)",
"Value 4 (3)",
"Value 5 (2)",
"Value 6 (2)",
]);
});
test.tags("desktop");
test(`non empty editable list with sample data: delete all records`, async () => {
await mountView({
@ -7870,6 +7991,40 @@ test(`groupby node with edit button`, async () => {
expect.verifySteps(["doAction"]);
});
test(`edit button does not trigger fold group`, async () => {
mockService("action", {
doAction(action) {
expect.step("doAction");
expect(action).toEqual({
context: { create: false },
res_id: 1,
res_model: "res.currency",
type: "ir.actions.act_window",
views: [[false, "form"]],
});
},
});
await mountView({
resModel: "foo",
type: "list",
arch: `
<list>
<field name="foo"/>
<groupby name="currency_id">
<button name="edit" type="edit" icon="fa-edit" title="Edit"/>
</groupby>
</list>
`,
groupBy: ["currency_id"],
});
expect(`.o_group_open`).toHaveCount(0);
await contains(`.o_group_header:eq(0)`).click();
expect(`.o_group_open`).toHaveCount(1);
await contains(`.o_group_header .o_group_buttons button:eq(0)`).click();
expect(`.o_group_open`).toHaveCount(1);
expect.verifySteps(["doAction"]);
});
test(`groupby node with subfields, and onchange`, async () => {
Foo._onChanges = {
foo() {},
@ -7923,9 +8078,9 @@ test(`list view, editable, without data`, async () => {
type: "list",
arch: `
<list editable="top">
<field name="foo"/>
<field name="date"/>
<field name="m2o"/>
<field name="foo"/>
<button type="object" icon="fa-plus-square" name="method"/>
</list>
`,
@ -7947,7 +8102,7 @@ test(`list view, editable, without data`, async () => {
expect(`tbody tr:eq(0)`).toHaveClass("o_selected_row", {
message: "the date field td should be in edit mode",
});
expect(`tbody tr:eq(0) td:eq(1)`).toHaveText("Feb 10, 2017", {
expect(`tbody tr:eq(0) td:eq(2)`).toHaveText("Feb 10, 2017", {
message: "the date field td should have the default value",
});
expect(`tr.o_selected_row .o_list_record_selector input`).toHaveProperty("disabled", true, {
@ -8099,6 +8254,10 @@ test(`editable list view, should refocus date field`, async () => {
await contains(getPickerCell("15")).click();
expect(`.o_datetime_picker`).toHaveCount(0);
// the datetime field is rendered multiple times before `picker.activeInput`
// is reset, and so before the field displays a button instead of the input
await waitFor(`.o_field_widget[name=date] button`);
expect(`.o_field_widget[name=date] button`).toHaveValue("02/15/2017");
expect(`.o_field_widget[name=date] button`).toBeFocused();
});
@ -10881,7 +11040,7 @@ test(`multi edit field with daterange widget (edition without using the picker)`
await contains(
`.o_data_row .o_data_cell .o_field_daterange[name='date_start'] input[data-field='date_start']`
).edit("2016-04-01 11:00:00", { confirm: "enter" });
expect(`.modal`).toHaveCount(1, {
expect(await waitFor(".modal")).toHaveCount(1, {
message: "The confirm dialog should appear to confirm the multi edition.",
});
expect(queryAllTexts(`.modal-body .o_modal_changes td`)).toEqual([
@ -10934,6 +11093,48 @@ test(`list daterange with empty start date and end date`, async () => {
]);
});
test(`list daterange in form: open/close picker`, async () => {
Foo._fields.foo_o2m = fields.One2many({ relation: "foo" });
Foo._fields.date_end = fields.Date();
await mountView({
resModel: "foo",
type: "form",
arch: `
<form>
<sheet>
<field name="foo_o2m">
<list editable="bottom">
<field name="date" widget="daterange" options="{'end_date_field': 'date_end', 'always_range': '1'}"/>
</list>
</field>
</sheet>
</form>
`,
resId: 1,
});
await contains(`.o_field_x2many_list_row_add a`).click();
await contains(".o_field_daterange[name=date]").click();
await animationFrame();
await animationFrame();
expect(".o_datetime_picker").toBeDisplayed();
expect("input[data-field=date]").toBeFocused();
await contains(getPickerCell("15")).click();
await contains(getPickerCell("20")).click();
// Close picker
await pointerDown(`.o_view_controller`);
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
// Wait to check if the picker is still closed
await animationFrame();
await animationFrame();
expect(".o_datetime_picker").toHaveCount(0);
});
test.tags("desktop");
test(`editable list view: contexts are correctly sent`, async () => {
serverState.userContext = { someKey: "some value" };
@ -12637,6 +12838,47 @@ test(`grouped list view move to previous page of group when all records from las
expect(`.o_data_row`).toHaveCount(2);
});
test.tags("desktop");
test(`grouped list view move to previous page of group when all records from last page deleted with more pages`, async () => {
Foo._records.push({ id: 6, foo: "foo", m2o: 1 });
Foo._records.push({ id: 7, foo: "foo", m2o: 1 });
onRpc("web_search_read", ({ kwargs }) => {
expect.step(`web_search_read ${kwargs.limit} - ${kwargs.offset}`);
});
await mountView({
resModel: "foo",
type: "list",
arch: `<list limit="2"><field name="display_name"/></list>`,
actionMenus: {},
groupBy: ["m2o"],
});
expect(`th:contains(Value 1 (5))`).toHaveCount(1, {
message: "Value 1 should contain 3 records",
});
expect(`th:contains(Value 2 (1))`).toHaveCount(1, {
message: "Value 2 should contain 1 record",
});
await contains(`.o_group_header:eq(0)`).click();
expect(getPagerValue(queryFirst(`.o_group_header`))).toEqual([1, 2]);
expect(getPagerLimit(queryFirst(`.o_group_header`))).toBe(5);
expect.verifySteps(["web_search_read 2 - 0"]);
// move to next page
await pagerNext(queryFirst(`.o_group_header`));
await pagerNext(queryFirst(`.o_group_header`));
expect(getPagerValue(queryFirst(`.o_group_header`))).toEqual([5, 5]);
expect(getPagerLimit(queryFirst(`.o_group_header`))).toBe(5);
expect.verifySteps(["web_search_read 2 - 2", "web_search_read 2 - 4"]);
// delete a record
await contains(`.o_data_row .o_list_record_selector input`).click();
await contains(`.o_cp_action_menus .dropdown-toggle`).click();
await contains(`.dropdown-item:contains(Delete)`).click();
await contains(`.modal .btn-primary`).click();
expect(`th.o_group_name:eq(0) .o_pager_counter`).toHaveCount(1);
expect(`.o_data_row`).toHaveCount(2);
});
test.tags("desktop");
test(`grouped list view move to next page when all records from the current page deleted`, async () => {
Foo._records = [1, 2, 3, 4, 5, 6]
@ -19199,3 +19441,92 @@ test(`multi edition: many2many_tags add few tags in one time`, async () => {
message: "should have display_name in badge",
});
});
test.tags("desktop");
test("multi_edit: must work for copy/paster or operation", async () => {
Foo._records[1].datetime = "1989-05-03 12:51:35";
Foo._records[2].datetime = "1987-11-13 12:12:34";
Foo._records[3].datetime = "2019-04-09 03:21:35";
await mountView({
resModel: "foo",
type: "list",
arch: `
<list multi_edit="1">
<field name="foo"/>
<field name="datetime"/>
</list>
`,
});
await contains(`.o_list_record_selector`).click();
await contains(`.o_data_cell[name=datetime]`).click();
await animationFrame();
await waitFor(`.o_datetime_picker`);
await contains(`input[data-field=datetime]`).edit("+125d", { confirm: "tab" });
expect(`tbody tr:eq(0) td[name=datetime]`).toHaveText("Jul 14, 11:30 AM");
await contains(`.modal button:contains(update)`).click();
expect(".modal").toHaveCount(0);
expect(queryAllTexts(`.o_data_cell`)).toEqual([
"yop",
"Jul 14, 11:30 AM",
"blip",
"Jul 14, 11:30 AM",
"gnap",
"Jul 14, 11:30 AM",
"blip",
"Jul 14, 11:30 AM",
]);
});
test.tags("mobile");
test("scroll position is restored when coming back to list view", async () => {
Foo._views = {
kanban: `
<kanban>
<templates>
<t t-name="card">
<field name="foo"/>
</t>
</templates>
</kanban>`,
list: `<list><field name="foo"/></list>`,
search: `<search />`,
};
for (let i = 1; i < 30; i++) {
Foo._records.push({ id: 100 + i, foo: `Record ${i}` });
}
let def;
onRpc("web_search_read", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "foo",
type: "ir.actions.act_window",
views: [
[false, "kanban"],
[false, "list"],
],
});
expect(".o_kanban_view").toHaveCount(1);
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
// simulate a scroll in the list view
queryOne(".o_list_view").scrollTop = 200;
await getService("action").switchView("kanban");
expect(".o_kanban_view").toHaveCount(1);
// the list is "lazy", so it displays the control panel directly, and the renderer later with
// the data => simulate this and check that the scroll position is correctly restored
def = new Deferred();
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
expect(".o_list_renderer").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_list_renderer").toHaveCount(1);
expect(".o_list_view").toHaveProperty("scrollTop", 200);
});

View file

@ -1,5 +1,5 @@
import { expect, test } from "@odoo/hoot";
import { queryAll, queryAllTexts, queryFirst, queryOne, queryText } from "@odoo/hoot-dom";
import { queryAll, queryAllTexts, queryFirst, queryOne, queryText, resize } from "@odoo/hoot-dom";
import { animationFrame, Deferred, mockDate } from "@odoo/hoot-mock";
import { markup } from "@odoo/owl";
import {
@ -520,6 +520,7 @@ test("clicking on a cell triggers a doAction", async () => {
domain: [["product_id", "=", 37]],
name: "Partners",
res_model: "partner",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
view_mode: "list",
@ -535,6 +536,7 @@ test("clicking on a cell triggers a doAction", async () => {
await mountView({
type: "pivot",
resModel: "partner",
searchViewId: 67,
arch: `
<pivot string="Partners">
<field name="product_id" type="row"/>
@ -2799,12 +2801,9 @@ test("empty pivot view with sample data", async () => {
expect(".o_pivot_view .o_content").toHaveClass("o_view_sample_data");
expect(".o_view_nocontent .abc").toHaveCount(1);
expect(".ribbon").toHaveCount(1);
expect(".ribbon").toHaveText("SAMPLE DATA");
await removeFacet();
expect(".o_pivot_view .o_content").not.toHaveClass("o_view_sample_data");
expect(".o_view_nocontent .abc").toHaveCount(0);
expect(".ribbon").toHaveCount(0);
expect("table").toHaveCount(1);
});
@ -2828,13 +2827,11 @@ test("non empty pivot view with sample data", async () => {
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_view_nocontent .abc").toHaveCount(0);
expect(".ribbon").toHaveCount(0);
expect("table").toHaveCount(1);
await toggleSearchBarMenu();
await toggleMenuItem("Small Than 0");
expect(".o_content").not.toHaveClass("o_view_sample_data");
expect(".o_view_nocontent .abc").toHaveCount(1);
expect(".ribbon").toHaveCount(0);
expect("table").toHaveCount(0);
});
@ -3865,6 +3862,7 @@ test("middle clicking on a cell triggers a doAction", async () => {
domain: [["product_id", "=", 37]],
name: "Partners",
res_model: "partner",
search_view_id: [67, "search"],
target: "current",
type: "ir.actions.act_window",
view_mode: "list",
@ -3883,6 +3881,7 @@ test("middle clicking on a cell triggers a doAction", async () => {
await mountView({
type: "pivot",
resModel: "partner",
searchViewId: 67,
arch: `
<pivot string="Partners">
<field name="product_id" type="row"/>
@ -3994,3 +3993,102 @@ test("pivot view with monetary with multiple currencies", async () => {
expect(".o_pivot table tbody tr:eq(1)").toHaveText("USD \n$ 1,000.00");
expect(".o_pivot table tbody tr:last").toHaveText("EUR \n400.00 €");
});
test.tags("desktop");
test("scroll position is restored when coming back to pivot view", async () => {
Partner._views = {
kanban: `
<pivot>
<field name="foo" type="row"/>
</pivot>`,
list: `<list><field name="foo"/></list>`,
search: `<search />`,
};
for (let i = 1; i < 20; i++) {
Partner._records.push({ id: 100 + i, foo: 100 + i });
}
let def;
onRpc("formatted_read_grouping_sets", () => def);
await resize({ width: 800, height: 300 });
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "pivot"],
[false, "list"],
],
context: {
group_by: ["foo"],
},
});
expect(".o_pivot_view").toHaveCount(1);
// simulate a scroll in the pivot view
queryOne(".o_content").scrollTop = 200;
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
// the pivot is "lazy", so it displays the control panel directly, and the renderer later with
// the data => simulate this and check that the scroll position is correctly restored
def = new Deferred();
await getService("action").switchView("pivot");
expect(".o_pivot_view").toHaveCount(1);
expect(".o_content .o_pivot").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_content .o_pivot").toHaveCount(1);
expect(".o_content").toHaveProperty("scrollTop", 200);
});
test.tags("mobile");
test("scroll position is restored when coming back to pivot view (mobile)", async () => {
Partner._views = {
kanban: `
<pivot>
<field name="foo" type="row"/>
</pivot>`,
list: `<list><field name="foo"/></list>`,
search: `<search />`,
};
for (let i = 1; i < 20; i++) {
Partner._records.push({ id: 100 + i, foo: 100 + i });
}
let def;
onRpc("formatted_read_grouping_sets", () => def);
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "pivot"],
[false, "list"],
],
context: {
group_by: ["foo"],
},
});
expect(".o_pivot_view").toHaveCount(1);
// simulate a scroll in the pivot view
queryOne(".o_pivot_view").scrollTop = 200;
await getService("action").switchView("list");
expect(".o_list_view").toHaveCount(1);
// the pivot is "lazy", so it displays the control panel directly, and the renderer later with
// the data => simulate this and check that the scroll position is correctly restored
def = new Deferred();
await getService("action").switchView("pivot");
expect(".o_pivot_view").toHaveCount(1);
expect(".o_content .o_pivot").toHaveCount(0);
def.resolve();
await animationFrame();
expect(".o_content .o_pivot").toHaveCount(1);
expect(".o_pivot_view").toHaveProperty("scrollTop", 200);
});

View file

@ -1,5 +1,4 @@
import { before } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { before, withFetch } from "@odoo/hoot";
import { loadBundle } from "@web/core/assets";
import * as _fields from "./_framework/mock_server/mock_fields";
import * as _models from "./_framework/mock_server/mock_model";
@ -44,6 +43,7 @@ export {
findComponent,
getDropdownMenu,
mountWithCleanup,
waitUntilIdle,
} from "./_framework/component_test_helpers";
export { contains, defineStyle, editAce, sortableDrag } from "./_framework/dom_test_helpers";
export {
@ -173,9 +173,7 @@ export function preloadBundle(bundleName, options) {
if (once) {
odoo.loader.preventGlobalDefine = true;
}
mockFetch(globalCachedFetch);
await loadBundle(bundleName);
mockFetch(null);
await withFetch(globalCachedFetch, () => loadBundle(bundleName));
if (once) {
odoo.loader.preventGlobalDefine = false;
}

View file

@ -116,7 +116,7 @@ test("can display client actions in Dialog and close the dialog", async () => {
expect(".modal .test_client_action").toHaveCount(1);
expect(".modal-title").toHaveText("Dialog Test");
await contains(".modal footer .btn.btn-primary").click();
await contains(".modal .btn-close").click();
expect(".modal .test_client_action").toHaveCount(0);
});
@ -498,3 +498,27 @@ test("test home client action", async () => {
await animationFrame();
expect.verifySteps(["/web/webclient/version_info", "assign /"]);
});
test("test display_exception client action", async () => {
expect.errors(1);
await mountWithCleanup(WebClient);
getService("action").doAction({
type: "ir.actions.client",
tag: "display_exception",
params: {
code: 0,
message: "Odoo Server Error",
data: {
name: `odoo.exceptions.UserError`,
debug: "traceback",
arguments: [],
context: {},
message: "This is an error",
},
},
});
await animationFrame();
expect(".o_dialog").toHaveCount(1);
expect("header .modal-title").toHaveText("Invalid Operation");
expect.verifyErrors([/RPC_ERROR/]);
});

View file

@ -15,9 +15,10 @@ import {
getKwArgs,
} from "@web/../tests/web_test_helpers";
import { mockTouch, runAllTimers } from "@odoo/hoot-mock";
import { animationFrame, mockTouch, runAllTimers } from "@odoo/hoot-mock";
import { browser } from "@web/core/browser/browser";
import { router } from "@web/core/browser/router";
import { router, routerBus } from "@web/core/browser/router";
import { rpcBus } from "@web/core/network/rpc";
import { user } from "@web/core/user";
import { WebClient } from "@web/webclient/webclient";
@ -147,7 +148,6 @@ class ResUsersSettings extends WebResUsersSettings {
} else {
ResUsersSettingsEmbeddedAction.write(embeddedSettings.id, vals);
}
return embeddedSettings;
}
}
@ -195,7 +195,7 @@ defineModels([
IrActionsAct_Window,
]);
defineActions([
const actions = [
{
id: 1,
xml_id: "action_1",
@ -264,10 +264,13 @@ defineActions([
parent_action_id: 4,
action_id: 4,
},
]);
];
defineActions(actions);
beforeEach(() => {
user.updateUserSettings("id", 1); // workaround to populate the user settings
user.updateUserSettings("embedded_actions_config_ids", {}); // workaround to populate the embedded user settings
});
test("can display embedded actions linked to the current action", async () => {
@ -311,6 +314,14 @@ test("can toggle visibility of embedded actions", async () => {
expect(".o_embedded_actions > button").toHaveCount(3, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
expect(user.settings.embedded_actions_config_ids).toEqual({
"1+": {
embedded_actions_order: [],
embedded_actions_visibility: [false, 102],
embedded_visibility: true,
res_model: "partner",
},
});
});
test("can click on a embedded action and execute the corresponding action (with xml_id)", async () => {
@ -441,6 +452,14 @@ test("a view coming from a embedded can be saved in the embedded actions", async
expect(".o_embedded_actions > button").toHaveCount(4, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
expect(user.settings.embedded_actions_config_ids).toEqual({
"1+": {
embedded_actions_order: [false, 102, 103, 4],
embedded_actions_visibility: [false, 102, 4],
embedded_visibility: true,
res_model: "partner",
},
});
});
test("a view coming from a embedded with python_method can be saved in the embedded actions", async () => {
@ -499,6 +518,14 @@ test("a view coming from a embedded with python_method can be saved in the embed
expect(".o_embedded_actions > button").toHaveCount(4, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
expect(user.settings.embedded_actions_config_ids).toEqual({
"1+": {
embedded_actions_order: [false, 102, 103, 4],
embedded_actions_visibility: [false, 103, 4],
embedded_visibility: true,
res_model: "partner",
},
});
});
test("the embedded actions should not be displayed when switching view", async () => {
@ -532,6 +559,14 @@ test("User can move the main (first) embedded action", async () => {
expect(".o_embedded_actions > button:nth-child(2) > span").toHaveText("Partners Action 1", {
message: "Main embedded action should've been moved to 2nd position",
});
expect(user.settings.embedded_actions_config_ids).toEqual({
"1+": {
embedded_actions_order: [102, false, 103],
embedded_actions_visibility: [false, 102],
embedded_visibility: true,
res_model: "partner",
},
});
});
test("User can unselect the main (first) embedded action", async () => {
@ -548,6 +583,14 @@ test("User can unselect the main (first) embedded action", async () => {
expect(dropdownItem).not.toHaveClass("selected", {
message: "Main embedded action should be unselected",
});
expect(user.settings.embedded_actions_config_ids).toEqual({
"1+": {
embedded_actions_order: [],
embedded_actions_visibility: [],
embedded_visibility: true,
res_model: "partner",
},
});
});
test("User should be redirected to the first embedded action set in user settings", async () => {
@ -636,3 +679,72 @@ test("custom embedded action loaded first", async () => {
message: "'Favorite Ponies' view should be loaded",
});
});
test("test get_embedded_actions_settings rpc args", async () => {
onRpc("res.users.settings", "get_embedded_actions_settings", ({ args, kwargs }) => {
expect(args.length).toBe(1, {
message: "Should have one positional argument, which is the id of the user setting.",
});
expect(args[0]).toBe(1, { message: "The id of the user setting should be 1." });
expect(kwargs.context.res_id).toBe(5, {
message: "The context should contain the res_id passed to the action.",
});
expect(kwargs.context.res_model).toBe("partner", {
message: "The context should contain the res_model passed to the action.",
});
expect.step("get_embedded_actions_settings");
});
await mountWithCleanup(WebClient);
await getService("action").doAction(1, {
additionalContext: { active_id: 5 },
});
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
expect.verifySteps(["get_embedded_actions_settings"]);
});
test("an action containing embedded actions should reload if the page is refreshed", async () => {
onRpc("create", ({ args }) => {
const values = args[0][0];
expect(values.name).toBe("Custom Partners Action 1");
expect(values.action_id).toBe(1);
// Add the created embedded action to the actions list so that the mock server knows it when reloading (/web/action/load)
defineActions([
...actions,
{
id: 4,
name: "Custom Partners Action 1",
parent_res_model: values.parent_res_model,
type: "ir.embedded.actions",
parent_action_id: 1,
action_id: values.action_id,
},
]);
return [4, values.name]; // Fake new embedded action id
});
onRpc(
"create_filter",
() => [5] // Fake new filter id
);
await mountWithCleanup(WebClient);
await getService("action").doAction(1);
// First, we create a new (custom) embedded action based on the current one
await contains(".o_control_panel_navigation > button > i.fa-sliders").click();
await waitFor(".o_popover.dropdown-menu");
await contains(".o_save_current_view ").click();
await contains(".o_save_favorite ").click();
expect(".o_embedded_actions > button").toHaveCount(3, {
message: "Should have 2 embedded actions in the embedded + the dropdown button",
});
// Emulate a hard refresh of the page
rpcBus.trigger("CLEAR-CACHES", "/web/action/load");
routerBus.trigger("ROUTE_CHANGE");
await animationFrame();
// Check that the created embedded action is still there, as the reload should be done
expect(".o_embedded_actions > button").toHaveCount(3, {
message:
"After refresh, we should still have 2 embedded actions in the embedded + the dropdown button",
});
});

View file

@ -1869,7 +1869,9 @@ describe(`new urls`, () => {
await mountWebClient();
await getService("action").doAction(100);
await runAllTimers(); // wait for the router to be updated
await contains(".o_data_cell").click();
await runAllTimers(); // wait for the router to be updated
await getService("action").doAction(200);
expect.verifySteps(["/web/action/load", "/web/action/load"]);

View file

@ -16,6 +16,7 @@ import {
import { animationFrame } from "@odoo/hoot-dom";
import { browser } from "@web/core/browser/browser";
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog";
import { router } from "@web/core/browser/router";
class Partner extends models.Model {
_name = "res.partner";
@ -81,6 +82,17 @@ test("open record withtout the correct company (doAction)", async () => {
});
});
const _pushState = router.pushState;
patchWithCleanup(router, {
pushState: (state, options) => {
expect(browser.location.href).toBe("https://www.hoot.test/");
const res = _pushState(state, options);
expect.step("pushState");
expect(browser.location.href).toBe("http://example.com/odoo/res.partner/1");
return res;
},
});
await mountWebClient();
getService("action").doAction({
type: "ir.actions.act_window",
@ -90,7 +102,7 @@ test("open record withtout the correct company (doAction)", async () => {
});
await animationFrame();
expect(cookie.get("cids")).toBe("1-2");
expect.verifySteps(["reload"]);
expect.verifySteps(["pushState", "reload"]);
expect(browser.location.href).toBe("http://example.com/odoo/res.partner/1", {
message: "url should contain the information of the doAction",
});

View file

@ -1,5 +1,6 @@
import { afterEach, expect, test } from "@odoo/hoot";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import { waitFor } from "@odoo/hoot-dom";
import {
contains,
defineActions,
@ -349,6 +350,54 @@ test("can use custom handlers for report actions", async () => {
]);
});
test("custom handlers can close modals", async () => {
defineActions([
{
id: 5,
name: "Create a Partner",
res_model: "partner",
target: "new",
views: [[false, "form"]],
},
]);
patchWithCleanup(download, {
_download: (options) => {
expect.step(options.url);
return Promise.resolve();
},
});
onRpc("/report/check_wkhtmltopdf", () => "ok");
await mountWithCleanup(WebClient);
registry.category("ir.actions.report handlers").add("custom_handler", async (action) => {
expect.step("calling custom handler for action " + action.id);
return true;
});
await getService("action").doAction(5);
await waitFor(".o_technical_modal .o_form_view");
expect(".o_technical_modal .o_form_view").toHaveCount(1, {
message: "should have rendered a form view in a modal",
});
await getService("action").doAction(7);
expect(".o_technical_modal .o_form_view").toHaveCount(1, {
message: "The modal should still exist",
});
await getService("action").doAction(11);
await animationFrame();
expect(".o_technical_modal .o_form_view").toHaveCount(0, {
message: "the modal should have been closed after the custom handler",
});
expect.verifySteps([
"calling custom handler for action 7",
"calling custom handler for action 11",
]);
});
test.tags("desktop");
test("context is correctly passed to the client action report", async (assert) => {
patchWithCleanup(download, {

View file

@ -1,4 +1,5 @@
import { expect, test } from "@odoo/hoot";
import { animationFrame, press } from "@odoo/hoot-dom";
import { Deferred } from "@odoo/hoot-mock";
import {
contains,
@ -62,8 +63,9 @@ test("Barcode scanner crop overlay", async () => {
patchWithCleanup(BarcodeVideoScanner.prototype, {
async isVideoReady() {
await super.isVideoReady(...arguments);
const result = await super.isVideoReady(...arguments);
videoReady.resolve();
return result;
},
onResize(overlayInfo) {
expect.step(overlayInfo);
@ -73,6 +75,7 @@ test("Barcode scanner crop overlay", async () => {
const firstBarcodeFound = scanBarcode(env);
await videoReady;
await animationFrame();
await contains(".o_crop_icon").dragAndDrop(".o_crop_container", {
relative: true,
position: {
@ -93,6 +96,7 @@ test("Barcode scanner crop overlay", async () => {
const secondBarcodeFound = scanBarcode(env);
await videoReady;
await animationFrame();
const secondValueScanned = await secondBarcodeFound;
expect(secondValueScanned).toBe(secondBarcodeValue, {
message: `The detected barcode (${secondValueScanned}) should be the same as generated (${secondBarcodeValue})`,
@ -135,3 +139,61 @@ test("BarcodeVideoScanner onReady props", async () => {
});
expect(await resolvedOnReadyPromise).toBe(true);
});
test("Closing barcode scanner before camera loads should not throw an error", async () => {
const env = await makeMockEnv();
await mountWithCleanup(WebClient, { env });
const cameraReady = new Deferred();
patchWithCleanup(browser.navigator, {
mediaDevices: {
async getUserMedia() {
await cameraReady;
const canvas = document.createElement("canvas");
return canvas.captureStream();
},
},
});
scanBarcode(env);
await animationFrame();
expect(".o-barcode-modal").toHaveCount(1)
await press("escape");
await animationFrame();
expect(".o-barcode-modal").toHaveCount(0)
cameraReady.resolve();
await animationFrame()
expect(".o_error_dialog").toHaveCount(0)
});
test("Closing barcode scanner while video is loading should not cause errors", async () => {
const env = await makeMockEnv();
await mountWithCleanup(WebClient, { env });
patchWithCleanup(browser.navigator, {
mediaDevices: {
async getUserMedia() {
const canvas = document.createElement("canvas");
return canvas.captureStream();
},
},
});
scanBarcode(env);
await animationFrame();
expect(".o-barcode-modal").toHaveCount(1)
await press("escape");
await animationFrame();
expect(".o-barcode-modal").toHaveCount(0)
await animationFrame()
expect(".o_error_dialog").toHaveCount(0)
});

View file

@ -180,6 +180,87 @@ test("clickbot clickeverywhere test", async () => {
]);
});
test("only one app", async () => {
onRpc("has_group", () => true);
mockDate("2017-10-08T15:35:11.000");
const clickEverywhereDef = new Deferred();
patchWithCleanup(browser.localStorage, {
removeItem(key) {
const savedState = super.getItem(key);
expect.step("savedState: " + savedState);
return super.removeItem(key);
},
});
patchWithCleanup(browser, {
console: {
log: (msg) => {
expect.step(msg);
if (msg === SUCCESS_SIGNAL) {
clickEverywhereDef.resolve();
}
},
error: (msg) => {
expect.step(msg);
clickEverywhereDef.resolve();
},
},
});
defineMenus([
{ id: 1, name: "App1", appID: 1, actionID: 1001, xmlid: "app1" },
{
id: 2,
children: [
{
id: 3,
name: "menu 1",
appID: 2,
actionID: 1002,
xmlid: "app2_menu1",
},
{
id: 4,
name: "menu 2",
appID: 2,
actionID: 1022,
xmlid: "app2_menu2",
},
],
name: "App2",
appID: 2,
actionID: 1002,
xmlid: "app2",
},
]);
const webClient = await mountWithCleanup(WebClient);
patchWithCleanup(odoo, {
__WOWL_DEBUG__: { root: webClient },
});
window.clickEverywhere("app1");
await clickEverywhereDef;
expect.verifySteps([
"Clicking on: apps menu toggle button",
"Testing app menu: app1",
"Testing menu App1 app1",
'Clicking on: menu item "App1"',
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Testing view switch: kanban",
"Clicking on: kanban view switcher",
"Testing 2 filters",
'Clicking on: filter "Not Bar"',
'Clicking on: filter "Date"',
'Clicking on: filter option "October"',
"Successfully tested 1 apps",
"Successfully tested 0 menus",
"Successfully tested 0 modals",
"Successfully tested 4 filters",
SUCCESS_SIGNAL,
'savedState: {"light":false,"studioCount":0,"testedApps":["app1"],"testedMenus":["app1"],"testedFilters":4,"testedModals":0,"appIndex":0,"menuIndex":0,"subMenuIndex":0,"xmlId":"app1","app":"app1"}',
]);
});
test("clickbot clickeverywhere test (with dropdown menu)", async () => {
onRpc("has_group", () => true);
mockDate("2017-10-08T15:35:11.000");
@ -438,6 +519,7 @@ test("clickbot show rpc error when an error dialog is detected", async () => {
debug: "traceback",
arguments: [],
context: {},
message: "This is a server Error, it should be displayed in an error dialog",
},
exceptionName: "odoo.exceptions.Programming error",
subType: "server",
@ -457,7 +539,7 @@ test("clickbot show rpc error when an error dialog is detected", async () => {
<button class="btn btn-link p-0">See technical details</button>
</div>
</main>
<footer class="modal-footer justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
<footer class="modal-footer d-empty-none justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
<button class="btn btn-primary o-default-button">Close</button>
</footer>`
.trim()

View file

@ -4,6 +4,7 @@ import {
contains,
defineModels,
editSelectMenu,
getMockEnv,
mountView,
onRpc,
serverState,
@ -191,6 +192,7 @@ beforeEach(() => {
description: false,
group_ids: [1, 2],
category_id: 121,
placeholder: "No",
},
222: {
id: 222,
@ -198,6 +200,7 @@ beforeEach(() => {
description: "Project access rights description",
group_ids: [11, 12, 13],
category_id: 221,
placeholder: "View",
},
223: {
id: 223,
@ -273,6 +276,27 @@ test("simple rendering", async () => {
expect(".o_group_info_button").toHaveCount(0); // not displayed in non debug mode
});
test("simple rendering in readonly", async () => {
await mountView({
type: "form",
arch: `
<form edit="0">
<sheet>
<field name="group_ids" widget="res_user_group_ids"/>
</sheet>
</form>`,
resModel: "res.users",
resId: 1,
});
expect(".o_field_widget[name=group_ids] input").toHaveCount(0);
expect(queryAllTexts(".o_field_res_user_group_ids_privilege span")).toEqual([
"Access Rights",
"Project User",
"",
]);
});
test("simple rendering (debug)", async () => {
serverState.debug = "1";
await mountView({
@ -359,6 +383,34 @@ test("editing groups doesn't remove groups (debug)", async () => {
expect.verifySteps(["web_save"]);
});
test(`Click on "?" should not trigger a focus`, async () => {
await mountView({
type: "form",
arch: `
<form>
<sheet>
<field name="group_ids" widget="res_user_group_ids"/>
</sheet>
</form>`,
resModel: "res.users",
resId: 1,
});
expect(`.o_form_label[for="field_222_0"] :contains("?")`).toHaveCount(1);
await contains(`.o_form_label[for="field_222_0"] :contains("?")`).click();
await runAllTimers();
expect(".o-overlay-container .o-dropdown-item").toHaveCount(0);
if (getMockEnv().isSmall) {
expect(".o-overlay-container .o-tooltip").toHaveCount(1);
} else {
expect(".o-overlay-container .o-tooltip").toHaveCount(0);
}
await contains(`.o_form_label[for="field_222_0"]`).click();
await runAllTimers();
expect(".o-overlay-container .o-dropdown-item").toHaveCount(4);
expect(".o-overlay-container .o-tooltip").toHaveCount(0);
});
test.tags("desktop");
test(`privilege tooltips`, async () => {
await mountView({
@ -522,7 +574,7 @@ test("implied groups: lower level groups no longer available", async () => {
expect(".o_inner_group:eq(1) .o_select_menu").toHaveCount(2);
await contains(queryFirst(".o_inner_group:eq(1) .o_wrap_input input")).click();
expect(queryFirst(".o_inner_group:eq(1) .o_wrap_input input")).toHaveValue("Project User");
expect(".o_select_menu_item").toHaveCount(3);
expect(".o_select_menu_item").toHaveCount(4);
expect(".o_inner_group:eq(1) .o_wrap_input:last-child input").toHaveValue("");
await editSelectMenu(
".o_field_widget[name='group_ids'] .o_inner_group:nth-child(2) .o_wrap_input:last-child input",
@ -543,7 +595,7 @@ test("implied groups: lower level groups no longer available", async () => {
expect(queryFirst(".o_inner_group:eq(1) .o_wrap_input input")).toHaveValue("Project User");
await contains(queryFirst(".o_inner_group:eq(1) .o_wrap_input input")).click();
expect(".o_select_menu_item").toHaveCount(3);
expect(".o_select_menu_item").toHaveCount(4);
});
test("implied groups: lower level groups of same privilege still available", async () => {
@ -560,7 +612,7 @@ test("implied groups: lower level groups of same privilege still available", asy
resId: 1,
});
await contains(queryFirst(".o_inner_group:eq(1) .o_wrap_input input")).click();
expect(".o_select_menu_item").toHaveCount(3);
expect(".o_select_menu_item").toHaveCount(4);
});
test("do not lose shadowed groups when editing", async () => {
@ -738,3 +790,46 @@ test("privileges without category", async () => {
await contains(`.o_form_button_save`).click();
expect.verifySteps(["web_save"]);
});
test("privileges with placeholder", async () => {
ResUsers._records[0].group_ids = [];
await mountView({
type: "form",
arch: `
<form>
<sheet>
<field name="group_ids" widget="res_user_group_ids"/>
</sheet>
</form>`,
resModel: "res.users",
resId: 1,
});
expect(queryAllValues(".o_select_menu_input")).toEqual(["No", "View", ""]);
await contains(".o_field_widget[name=group_ids] .o_inner_group:eq(1) input").click();
expect(queryAllTexts(".o_select_menu_item")).toEqual([
"View",
"Project User",
"Project Manager",
"Project Administrator",
]);
await contains(".o_field_widget[name=group_ids] .o_inner_group:eq(1) input:eq(1)").click();
expect(`.o_select_menu_item`).toHaveCount(2);
await editSelectMenu(".o_field_widget[name=group_ids] .o_inner_group:eq(1) input:eq(1)", {
value: "Helpdesk Administrator",
});
expect(queryAllValues(".o_select_menu_input")).toEqual(["No", "", "Helpdesk Administrator"]);
expect(queryFirst(".o_inner_group:eq(1) .o_wrap_input input")).toHaveAttribute(
"placeholder",
"Project Manager"
);
await contains(".o_field_widget[name=group_ids] .o_inner_group:eq(1) input").click();
expect(queryAllTexts(".o_select_menu_item")).toEqual([
"Project Manager",
"Project Administrator",
]);
});

View file

@ -2083,7 +2083,7 @@ test("server actions are called with the correct context", async () => {
test("BinaryField is correctly rendered in Settings form view", async () => {
onRpc("/web/content", async (request) => {
const body = await request.text();
const body = await request.formData();
expect(body).toBeInstanceOf(FormData);
expect(body.get("field")).toBe("file", {
message: "we should download the field document",
@ -2369,3 +2369,27 @@ test("settings search is accent-insensitive", async () => {
await editSearch("àz");
expect(queryAllTexts(".highlighter")).toEqual(["ÄZ", "áz"]);
});
test("settings search does not highlight escaped characters when highlighting the searched text", async () => {
await mountView({
type: "form",
resModel: "res.config.settings",
arch: /* xml */ `
<form string="Settings" class="oe_form_configuration o_base_settings" js_class="base_settings">
<app string="CRM" name="crm">
<block title="Research &amp; Development">
<setting help="This is Research &amp; Development Settings">
<field name="bar"/>
<div>This test is to check whether &amp; gets escaped during search or not.</div>
</setting>
</block>
</app>
</form>
`,
});
await editSearch("a");
expect(queryAllTexts(".highlighter")).toEqual(["a", "a", "a", "a", "a"]);
await editSearch("&");
expect(queryAllTexts(".highlighter")).toEqual(["&", "&", "&"]);
});

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