mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 03:52:01 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}"`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'<p>a<span class="hl">b</span></p>'
|
||||
);
|
||||
|
|
@ -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>,<span>test 2</span></p>undefined"
|
||||
);
|
||||
expect(res.toString()).toBe("<p><span>test 1</span></p><span>test 2</span>");
|
||||
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("<p><b>test</b></p>");
|
||||
expect(odoomark(markup`<p>**test**</p>`).toString()).toBe("<p><b>test</b></p>");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"]]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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:11 AM", {
|
||||
expect("div[name='datetime'] input").toHaveAttribute("placeholder", /Apr 1, 2025, 9:11\sAM/, {
|
||||
message: "placeholder_field should be the placeholder",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => __comp__.props.onNotebookPageChange(0, page)">
|
||||
<Notebook defaultPage="__comp__.props.record.isNew ? undefined : __comp__.props.activeNotebookPages[0]" onPageUpdate="(page) => __comp__.props.onNotebookPageChange(0, page)" onWillActivatePage="(page) => __comp__.onWillChangeNotebookPage?.(0, page)">
|
||||
<t t-set-slot="page_1" title="\`Page1\`" name="\`p1\`" isVisible="true" fieldnames="["charfield"]">
|
||||
<Field id="'charfield'" name="'charfield'" record="__comp__.props.record" fieldInfo="__comp__.props.archInfo.fieldNodes['charfield']" readonly="__comp__.props.readonly"/>
|
||||
</t>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & Development">
|
||||
<setting help="This is Research & Development Settings">
|
||||
<field name="bar"/>
|
||||
<div>This test is to check whether & 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
Loading…
Add table
Add a link
Reference in a new issue