Since commit 0134495ba5 the reporting
views pivot, graph, cohort are written in full new framework.
Some applications still need the legacy ones, so this commit just implements
the lazy loading of those views.
In stock, report_stock_forecasted has been adapted in order to avoid having
a GraphView override: we now modify the canvas' height in pure JS.
*: board, stock
closes odoo/odoo#76931
Related: odoo/enterprise#21054
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
1845 lines
71 KiB
JavaScript
1845 lines
71 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { browser } from "@web/core/browser/browser";
|
|
import { Domain } from "@web/core/domain";
|
|
import { evaluateExpr } from "@web/core/py_js/py";
|
|
import { registry } from "@web/core/registry";
|
|
import { makeFakeRPCService, makeMockFetch } from "./mock_services";
|
|
import { patchWithCleanup } from "./utils";
|
|
import { parseDateTime } from "@web/core/l10n/dates";
|
|
|
|
const { DateTime } = luxon;
|
|
const serviceRegistry = registry.category("services");
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Utils
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function traverseElementTree(tree, cb) {
|
|
if (cb(tree)) {
|
|
Array.from(tree.children).forEach((c) => traverseElementTree(c, cb));
|
|
}
|
|
}
|
|
|
|
export function _fieldsViewGet(params) {
|
|
let processedNodes = params.processedNodes || [];
|
|
const { arch, context, fields, modelName } = params;
|
|
function isNodeProcessed(node) {
|
|
return processedNodes.findIndex((n) => n.isSameNode(node)) > -1;
|
|
}
|
|
const modifiersNames = ["invisible", "readonly", "required"];
|
|
const onchanges = params.models[modelName].onchanges || {};
|
|
const fieldNodes = {};
|
|
const groupbyNodes = {};
|
|
let doc;
|
|
if (typeof arch === "string") {
|
|
const domParser = new DOMParser();
|
|
doc = domParser.parseFromString(arch, "text/xml").documentElement;
|
|
} else {
|
|
doc = arch;
|
|
}
|
|
const inTreeView = doc.tagName === "tree";
|
|
// mock _postprocess_access_rights
|
|
const isBaseModel = !context.base_model_name || modelName === context.base_model_name;
|
|
const views = ["kanban", "tree", "form", "gantt", "activity"];
|
|
if (isBaseModel && views.indexOf(doc.tagName) !== -1) {
|
|
for (const action of ["create", "delete", "edit", "write"]) {
|
|
if (!doc.getAttribute(action) && action in context && !context[action]) {
|
|
doc.setAttribute(action, "false");
|
|
}
|
|
}
|
|
}
|
|
|
|
traverseElementTree(doc, (node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return false;
|
|
}
|
|
const modifiers = {};
|
|
const isField = node.tagName === "field";
|
|
const isGroupby = node.tagName === "groupby";
|
|
if (isField) {
|
|
const fieldName = node.getAttribute("name");
|
|
fieldNodes[fieldName] = node;
|
|
// 'transfer_field_to_modifiers' simulation
|
|
const field = fields[fieldName];
|
|
if (!field) {
|
|
throw new Error("Field " + fieldName + " does not exist");
|
|
}
|
|
const defaultValues = {};
|
|
const stateExceptions = {}; // what is this ?
|
|
modifiersNames.forEach((attr) => {
|
|
stateExceptions[attr] = [];
|
|
defaultValues[attr] = !!field[attr];
|
|
});
|
|
// LPE: what is this ?
|
|
/* _.each(field.states || {}, function (modifs, state) {
|
|
_.each(modifs, function (modif) {
|
|
if (defaultValues[modif[0]] !== modif[1]) {
|
|
stateExceptions[modif[0]].append(state);
|
|
}
|
|
});
|
|
});*/
|
|
Object.entries(defaultValues).forEach(([attr, defaultValue]) => {
|
|
if (stateExceptions[attr].length) {
|
|
modifiers[attr] = [
|
|
["state", defaultValue ? "not in" : "in", stateExceptions[attr]],
|
|
];
|
|
} else {
|
|
modifiers[attr] = defaultValue;
|
|
}
|
|
});
|
|
} else if (isGroupby && !isNodeProcessed(node)) {
|
|
const groupbyName = node.getAttribute("name");
|
|
fieldNodes[groupbyName] = node;
|
|
groupbyNodes[groupbyName] = node;
|
|
}
|
|
// 'transfer_node_to_modifiers' simulation
|
|
let attrs = node.getAttribute("attrs");
|
|
if (attrs) {
|
|
attrs = evaluateExpr(attrs);
|
|
Object.assign(modifiers, attrs);
|
|
}
|
|
const states = node.getAttribute("states");
|
|
if (states) {
|
|
if (!modifiers.invisible) {
|
|
modifiers.invisible = [];
|
|
}
|
|
modifiers.invisible.push(["state", "not in", states.split(",")]);
|
|
}
|
|
const inListHeader = inTreeView && node.closest("header");
|
|
modifiersNames.forEach((attr) => {
|
|
const mod = node.getAttribute(attr);
|
|
if (mod) {
|
|
// TODO
|
|
// const pyevalContext = window.py.dict.fromJSON(context || {});
|
|
// var v = pyUtils.py_eval(mod, {context: pyevalContext}) ? true : false;
|
|
console.info(
|
|
"MockServer: naive parse of modifier value in",
|
|
QUnit.config.current.testName
|
|
);
|
|
const v = JSON.parse(mod);
|
|
if (inTreeView && !inListHeader && attr === "invisible") {
|
|
modifiers.column_invisible = v;
|
|
} else if (v || !(attr in modifiers) || !Array.isArray(modifiers[attr])) {
|
|
modifiers[attr] = v;
|
|
}
|
|
}
|
|
});
|
|
modifiersNames.forEach((attr) => {
|
|
if (
|
|
attr in modifiers &&
|
|
(!!modifiers[attr] === false ||
|
|
(Array.isArray(modifiers[attr]) && !modifiers[attr].length))
|
|
) {
|
|
delete modifiers[attr];
|
|
}
|
|
});
|
|
if (Object.keys(modifiers).length) {
|
|
node.setAttribute("modifiers", JSON.stringify(modifiers));
|
|
}
|
|
if (isGroupby && !isNodeProcessed(node)) {
|
|
return false;
|
|
}
|
|
return !isField;
|
|
});
|
|
let relModel, relFields;
|
|
Object.entries(fieldNodes).forEach(([name, node]) => {
|
|
const field = fields[name];
|
|
if (field.type === "many2one" || field.type === "many2many") {
|
|
const canCreate = node.getAttribute("can_create");
|
|
node.setAttribute("can_create", canCreate || "true");
|
|
const canWrite = node.getAttribute("can_write");
|
|
node.setAttribute("can_write", canWrite || "true");
|
|
}
|
|
if (field.type === "one2many" || field.type === "many2many") {
|
|
field.views = {};
|
|
Array.from(node.children).forEach((children) => {
|
|
if (children.tagName) {
|
|
// skip text nodes
|
|
relModel = field.relation;
|
|
relFields = Object.assign({}, params.models[relModel].fields);
|
|
field.views[children.tagName] = _fieldsViewGet({
|
|
models: params.models,
|
|
arch: children,
|
|
modelName: relModel,
|
|
fields: relFields,
|
|
context: Object.assign({}, context, { base_model_name: modelName }),
|
|
processedNodes,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
// add onchanges
|
|
if (name in onchanges) {
|
|
node.setAttribute("on_change", "1");
|
|
}
|
|
});
|
|
Object.entries(groupbyNodes).forEach(([name, node]) => {
|
|
const field = fields[name];
|
|
if (field.type !== "many2one") {
|
|
throw new Error("groupby can only target many2one");
|
|
}
|
|
field.views = {};
|
|
relModel = field.relation;
|
|
relFields = Object.assign({}, params.models[relModel].fields);
|
|
processedNodes.push(node);
|
|
// postprocess simulation
|
|
field.views.groupby = _fieldsViewGet({
|
|
models: params.models,
|
|
arch: node,
|
|
modelName: relModel,
|
|
fields: relFields,
|
|
context,
|
|
processedNodes,
|
|
});
|
|
while (node.firstChild) {
|
|
node.removeChild(node.firstChild);
|
|
}
|
|
});
|
|
const xmlSerializer = new XMLSerializer();
|
|
const processedArch = xmlSerializer.serializeToString(doc);
|
|
const fieldsInView = {};
|
|
Object.entries(fields).forEach(([fname, field]) => {
|
|
if (fname in fieldNodes) {
|
|
fieldsInView[fname] = field;
|
|
}
|
|
});
|
|
return {
|
|
arch: processedArch,
|
|
fields: fieldsInView,
|
|
model: modelName,
|
|
type: doc.tagName === "tree" ? "list" : doc.tagName,
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// MockServer
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export class MockServer {
|
|
constructor(data, options = {}) {
|
|
this.models = data.models || {};
|
|
this.actions = data.actions || {};
|
|
this.menus = data.menus || null;
|
|
this.archs = data.views || {};
|
|
this.debug = options.debug || false;
|
|
Object.entries(this.models).forEach(([modelName, model]) => {
|
|
if (!("id" in model.fields)) {
|
|
model.fields.id = { string: "ID", type: "integer" };
|
|
}
|
|
if (!("display_name" in model.fields)) {
|
|
model.fields.display_name = { string: "Display Name", type: "char" };
|
|
}
|
|
if (!("__last_update" in model.fields)) {
|
|
model.fields.__last_update = { string: "Last Modified on", type: "datetime" };
|
|
}
|
|
if (!("name" in model.fields)) {
|
|
model.fields.name = { string: "Name", type: "char", default: "name" };
|
|
}
|
|
model.records = model.records || [];
|
|
for (var i = 0; i < model.records.length; i++) {
|
|
const values = model.records[i];
|
|
// add potentially missing id
|
|
const id = values.id === undefined ? this.getUnusedID(modelName) : values.id;
|
|
// create a clean object, initial values are passed to write
|
|
model.records[i] = { id };
|
|
// ensure initial data goes through proper conversion (x2m, ...)
|
|
this.applyDefaults(model, values);
|
|
this.writeRecord(modelName, values, id, { ensureIntegrity: false });
|
|
}
|
|
model.onchanges = model.onchanges || {};
|
|
model.methods = model.methods || {};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Simulate a complete RPC call. This is the main method for this class.
|
|
*
|
|
* This method also log incoming and outgoing data, and stringify/parse data
|
|
* to simulate a barrier between the server and the client. It also simulate
|
|
* server errors.
|
|
*/
|
|
async performRPC(route, args) {
|
|
args = JSON.parse(JSON.stringify(args));
|
|
if (this.debug) {
|
|
console.log("%c[rpc] request " + route, "color: blue; font-weight: bold;", args);
|
|
args = JSON.parse(JSON.stringify(args));
|
|
}
|
|
let result;
|
|
// try {
|
|
result = await this._performRPC(route, args);
|
|
// } catch {
|
|
// const message = result && result.message;
|
|
// const event = result && result.event;
|
|
// const errorString = JSON.stringify(message || false);
|
|
// console.warn(
|
|
// "%c[rpc] response (error) " + route,
|
|
// "color: orange; font-weight: bold;",
|
|
// JSON.parse(errorString)
|
|
// );
|
|
// return Promise.reject({ message: errorString, event });
|
|
// }
|
|
const resultString = JSON.stringify(result || false);
|
|
if (this.debug) {
|
|
console.log(
|
|
"%c[rpc] response" + route,
|
|
"color: blue; font-weight: bold;",
|
|
JSON.parse(resultString)
|
|
);
|
|
}
|
|
return JSON.parse(resultString);
|
|
// TODO?
|
|
// var abort = def.abort || def.reject;
|
|
// if (abort) {
|
|
// abort = abort.bind(def);
|
|
// } else {
|
|
// abort = function () {
|
|
// throw new Error("Can't abort this request");
|
|
// };
|
|
// }
|
|
// def.abort = abort;
|
|
}
|
|
|
|
fieldsViewGet(modelName, args, kwargs) {
|
|
if (!(modelName in this.models)) {
|
|
throw new Error(`Model ${modelName} was not defined in mock server data`);
|
|
}
|
|
// find the arch
|
|
let [viewId, viewType] = args;
|
|
if (!viewId) {
|
|
const contextKey = (viewType === "list" ? "tree" : viewType) + "_view_ref";
|
|
if (contextKey in kwargs.context) {
|
|
viewId = kwargs.context[contextKey];
|
|
}
|
|
}
|
|
const key = [modelName, viewId, viewType].join(",");
|
|
let arch = this.archs[key];
|
|
if (!arch) {
|
|
const genericViewKey = Object.keys(this.archs).find((fullKey) => {
|
|
const [_model, , _viewType] = fullKey.split(",");
|
|
return _model === modelName && _viewType === viewType;
|
|
});
|
|
if (genericViewKey) {
|
|
arch = this.archs[genericViewKey];
|
|
viewId = parseInt(genericViewKey.split(",")[1], 10) || false;
|
|
}
|
|
}
|
|
if (!arch) {
|
|
throw new Error("No arch found for key " + key);
|
|
}
|
|
// generate a field_view_get result
|
|
const fields = Object.assign({}, this.models[modelName].fields);
|
|
for (const fieldName in fields) {
|
|
fields[fieldName].name = fieldName;
|
|
}
|
|
// var viewOptions = params.viewOptions || {};
|
|
const fvg = _fieldsViewGet({
|
|
arch,
|
|
modelName,
|
|
fields,
|
|
context: kwargs.context || {},
|
|
models: this.models,
|
|
});
|
|
if (kwargs.options.toolbar) {
|
|
fvg.toolbar = this.models[modelName].toolbar || {};
|
|
}
|
|
if (viewId !== undefined) {
|
|
fvg.view_id = viewId;
|
|
fvg.name = key;
|
|
}
|
|
return fvg;
|
|
}
|
|
|
|
/**
|
|
* Converts an Object representing a record to actual return Object of the
|
|
* python `onchange` method.
|
|
* Specifically, it applies `name_get` on many2one's and transforms raw id
|
|
* list in orm command lists for x2many's.
|
|
* For x2m fields that add or update records (ORM commands 0 and 1), it is
|
|
* recursive.
|
|
*
|
|
* @param {string} model: the model's name
|
|
* @param {Object} values: an object representing a record
|
|
* @returns {Object}
|
|
*/
|
|
convertToOnChange(modelName, values) {
|
|
Object.entries(values).forEach(([fname, val]) => {
|
|
const field = this.models[modelName].fields[fname];
|
|
if (field.type === "many2one" && typeof val === "number") {
|
|
// implicit name_get
|
|
const m2oRecord = this.models[field.relation].records.find((r) => r.id === val);
|
|
values[fname] = [val, m2oRecord.display_name];
|
|
} else if (field.type === "one2many" || field.type === "many2many") {
|
|
// TESTS ONLY
|
|
// one2many_ids = [1,2,3] is a simpler way to express it than orm commands
|
|
const isCommandList = Array.isArray(val) && Array.isArray(val[0]);
|
|
if (!isCommandList) {
|
|
values[fname] = [[6, false, val]];
|
|
} else {
|
|
val.forEach((cmd) => {
|
|
if (cmd[0] === 0 || cmd[0] === 1) {
|
|
cmd[2] = this.convertToOnChange(field.relation, cmd[2]);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
return values;
|
|
}
|
|
|
|
async _performRPC(route, args) {
|
|
// Check if there is an handler in the mockRegistry: either specific for this model
|
|
// (with key 'model/method'), or global (with key 'method')
|
|
// This allows to mock routes/methods defined outside web.
|
|
const methodName = args.method || route;
|
|
const mockFunction =
|
|
registry.category("mock_server").get(`${args.model}/${methodName}`, null) ||
|
|
registry.category("mock_server").get(methodName, null);
|
|
if (mockFunction) {
|
|
return mockFunction.call(this, route, args);
|
|
}
|
|
|
|
switch (route) {
|
|
case "/web/webclient/load_menus":
|
|
return this.mockLoadMenus();
|
|
case "/web/action/load":
|
|
return this.mockLoadAction(args);
|
|
case "/web/dataset/search_read":
|
|
return this.mockSearchReadController(args);
|
|
case "/web/dataset/search":
|
|
return this.mockSearchController(args);
|
|
}
|
|
if (
|
|
route.indexOf("/web/image") >= 0 ||
|
|
[".png", ".jpg"].includes(route.substr(route.length - 4))
|
|
) {
|
|
return;
|
|
}
|
|
switch (args.method) {
|
|
case "render_public_asset": {
|
|
return true;
|
|
}
|
|
case "create":
|
|
return this.mockCreate(args.model, args.args[0]);
|
|
case "fields_get":
|
|
return this.mockFieldsGet(args.model);
|
|
case "load_views":
|
|
return this.mockLoadViews(args.model, args.kwargs);
|
|
case "name_create":
|
|
return this.mockNameCreate(args.model, args.args[0]);
|
|
case "name_get":
|
|
return this.mockNameGet(args.model, args.args);
|
|
case "name_search":
|
|
return this.mockNameSearch(args.model, args.args, args.kwargs);
|
|
case "onchange":
|
|
return this.mockOnchange(args.model, args.args, args.kwargs);
|
|
case "read":
|
|
return this.mockRead(args.model, args.args);
|
|
case "search":
|
|
return this.mockSearch(args.model, args.args, args.kwargs);
|
|
case "search_count":
|
|
return this.mockSearchCount(args.model, args.args, args.kwargs);
|
|
case "search_panel_select_range":
|
|
return this.mockSearchPanelSelectRange(args.model, args.args, args.kwargs);
|
|
case "search_panel_select_multi_range":
|
|
return this.mockSearchPanelSelectMultiRange(args.model, args.args, args.kwargs);
|
|
case "search_read":
|
|
return this.mockSearchRead(args.model, args.args, args.kwargs);
|
|
case "web_search_read":
|
|
return this.mockWebSearchRead(args.model, args.args, args.kwargs);
|
|
case "read_group":
|
|
return this.mockReadGroup(args.model, args.kwargs);
|
|
case "web_read_group":
|
|
return this.mockWebReadGroup(args.model, args.kwargs);
|
|
case "write":
|
|
return this.mockWrite(args.model, args.args);
|
|
}
|
|
const model = this.models[args.model];
|
|
const method = model && model.methods[args.method];
|
|
if (method) {
|
|
return method(args.model, args.args, args.kwargs);
|
|
}
|
|
throw new Error(`Unimplemented route: ${route}`);
|
|
}
|
|
|
|
mockCreate(modelName, values) {
|
|
if ("id" in values) {
|
|
throw new Error("Cannot create a record with a predefinite id");
|
|
}
|
|
const model = this.models[modelName];
|
|
const id = this.getUnusedID(modelName);
|
|
const record = { id };
|
|
model.records.push(record);
|
|
this.applyDefaults(model, values);
|
|
this.writeRecord(modelName, values, id);
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* @param {string} modelName
|
|
* @param {array[]} args a list with a list of fields in the first position
|
|
* @param {Object} [kwargs={}]
|
|
* @param {Object} [kwargs.context] the context to eventually read default
|
|
* values
|
|
* @returns {Object}
|
|
*/
|
|
mockDefaultGet(modelName, args, kwargs = {}) {
|
|
const fields = args[0];
|
|
const model = this.models[modelName];
|
|
const result = {};
|
|
for (const fieldName of fields) {
|
|
const key = "default_" + fieldName;
|
|
if (kwargs.context && key in kwargs.context) {
|
|
result[fieldName] = kwargs.context[key];
|
|
continue;
|
|
}
|
|
const field = model.fields[fieldName];
|
|
if ("default" in field) {
|
|
result[fieldName] = field.default;
|
|
continue;
|
|
}
|
|
}
|
|
for (const fieldName in result) {
|
|
const field = model.fields[fieldName];
|
|
if (field.type === "many2one") {
|
|
const recordExists = this.models[field.relation].records.some(
|
|
(r) => r.id === result[fieldName]
|
|
);
|
|
if (!recordExists) {
|
|
delete result[fieldName];
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
mockFieldsGet(modelName) {
|
|
return this.models[modelName].fields;
|
|
}
|
|
|
|
mockLoadAction(kwargs) {
|
|
const action = this.actions[kwargs.action_id];
|
|
if (!action) {
|
|
// when the action doesn't exist, the real server doesn't crash, it
|
|
// simply returns false
|
|
console.warn(
|
|
`No action found for ID ${kwargs.action_id} during test ${QUnit.config.current.testName}`
|
|
);
|
|
}
|
|
return action || false;
|
|
}
|
|
|
|
mockLoadMenus() {
|
|
let menus = this.menus;
|
|
if (!menus) {
|
|
menus = {
|
|
root: { id: "root", children: [1], name: "root", appID: "root" },
|
|
1: { id: 1, children: [], name: "App0", appID: 1 },
|
|
};
|
|
}
|
|
return menus;
|
|
}
|
|
|
|
mockLoadViews(modelName, kwargs) {
|
|
const fieldsViews = {};
|
|
kwargs.views.forEach(([viewId, viewType]) => {
|
|
fieldsViews[viewType] = this.fieldsViewGet(modelName, [viewId, viewType], kwargs);
|
|
});
|
|
const result = {
|
|
fields: this.mockFieldsGet(modelName),
|
|
fields_views: fieldsViews,
|
|
};
|
|
if (kwargs.options.load_filters) {
|
|
result.filters = this.models[modelName].filters || [];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Simulate a 'name_create' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {string} name
|
|
* @returns {Array} a couple [id, name]
|
|
*/
|
|
mockNameCreate(modelName, name) {
|
|
const values = {
|
|
name: name,
|
|
display_name: name,
|
|
};
|
|
const id = this.mockCreate(modelName, values);
|
|
return [id, name];
|
|
}
|
|
|
|
/**
|
|
* Simulate a 'name_get' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {Array[]} a list of [id, display_name]
|
|
*/
|
|
mockNameGet(model, args) {
|
|
var ids = args[0];
|
|
if (!args.length) {
|
|
throw new Error("name_get: expected one argument");
|
|
} else if (!ids) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(ids)) {
|
|
ids = [ids];
|
|
}
|
|
var records = this.models[model].records;
|
|
var names = ids.map((id) =>
|
|
id ? [id, records.find((r) => r.id === id).display_name] : [null, "False"]
|
|
);
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Simulate a 'name_search' operation.
|
|
*
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @param {string} args[0]
|
|
* @param {Array} args[1], search domain
|
|
* @param {Object} kwargs
|
|
* @param {number} [kwargs.limit=100] server-side default limit
|
|
* @returns {Array[]} a list of [id, display_name]
|
|
*/
|
|
mockNameSearch(model, args, kwargs) {
|
|
const str = args && typeof args[0] === "string" ? args[0] : kwargs.name;
|
|
const limit = kwargs.limit || 100;
|
|
const domain = (args && args[1]) || kwargs.args || [];
|
|
let { records } = this.models[model];
|
|
const result = [];
|
|
for (const r of records) {
|
|
const isInDomain = this.evaluateDomain(domain, r);
|
|
if (isInDomain && (!str.length || r.display_name.includes(str))) {
|
|
result.push([r.id, r.display_name]);
|
|
}
|
|
}
|
|
return result.slice(0, limit);
|
|
}
|
|
|
|
mockOnchange(modelName, args, kwargs) {
|
|
const currentData = args[1];
|
|
const onChangeSpec = args[3];
|
|
let fields = args[2] ? (Array.isArray(args[2]) ? args[2] : [args[2]]) : [];
|
|
const onchanges = this.models[modelName].onchanges || {};
|
|
const firstOnChange = !fields.length;
|
|
const onchangeVals = {};
|
|
let defaultVals = undefined;
|
|
let nullValues;
|
|
if (firstOnChange) {
|
|
const fieldsFromView = Object.keys(onChangeSpec).reduce((acc, fname) => {
|
|
fname = fname.split(".", 1)[0];
|
|
if (!acc.includes(fname)) {
|
|
acc.push(fname);
|
|
}
|
|
return acc;
|
|
}, []);
|
|
const defaultingFields = fieldsFromView.filter((fname) => !(fname in currentData));
|
|
defaultVals = this.mockDefaultGet(modelName, [defaultingFields], kwargs);
|
|
// It is the new semantics: no field in arguments means we are in
|
|
// a default_get + onchange situation
|
|
fields = fieldsFromView;
|
|
nullValues = {};
|
|
fields
|
|
.filter((fName) => !Object.keys(defaultVals).includes(fName))
|
|
.forEach((fName) => {
|
|
nullValues[fName] = false;
|
|
});
|
|
}
|
|
Object.assign(currentData, defaultVals);
|
|
fields.forEach((field) => {
|
|
if (field in onchanges) {
|
|
const changes = Object.assign({}, nullValues, currentData);
|
|
onchanges[field](changes);
|
|
Object.entries(changes).forEach(([key, value]) => {
|
|
if (currentData[key] !== value) {
|
|
onchangeVals[key] = value;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
return {
|
|
value: this.convertToOnChange(modelName, Object.assign({}, defaultVals, onchangeVals)),
|
|
};
|
|
}
|
|
|
|
mockRead(modelName, args) {
|
|
const model = this.models[modelName];
|
|
let fields;
|
|
if (args[1] && args[1].length) {
|
|
fields = [...new Set(args[1].concat(["id"]))];
|
|
} else {
|
|
fields = Object.keys(model.fields);
|
|
}
|
|
const ids = Array.isArray(args[0]) ? args[0] : [args[0]];
|
|
const records = ids.reduce((records, id) => {
|
|
if (!id) {
|
|
throw new Error(
|
|
"mock read: falsy value given as id, would result in an access error in actual server !"
|
|
);
|
|
}
|
|
const record = model.records.find((r) => r.id === id);
|
|
return record ? records.concat(record) : records;
|
|
}, []);
|
|
return records.map((record) => {
|
|
const result = { id: record.id };
|
|
for (const fieldName of fields) {
|
|
const field = model.fields[fieldName];
|
|
if (!field) {
|
|
continue; // the field doens't exist on the model, so skip it
|
|
}
|
|
if (["float", "integer", "monetary"].includes(field.type)) {
|
|
// read should return 0 for unset numeric fields
|
|
result[fieldName] = record[fieldName] || 0;
|
|
} else if (field.type === "many2one") {
|
|
const CoModel = this.models[field.relation];
|
|
const relRecord = CoModel.records.find((r) => r.id === record[fieldName]);
|
|
if (relRecord) {
|
|
result[fieldName] = [record[fieldName], relRecord.display_name];
|
|
} else {
|
|
result[fieldName] = false;
|
|
}
|
|
} else if (field.type === "one2many" || field.type === "many2many") {
|
|
result[fieldName] = record[fieldName] || [];
|
|
} else {
|
|
result[fieldName] = record[fieldName] || false;
|
|
}
|
|
}
|
|
return result;
|
|
});
|
|
}
|
|
|
|
mockReadGroup(modelName, kwargs) {
|
|
if (!("lazy" in kwargs)) {
|
|
kwargs.lazy = true;
|
|
}
|
|
const fields = this.models[modelName].fields;
|
|
const records = this.getRecords(modelName, kwargs.domain);
|
|
let groupBy = [];
|
|
if (kwargs.groupby.length) {
|
|
groupBy = kwargs.lazy ? [kwargs.groupby[0]] : kwargs.groupby;
|
|
}
|
|
const groupByFieldNames = groupBy.map((groupByField) => {
|
|
return groupByField.split(":")[0];
|
|
});
|
|
let aggregatedFields = [];
|
|
// if no fields have been given, the server picks all stored fields
|
|
if (kwargs.fields.length === 0) {
|
|
aggregatedFields = Object.keys(fields).filter(
|
|
(fieldName) => !groupByFieldNames.includes(fieldName)
|
|
);
|
|
} else {
|
|
kwargs.fields.forEach((field) => {
|
|
var split = field.split(":");
|
|
var fieldName = split[0];
|
|
if (!fields[fieldName]) {
|
|
return;
|
|
}
|
|
if (groupByFieldNames.includes(fieldName)) {
|
|
// grouped fields are not aggregated
|
|
return;
|
|
}
|
|
if (
|
|
fields[fieldName] &&
|
|
fields[fieldName].type === "many2one" &&
|
|
split[1] !== "count_distinct"
|
|
) {
|
|
return;
|
|
}
|
|
aggregatedFields.push(fieldName);
|
|
});
|
|
}
|
|
function aggregateFields(group, records) {
|
|
let type;
|
|
for (let i = 0; i < aggregatedFields.length; i++) {
|
|
type = fields[aggregatedFields[i]].type;
|
|
if (type === "float" || type === "integer") {
|
|
group[aggregatedFields[i]] = null;
|
|
for (let j = 0; j < records.length; j++) {
|
|
const value = group[aggregatedFields[i]] || 0;
|
|
group[aggregatedFields[i]] = value + records[j][aggregatedFields[i]];
|
|
}
|
|
}
|
|
if (type === "many2one") {
|
|
const ids = records.map((record) => record[aggregatedFields[i]]);
|
|
group[aggregatedFields[i]] = [...new Set(ids)].length || null;
|
|
}
|
|
}
|
|
}
|
|
function formatValue(groupByField, val) {
|
|
if (val === false || val === undefined) {
|
|
return false;
|
|
}
|
|
const [fieldName, aggregateFunction = "month"] = groupByField.split(":");
|
|
const { type } = fields[fieldName];
|
|
if (type === "date") {
|
|
const date = DateTime.fromSQL(val);
|
|
if (aggregateFunction === "day") {
|
|
return date.toFormat("yyyy-MM-dd");
|
|
} else if (aggregateFunction === "week") {
|
|
return `W${date.toFormat("WW kkkk")}`;
|
|
} else if (aggregateFunction === "quarter") {
|
|
return `Q${date.toFormat("q yyyy")}`;
|
|
} else if (aggregateFunction === "year") {
|
|
return date.toFormat("yyyy");
|
|
} else {
|
|
return date.toFormat("MMMM yyyy");
|
|
}
|
|
} else if (type === "datetime") {
|
|
const date = DateTime.fromSQL(val);
|
|
if (aggregateFunction === "hour") {
|
|
return date.toFormat("HH:00 dd MMM");
|
|
} else if (aggregateFunction === "day") {
|
|
return date.toFormat("yyyy-MM-dd");
|
|
} else if (aggregateFunction === "week") {
|
|
return `W${date.toFormat("WW kkkk")}`;
|
|
} else if (aggregateFunction === "quarter") {
|
|
return `Q${date.toFormat("q yyyy")}`;
|
|
} else if (aggregateFunction === "year") {
|
|
return date.toFormat("yyyy");
|
|
} else {
|
|
return date.toFormat("MMMM yyyy");
|
|
}
|
|
} else if (Array.isArray(val)) {
|
|
if (val.length === 0) {
|
|
return false;
|
|
}
|
|
return type === "many2many" ? val : val[0];
|
|
} else {
|
|
return val;
|
|
}
|
|
}
|
|
|
|
if (!groupBy.length) {
|
|
const group = { __count: records.length };
|
|
aggregateFields(group, records);
|
|
return [group];
|
|
}
|
|
|
|
const groups = {};
|
|
for (const r of records) {
|
|
let recordGroupValues = [];
|
|
for (const gbField of groupBy) {
|
|
const [fieldName] = gbField.split(":");
|
|
let value = formatValue(gbField, r[fieldName]);
|
|
if (!Array.isArray(value)) {
|
|
value = [value];
|
|
}
|
|
recordGroupValues = value.reduce((acc, val) => {
|
|
const newGroup = {};
|
|
newGroup[gbField] = val;
|
|
if (recordGroupValues.length === 0) {
|
|
acc.push(newGroup);
|
|
} else {
|
|
for (const groupValue of recordGroupValues) {
|
|
acc.push({ ...groupValue, ...newGroup });
|
|
}
|
|
}
|
|
return acc;
|
|
}, []);
|
|
}
|
|
for (const groupValue of recordGroupValues) {
|
|
const valueKey = JSON.stringify(groupValue);
|
|
groups[valueKey] = groups[valueKey] || [];
|
|
groups[valueKey].push(r);
|
|
}
|
|
}
|
|
|
|
let readGroupResult = [];
|
|
for (const [groupId, groupRecords] of Object.entries(groups)) {
|
|
const group = {
|
|
...JSON.parse(groupId),
|
|
__domain: kwargs.domain || [],
|
|
__range: {},
|
|
};
|
|
for (const gbField of groupBy) {
|
|
if (!(gbField in group)) {
|
|
group[gbField] = false;
|
|
continue;
|
|
}
|
|
|
|
const [fieldName, dateRange] = gbField.split(":");
|
|
const value = Number.isInteger(group[gbField])
|
|
? group[gbField]
|
|
: group[gbField] || false;
|
|
const { relation, type } = fields[fieldName];
|
|
|
|
if (["many2one", "many2many"].includes(type) && !Array.isArray(value)) {
|
|
const relatedRecord = this.models[relation].records.find(
|
|
({ id }) => id === value
|
|
);
|
|
if (relatedRecord) {
|
|
group[gbField] = [value, relatedRecord.display_name];
|
|
} else {
|
|
group[gbField] = false;
|
|
}
|
|
}
|
|
|
|
if (["date", "datetime"].includes(type)) {
|
|
if (value) {
|
|
let startDate, endDate;
|
|
switch (dateRange) {
|
|
case "hour": {
|
|
try {
|
|
startDate = parseDateTime(value, { format: "HH dd MMM" });
|
|
} catch {
|
|
startDate = parseDateTime(value, { format: "HH:00 dd MMM" });
|
|
}
|
|
endDate = startDate.plus({ hours: 1 });
|
|
break;
|
|
}
|
|
case "day": {
|
|
startDate = parseDateTime(value, { format: "yyyy-MM-dd" });
|
|
endDate = startDate.plus({ days: 1 });
|
|
break;
|
|
}
|
|
case "week": {
|
|
startDate = parseDateTime(value, { format: "WW kkkk" });
|
|
endDate = startDate.plus({ weeks: 1 });
|
|
break;
|
|
}
|
|
case "quarter": {
|
|
startDate = parseDateTime(value, { format: "q yyyy" });
|
|
endDate = startDate.plus({ quarters: 1 });
|
|
break;
|
|
}
|
|
case "year": {
|
|
startDate = parseDateTime(value, { format: "y" });
|
|
endDate = startDate.plus({ years: 1 });
|
|
break;
|
|
}
|
|
case "month":
|
|
default: {
|
|
startDate = parseDateTime(value, { format: "MMMM yyyy" });
|
|
endDate = startDate.plus({ months: 1 });
|
|
break;
|
|
}
|
|
}
|
|
const from =
|
|
type === "date"
|
|
? startDate.toFormat("yyyy-MM-dd")
|
|
: startDate.toFormat("yyyy-MM-dd HH:mm:ss");
|
|
const to =
|
|
type === "date"
|
|
? endDate.toFormat("yyyy-MM-dd")
|
|
: endDate.toFormat("yyyy-MM-dd HH:mm:ss");
|
|
group.__range[fieldName] = { from, to };
|
|
group.__domain = [
|
|
[fieldName, ">=", from],
|
|
[fieldName, "<", to],
|
|
].concat(group.__domain);
|
|
} else {
|
|
group.__range[fieldName] = false;
|
|
group.__domain = [[fieldName, "=", value]].concat(group.__domain);
|
|
}
|
|
} else {
|
|
group.__domain = [[fieldName, "=", value]].concat(group.__domain);
|
|
}
|
|
}
|
|
if (_.isEmpty(group.__range)) {
|
|
delete group.__range;
|
|
}
|
|
// compute count key to match dumb server logic...
|
|
const countKey = kwargs.lazy ? groupBy[0].split(":")[0] + "_count" : "__count";
|
|
group[countKey] = groupRecords.length;
|
|
aggregateFields(group, groupRecords);
|
|
readGroupResult.push(group);
|
|
}
|
|
|
|
if (kwargs.orderby) {
|
|
// only consider first sorting level
|
|
kwargs.orderby = kwargs.orderby.split(",")[0];
|
|
const fieldName = kwargs.orderby.split(" ")[0];
|
|
const order = kwargs.orderby.split(" ")[1];
|
|
readGroupResult = this.sortByField(readGroupResult, modelName, fieldName, order);
|
|
}
|
|
if (kwargs.limit) {
|
|
const offset = kwargs.offset || 0;
|
|
readGroupResult = readGroupResult.slice(offset, kwargs.limit + offset);
|
|
}
|
|
return readGroupResult;
|
|
}
|
|
|
|
mockWebReadGroup(modelName, kwargs) {
|
|
const groups = this.mockReadGroup(modelName, kwargs);
|
|
if (kwargs.expand && kwargs.groupby.length === 1) {
|
|
groups.forEach((group) => {
|
|
group.__data = this.mockSearchReadController({
|
|
domain: group.__domain,
|
|
model: modelName,
|
|
fields: kwargs.fields,
|
|
limit: kwargs.expand_limit,
|
|
sort: kwargs.expand_orderby,
|
|
});
|
|
});
|
|
}
|
|
const allGroups = this.mockReadGroup(modelName, {
|
|
domain: kwargs.domain,
|
|
fields: ["display_name"],
|
|
groupby: kwargs.groupby,
|
|
lazy: kwargs.lazy,
|
|
});
|
|
return {
|
|
groups: groups,
|
|
length: allGroups.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Simulates a call to the server '_search_panel_field_image' method.
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {string} fieldName
|
|
* @param {Object} kwargs
|
|
* @see mockSearchPanelDomainImage()
|
|
*/
|
|
mockSearchPanelFieldImage(model, fieldName, kwargs) {
|
|
const enableCounters = kwargs.enable_counters;
|
|
const onlyCounters = kwargs.only_counters;
|
|
const extraDomain = kwargs.extra_domain || [];
|
|
const normalizedExtra = new Domain(extraDomain).toList();
|
|
const noExtra = JSON.stringify(normalizedExtra) === "[]";
|
|
const modelDomain = kwargs.model_domain || [];
|
|
const countDomain = new Domain([...modelDomain, ...extraDomain]).toList();
|
|
|
|
const limit = kwargs.limit;
|
|
const setLimit = kwargs.set_limit;
|
|
|
|
if (onlyCounters) {
|
|
return this.mockSearchPanelDomainImage(model, fieldName, countDomain, true);
|
|
}
|
|
|
|
const modelDomainImage = this.mockSearchPanelDomainImage(
|
|
model,
|
|
fieldName,
|
|
modelDomain,
|
|
enableCounters && noExtra,
|
|
setLimit && limit
|
|
);
|
|
if (enableCounters && !noExtra) {
|
|
const countDomainImage = this.mockSearchPanelDomainImage(
|
|
model,
|
|
fieldName,
|
|
countDomain,
|
|
true
|
|
);
|
|
for (const [id, values] of modelDomainImage.entries()) {
|
|
const element = countDomainImage.get(id);
|
|
values.__count = element ? element.__count : 0;
|
|
}
|
|
}
|
|
|
|
return modelDomainImage;
|
|
}
|
|
|
|
/**
|
|
* Simulates a call to the server '_search_panel_domain_image' method.
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array[]} domain
|
|
* @param {string} fieldName
|
|
* @param {boolean} setCount
|
|
* @returns {Map}
|
|
*/
|
|
mockSearchPanelDomainImage(model, fieldName, domain, setCount = false, limit = false) {
|
|
const field = this.models[model].fields[fieldName];
|
|
let groupIdName;
|
|
if (field.type === "many2one") {
|
|
groupIdName = (value) => value || [false, undefined];
|
|
// mockReadGroup does not take care of the condition [fieldName, '!=', false]
|
|
// in the domain defined below !!!
|
|
} else if (field.type === "selection") {
|
|
const selection = {};
|
|
for (const [value, label] of this.models[model].fields[fieldName].selection) {
|
|
selection[value] = label;
|
|
}
|
|
groupIdName = (value) => [value, selection[value]];
|
|
}
|
|
domain = new Domain([...domain, [fieldName, "!=", false]]).toList();
|
|
const groups = this.mockReadGroup(model, {
|
|
domain,
|
|
fields: [fieldName],
|
|
groupby: [fieldName],
|
|
limit,
|
|
});
|
|
const domainImage = new Map();
|
|
for (const group of groups) {
|
|
const [id, display_name] = groupIdName(group[fieldName]);
|
|
const values = { id, display_name };
|
|
if (setCount) {
|
|
values.__count = group[fieldName + "_count"];
|
|
}
|
|
domainImage.set(id, values);
|
|
}
|
|
return domainImage;
|
|
}
|
|
/**
|
|
* Simulates a call to the server '_search_panel_global_counters' method.
|
|
*
|
|
* @private
|
|
* @param {Map} valuesRange
|
|
* @param {(string|boolean)} parentName 'parent_id' or false
|
|
*/
|
|
mockSearchPanelGlobalCounters(valuesRange, parentName) {
|
|
const localCounters = [...valuesRange.keys()].map((id) => valuesRange.get(id).__count);
|
|
for (let [id, values] of valuesRange.entries()) {
|
|
const count = localCounters[id];
|
|
if (count) {
|
|
let parent_id = values[parentName];
|
|
while (parent_id) {
|
|
values = valuesRange.get(parent_id);
|
|
values.__count += count;
|
|
parent_id = values[parentName];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Simulates a call to the server '_search_panel_sanitized_parent_hierarchy' method.
|
|
*
|
|
* @private
|
|
* @param {Object[]} records
|
|
* @param {(string|boolean)} parentName 'parent_id' or false
|
|
* @param {number[]} ids
|
|
* @returns {Object[]}
|
|
*/
|
|
mockSearchPanelSanitizedParentHierarchy(records, parentName, ids) {
|
|
const getParentId = (record) => record[parentName] && record[parentName][0];
|
|
const allowedRecords = {};
|
|
for (const record of records) {
|
|
allowedRecords[record.id] = record;
|
|
}
|
|
const recordsToKeep = {};
|
|
for (const id of ids) {
|
|
const ancestorChain = {};
|
|
let recordId = id;
|
|
let chainIsFullyIncluded = true;
|
|
while (chainIsFullyIncluded && recordId) {
|
|
const knownStatus = recordsToKeep[recordId];
|
|
if (knownStatus !== undefined) {
|
|
chainIsFullyIncluded = knownStatus;
|
|
break;
|
|
}
|
|
const record = allowedRecords[recordId];
|
|
if (record) {
|
|
ancestorChain[recordId] = record;
|
|
recordId = getParentId(record);
|
|
} else {
|
|
chainIsFullyIncluded = false;
|
|
}
|
|
}
|
|
for (const id in ancestorChain) {
|
|
recordsToKeep[id] = chainIsFullyIncluded;
|
|
}
|
|
}
|
|
return records.filter((rec) => recordsToKeep[rec.id]);
|
|
}
|
|
/**
|
|
* Simulates a call to the server 'search_panel_selection_range' method.
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {string} fieldName
|
|
* @param {Object} kwargs
|
|
* @returns {Object[]}
|
|
*/
|
|
mockSearchPanelSelectionRange(model, fieldName, kwargs) {
|
|
const enableCounters = kwargs.enable_counters;
|
|
const expand = kwargs.expand;
|
|
let domainImage;
|
|
if (enableCounters || !expand) {
|
|
const newKwargs = Object.assign({}, kwargs, {
|
|
only_counters: expand,
|
|
});
|
|
domainImage = this.mockSearchPanelFieldImage(model, fieldName, newKwargs);
|
|
}
|
|
if (!expand) {
|
|
return [...domainImage.values()];
|
|
}
|
|
const selection = this.models[model].fields[fieldName].selection;
|
|
const selectionRange = [];
|
|
for (const [value, label] of selection) {
|
|
const values = {
|
|
id: value,
|
|
display_name: label,
|
|
};
|
|
if (enableCounters) {
|
|
values.__count = domainImage.get(value) ? domainImage.get(value).__count : 0;
|
|
}
|
|
selectionRange.push(values);
|
|
}
|
|
return selectionRange;
|
|
}
|
|
|
|
/**
|
|
* Simulates a call to the server 'search_panel_select_range' method.
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {string[]} args
|
|
* @param {string} args[fieldName]
|
|
* @param {Object} [kwargs={}]
|
|
* @param {Array[]} [kwargs.category_domain] domain generated by categories
|
|
* (this parameter is used in _search_panel_range)
|
|
* @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
|
|
* (this parameter is used in _search_panel_range)
|
|
* @param {boolean} [kwargs.enable_counters] whether to count records by value
|
|
* @param {Array[]} [kwargs.filter_domain] domain generated by filters
|
|
* @param {integer} [kwargs.limit] maximal number of values to fetch
|
|
* @param {Array[]} [kwargs.search_domain] base domain of search (this parameter
|
|
* is used in _search_panel_range)
|
|
* @returns {Object}
|
|
*/
|
|
mockSearchPanelSelectRange(model, [fieldName], kwargs) {
|
|
const field = this.models[model].fields[fieldName];
|
|
const supportedTypes = ["many2one", "selection"];
|
|
if (!supportedTypes.includes(field.type)) {
|
|
throw new Error(
|
|
`Only types ${supportedTypes} are supported for category (found type ${field.type})`
|
|
);
|
|
}
|
|
|
|
const modelDomain = kwargs.search_domain || [];
|
|
const extraDomain = new Domain([
|
|
...(kwargs.category_domain || []),
|
|
...(kwargs.filter_domain || []),
|
|
]).toList();
|
|
|
|
if (field.type === "selection") {
|
|
const newKwargs = Object.assign({}, kwargs, {
|
|
model_domain: modelDomain,
|
|
extra_domain: extraDomain,
|
|
});
|
|
kwargs.model_domain = modelDomain;
|
|
return {
|
|
parent_field: false,
|
|
values: this.mockSearchPanelSelectionRange(model, fieldName, newKwargs),
|
|
};
|
|
}
|
|
|
|
const fieldNames = ["display_name"];
|
|
let hierarchize = "hierarchize" in kwargs ? kwargs.hierarchize : true;
|
|
let getParentId;
|
|
let parentName = false;
|
|
if (hierarchize && this.models[field.relation].fields.parent_id) {
|
|
parentName = "parent_id"; // in tests, parent field is always 'parent_id'
|
|
fieldNames.push(parentName);
|
|
getParentId = (record) => record.parent_id && record.parent_id[0];
|
|
} else {
|
|
hierarchize = false;
|
|
}
|
|
let comodelDomain = kwargs.comodel_domain || [];
|
|
const enableCounters = kwargs.enable_counters;
|
|
const expand = kwargs.expand;
|
|
const limit = kwargs.limit;
|
|
let domainImage;
|
|
if (enableCounters || !expand) {
|
|
const newKwargs = Object.assign({}, kwargs, {
|
|
model_domain: modelDomain,
|
|
extra_domain: extraDomain,
|
|
only_counters: expand,
|
|
set_limit: limit && !(expand || hierarchize || comodelDomain),
|
|
});
|
|
domainImage = this.mockSearchPanelFieldImage(model, fieldName, newKwargs);
|
|
}
|
|
if (!expand && !hierarchize && !comodelDomain.length) {
|
|
if (limit && domainImage.size === limit) {
|
|
return { error_msg: "Too many items to display." };
|
|
}
|
|
return {
|
|
parent_field: parentName,
|
|
values: [...domainImage.values()],
|
|
};
|
|
}
|
|
let imageElementIds;
|
|
if (!expand) {
|
|
imageElementIds = [...domainImage.keys()].map(Number);
|
|
let condition;
|
|
if (hierarchize) {
|
|
const records = this.models[field.relation].records;
|
|
const ancestorIds = new Set();
|
|
for (const id of imageElementIds) {
|
|
let recordId = id;
|
|
let record;
|
|
while (recordId) {
|
|
ancestorIds.add(recordId);
|
|
record = records.find((rec) => rec.id === recordId);
|
|
recordId = record[parentName];
|
|
}
|
|
}
|
|
condition = ["id", "in", [...new Set(ancestorIds)]];
|
|
} else {
|
|
condition = ["id", "in", imageElementIds];
|
|
}
|
|
comodelDomain = new Domain([...comodelDomain, condition]).toList();
|
|
}
|
|
let comodelRecords = this.mockSearchRead(field.relation, [comodelDomain, fieldNames], {
|
|
limit,
|
|
});
|
|
|
|
if (hierarchize) {
|
|
const ids = expand ? comodelRecords.map((rec) => rec.id) : imageElementIds;
|
|
comodelRecords = this.mockSearchPanelSanitizedParentHierarchy(
|
|
comodelRecords,
|
|
parentName,
|
|
ids
|
|
);
|
|
}
|
|
|
|
if (limit && comodelRecords.length === limit) {
|
|
return { error_msg: "Too many items to display." };
|
|
}
|
|
// A map is used to keep the initial order.
|
|
const fieldRange = new Map();
|
|
for (const record of comodelRecords) {
|
|
const values = {
|
|
id: record.id,
|
|
display_name: record.display_name,
|
|
};
|
|
if (hierarchize) {
|
|
values[parentName] = getParentId(record);
|
|
}
|
|
if (enableCounters) {
|
|
values.__count = domainImage.get(record.id)
|
|
? domainImage.get(record.id).__count
|
|
: 0;
|
|
}
|
|
fieldRange.set(record.id, values);
|
|
}
|
|
|
|
if (hierarchize && enableCounters) {
|
|
this.mockSearchPanelGlobalCounters(fieldRange, parentName);
|
|
}
|
|
|
|
return {
|
|
parent_field: parentName,
|
|
values: [...fieldRange.values()],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Simulates a call to the server 'search_panel_select_multi_range' method.
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {string[]} args
|
|
* @param {string} args[fieldName]
|
|
* @param {Object} [kwargs={}]
|
|
* @param {Array[]} [kwargs.category_domain] domain generated by categories
|
|
* @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
|
|
* (this parameter is used in _search_panel_range)
|
|
* @param {boolean} [kwargs.enable_counters] whether to count records by value
|
|
* @param {Array[]} [kwargs.filter_domain] domain generated by filters
|
|
* @param {string} [kwargs.group_by] extra field to read on comodel, to group
|
|
* comodel records
|
|
* @param {Array[]} [kwargs.group_domain] dict, one domain for each activated
|
|
* group for the group_by (if any). Those domains are used to fech accurate
|
|
* counters for values in each group
|
|
* @param {integer} [kwargs.limit] maximal number of values to fetch
|
|
* @param {Array[]} [kwargs.search_domain] base domain of search
|
|
* @returns {Object}
|
|
*/
|
|
mockSearchPanelSelectMultiRange(model, [fieldName], kwargs) {
|
|
const field = this.models[model].fields[fieldName];
|
|
const supportedTypes = ["many2one", "many2many", "selection"];
|
|
if (!supportedTypes.includes(field.type)) {
|
|
throw new Error(
|
|
`Only types ${supportedTypes} are supported for filter (found type ${field.type})`
|
|
);
|
|
}
|
|
let modelDomain = kwargs.search_domain || [];
|
|
let extraDomain = new Domain([
|
|
...(kwargs.category_domain || []),
|
|
...(kwargs.filter_domain || []),
|
|
]).toList();
|
|
if (field.type === "selection") {
|
|
const newKwargs = Object.assign({}, kwargs, {
|
|
model_domain: modelDomain,
|
|
extra_domain: extraDomain,
|
|
});
|
|
return {
|
|
values: this.mockSearchPanelSelectionRange(model, fieldName, newKwargs),
|
|
};
|
|
}
|
|
const fieldNames = ["display_name"];
|
|
const groupBy = kwargs.group_by;
|
|
let groupIdName;
|
|
if (groupBy) {
|
|
const groupByField = this.models[field.relation].fields[groupBy];
|
|
fieldNames.push(groupBy);
|
|
if (groupByField.type === "many2one") {
|
|
groupIdName = (value) => value || [false, "Not set"];
|
|
} else if (groupByField.type === "selection") {
|
|
const groupBySelection = Object.assign(
|
|
{},
|
|
this.models[field.relation].fields[groupBy].selection
|
|
);
|
|
groupBySelection[false] = "Not Set";
|
|
groupIdName = (value) => [value, groupBySelection[value]];
|
|
} else {
|
|
groupIdName = (value) => (value ? [value, value] : [false, "Not set"]);
|
|
}
|
|
}
|
|
let comodelDomain = kwargs.comodel_domain || [];
|
|
const enableCounters = kwargs.enable_counters;
|
|
const expand = kwargs.expand;
|
|
const limit = kwargs.limit;
|
|
if (field.type === "many2many") {
|
|
const comodelRecords = this.mockSearchRead(
|
|
field.relation,
|
|
[comodelDomain, fieldNames],
|
|
{
|
|
limit,
|
|
}
|
|
);
|
|
if (expand && limit && comodelRecords.length === limit) {
|
|
return { error_msg: "Too many items to display." };
|
|
}
|
|
|
|
const groupDomain = kwargs.group_domain;
|
|
const fieldRange = [];
|
|
for (const record of comodelRecords) {
|
|
const values = {
|
|
id: record.id,
|
|
display_name: record.display_name,
|
|
};
|
|
let groupId;
|
|
if (groupBy) {
|
|
const [gId, gName] = groupIdName(record[groupBy]);
|
|
values.group_id = groupId = gId;
|
|
values.group_name = gName;
|
|
}
|
|
let count;
|
|
let inImage;
|
|
if (enableCounters || !expand) {
|
|
const searchDomain = new Domain([
|
|
...modelDomain,
|
|
[fieldName, "in", record.id],
|
|
]).toList();
|
|
let localExtraDomain = extraDomain;
|
|
if (groupBy && groupDomain) {
|
|
localExtraDomain = new Domain([
|
|
...localExtraDomain,
|
|
...(groupDomain[JSON.stringify(groupId)] || []),
|
|
]).toList();
|
|
}
|
|
const searchCountDomain = new Domain([
|
|
...searchDomain,
|
|
...localExtraDomain,
|
|
]).toList();
|
|
if (enableCounters) {
|
|
count = this.mockSearchCount(model, [searchCountDomain]);
|
|
}
|
|
if (!expand) {
|
|
if (enableCounters && JSON.stringify(localExtraDomain) === "[]") {
|
|
inImage = count;
|
|
} else {
|
|
inImage = this.mockSearch(model, [searchDomain], { limit: 1 }).length;
|
|
}
|
|
}
|
|
}
|
|
if (expand || inImage) {
|
|
if (enableCounters) {
|
|
values.__count = count;
|
|
}
|
|
fieldRange.push(values);
|
|
}
|
|
}
|
|
|
|
if (!expand && limit && fieldRange.length === limit) {
|
|
return { error_msg: "Too many items to display." };
|
|
}
|
|
|
|
return { values: fieldRange };
|
|
}
|
|
|
|
if (field.type === "many2one") {
|
|
let domainImage;
|
|
if (enableCounters || !expand) {
|
|
extraDomain = new Domain([...extraDomain, ...(kwargs.group_domain || [])]).toList();
|
|
modelDomain = new Domain([...modelDomain, ...(kwargs.group_domain || [])]).toList();
|
|
const newKwargs = Object.assign({}, kwargs, {
|
|
model_domain: modelDomain,
|
|
extra_domain: extraDomain,
|
|
only_counters: expand,
|
|
set_limit: limit && !(expand || groupBy || comodelDomain),
|
|
});
|
|
domainImage = this.mockSearchPanelFieldImage(model, fieldName, newKwargs);
|
|
}
|
|
if (!expand && !groupBy && !comodelDomain.length) {
|
|
if (limit && domainImage.size === limit) {
|
|
return { error_msg: "Too many items to display." };
|
|
}
|
|
return { values: [...domainImage.values()] };
|
|
}
|
|
if (!expand) {
|
|
const imageElementIds = [...domainImage.keys()].map(Number);
|
|
comodelDomain = new Domain([
|
|
...comodelDomain,
|
|
["id", "in", imageElementIds],
|
|
]).toList();
|
|
}
|
|
const comodelRecords = this.mockSearchRead(
|
|
field.relation,
|
|
[comodelDomain, fieldNames],
|
|
{
|
|
limit,
|
|
}
|
|
);
|
|
if (limit && comodelRecords.length === limit) {
|
|
return { error_msg: "Too many items to display." };
|
|
}
|
|
|
|
const fieldRange = [];
|
|
for (const record of comodelRecords) {
|
|
const values = {
|
|
id: record.id,
|
|
display_name: record.display_name,
|
|
};
|
|
if (groupBy) {
|
|
const [groupId, groupName] = groupIdName(record[groupBy]);
|
|
values.group_id = groupId;
|
|
values.group_name = groupName;
|
|
}
|
|
if (enableCounters) {
|
|
values.__count = domainImage.get(record.id)
|
|
? domainImage.get(record.id).__count
|
|
: 0;
|
|
}
|
|
fieldRange.push(values);
|
|
}
|
|
return { values: fieldRange };
|
|
}
|
|
}
|
|
|
|
mockSearch(modelName, args, kwargs) {
|
|
const result = this.mockSearchController({
|
|
model: modelName,
|
|
domain: kwargs.domain || args[0],
|
|
fields: kwargs.fields || args[1],
|
|
offset: kwargs.offset || args[2],
|
|
limit: kwargs.limit || args[3],
|
|
sort: kwargs.order || args[4],
|
|
context: kwargs.context,
|
|
});
|
|
return result.records;
|
|
}
|
|
|
|
/**
|
|
* Simulate a 'search_count' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {integer}
|
|
*/
|
|
mockSearchCount(model, args) {
|
|
return this.getRecords(model, args[0]).length;
|
|
}
|
|
|
|
mockSearchRead(modelName, args, kwargs) {
|
|
const result = this.mockSearchReadController({
|
|
model: modelName,
|
|
domain: kwargs.domain || args[0],
|
|
fields: kwargs.fields || args[1],
|
|
offset: kwargs.offset || args[2],
|
|
limit: kwargs.limit || args[3],
|
|
sort: kwargs.order || args[4],
|
|
context: kwargs.context,
|
|
});
|
|
return result.records;
|
|
}
|
|
|
|
mockWebSearchRead(modelName, args, kwargs) {
|
|
const result = this.mockSearchReadController({
|
|
model: modelName,
|
|
domain: kwargs.domain || args[0],
|
|
fields: kwargs.fields || args[1],
|
|
offset: kwargs.offset || args[2],
|
|
limit: kwargs.limit || args[3],
|
|
sort: kwargs.order || args[4],
|
|
context: kwargs.context,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
mockSearchController(params) {
|
|
const model = this.models[params.model];
|
|
let fieldNames = params.fields;
|
|
const offset = params.offset || 0;
|
|
if (!fieldNames || !fieldNames.length) {
|
|
fieldNames = Object.keys(model.fields);
|
|
}
|
|
fieldNames = [...new Set(fieldNames.concat(["id"]))];
|
|
const { context } = params;
|
|
const active_test = context && "active_test" in context ? context.active_test : true;
|
|
let records = this.getRecords(
|
|
params.model,
|
|
params.domain || [],
|
|
Object.assign({}, params.context, { active_test })
|
|
);
|
|
if (params.sort) {
|
|
// warning: only consider first level of sort
|
|
params.sort = params.sort.split(",")[0];
|
|
const fieldName = params.sort.split(" ")[0];
|
|
const order = params.sort.split(" ")[1];
|
|
records = this.sortByField(records, params.model, fieldName, order);
|
|
}
|
|
const nbRecords = records.length;
|
|
records = records.slice(offset, params.limit ? offset + params.limit : nbRecords);
|
|
return {
|
|
fieldNames,
|
|
length: nbRecords,
|
|
records,
|
|
};
|
|
}
|
|
|
|
mockSearchReadController(params) {
|
|
const { fieldNames, length, records } = this.mockSearchController(params);
|
|
return {
|
|
length,
|
|
records: this.mockRead(params.model, [records.map((r) => r.id), fieldNames]),
|
|
};
|
|
}
|
|
|
|
mockWrite(modelName, args) {
|
|
args[0].forEach((id) => this.writeRecord(modelName, args[1], id));
|
|
return true;
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// Private
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
evaluateDomain(domain, record) {
|
|
return new Domain(domain).contains(record);
|
|
}
|
|
|
|
/**
|
|
* Get all records from a model matching a domain. The only difficulty is
|
|
* that if we have an 'active' field, we implicitely add active = true in
|
|
* the domain.
|
|
*/
|
|
getRecords(modelName, domain, { active_test = true } = {}) {
|
|
if (!Array.isArray(domain)) {
|
|
throw new Error("MockServer._getRecords: given domain has to be an array.");
|
|
}
|
|
const model = this.models[modelName];
|
|
// add ['active', '=', true] to the domain if 'active' is not yet present in domain
|
|
if (active_test && "active" in model.fields) {
|
|
const activeInDomain = domain.some((subDomain) => subDomain[0] === "active");
|
|
if (!activeInDomain) {
|
|
domain = domain.concat([["active", "=", true]]);
|
|
}
|
|
}
|
|
let records = model.records;
|
|
if (domain.length) {
|
|
domain = domain.map((criterion) => {
|
|
// 'child_of' operator isn't supported by domain.js, so we replace
|
|
// in by the 'in' operator (with the ids of children)
|
|
if (criterion[1] === "child_of") {
|
|
let oldLength = 0;
|
|
const childIDs = [criterion[2]];
|
|
while (childIDs.length > oldLength) {
|
|
oldLength = childIDs.length;
|
|
records.forEach((r) => {
|
|
if (childIDs.indexOf(r.parent_id) >= 0) {
|
|
childIDs.push(r.id);
|
|
}
|
|
});
|
|
}
|
|
criterion = [criterion[0], "in", childIDs];
|
|
}
|
|
// In case of many2many field, if domain operator is '=' generally change it to 'in' operator
|
|
const field = model.fields[criterion[0]] || {};
|
|
if (field.type === "many2many" && criterion[1] === "=") {
|
|
if (criterion[2] === false) {
|
|
// if undefined value asked, domain.js require equality with empty array
|
|
criterion = [criterion[0], "=", []];
|
|
} else {
|
|
criterion = [criterion[0], "in", [criterion[2]]];
|
|
}
|
|
}
|
|
return criterion;
|
|
});
|
|
records = records.filter((record) => this.evaluateDomain(domain, record));
|
|
}
|
|
return records;
|
|
}
|
|
|
|
sortByField(records, modelName, fieldName, order) {
|
|
const field = this.models[modelName].fields[fieldName];
|
|
records.sort((r1, r2) => {
|
|
let v1 = r1[fieldName];
|
|
let v2 = r2[fieldName];
|
|
if (field.type === "many2one") {
|
|
const coRecords = this.models[field.relation].records;
|
|
if (this.models[field.relation].fields.sequence) {
|
|
// use sequence field of comodel to sort records
|
|
v1 = coRecords.find((r) => r.id === v1[0]).sequence;
|
|
v2 = coRecords.find((r) => r.id === v2[0]).sequence;
|
|
} else {
|
|
// sort by id
|
|
v1 = v1[0];
|
|
v2 = v2[0];
|
|
}
|
|
}
|
|
if (v1 < v2) {
|
|
return order === "ASC" ? -1 : 1;
|
|
}
|
|
if (v1 > v2) {
|
|
return order === "ASC" ? 1 : -1;
|
|
}
|
|
return 0;
|
|
});
|
|
return records;
|
|
}
|
|
|
|
writeRecord(modelName, values, id, { ensureIntegrity = true } = {}) {
|
|
const model = this.models[modelName];
|
|
const record = model.records.find((r) => r.id === id);
|
|
for (const fieldName in values) {
|
|
const field = model.fields[fieldName];
|
|
let value = values[fieldName];
|
|
if (!field) {
|
|
throw Error(
|
|
`Mock: Can't write value "${JSON.stringify(
|
|
value
|
|
)}" on field "${fieldName}" on record "${model},${id}" (field is undefined)`
|
|
);
|
|
}
|
|
if (["one2many", "many2many"].includes(field.type)) {
|
|
let ids = record[fieldName] ? record[fieldName].slice() : [];
|
|
if (Array.isArray(value)) {
|
|
if (
|
|
value.reduce((hasOnlyInt, val) => hasOnlyInt && Number.isInteger(val), true)
|
|
) {
|
|
// fallback to command 6 when given a simple list of ids
|
|
value = [[6, 0, value]];
|
|
}
|
|
} else if (value === false) {
|
|
// delete all command
|
|
value = [[5]];
|
|
}
|
|
// interpret commands
|
|
for (const command of value || []) {
|
|
if (command[0] === 0) {
|
|
// CREATE
|
|
const newId = this.mockCreate(field.relation, command[2]);
|
|
ids.push(newId);
|
|
} else if (command[0] === 1) {
|
|
// UPDATE
|
|
this.mockWrite(field.relation, [[command[1]], command[2]]);
|
|
} else if (command[0] === 2 || command[0] === 3) {
|
|
// DELETE or FORGET
|
|
ids.splice(ids.indexOf(command[1]), 1);
|
|
} else if (command[0] === 4) {
|
|
// LINK_TO
|
|
if (!ids.includes(command[1])) {
|
|
ids.push(command[1]);
|
|
}
|
|
} else if (command[0] === 5) {
|
|
// DELETE ALL
|
|
ids = [];
|
|
} else if (command[0] === 6) {
|
|
// REPLACE WITH
|
|
// copy array to avoid leak by reference (eg. of default data)
|
|
ids = [...command[2]];
|
|
} else {
|
|
throw Error(
|
|
`Command "${JSON.stringify(
|
|
value
|
|
)}" not supported by the MockServer on field "${fieldName}" on record "${model},${id}"`
|
|
);
|
|
}
|
|
}
|
|
record[fieldName] = ids;
|
|
} else if (field.type === "many2one") {
|
|
if (value) {
|
|
const relRecord = this.models[field.relation].records.find(
|
|
(r) => r.id === value
|
|
);
|
|
if (!relRecord && ensureIntegrity) {
|
|
throw Error(
|
|
`Wrong id "${JSON.stringify(
|
|
value
|
|
)}" for a many2one on field "${fieldName}" on record "${model},${id}"`
|
|
);
|
|
}
|
|
record[fieldName] = value;
|
|
} else {
|
|
record[fieldName] = false;
|
|
}
|
|
} else {
|
|
record[fieldName] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
getUnusedID(modelName) {
|
|
const model = this.models[modelName];
|
|
return (
|
|
model.records.reduce((max, record) => {
|
|
if (!Number.isInteger(record.id)) {
|
|
return max;
|
|
}
|
|
return Math.max(record.id, max);
|
|
}, 0) + 1
|
|
);
|
|
}
|
|
|
|
applyDefaults(model, record) {
|
|
record.display_name = record.display_name || record.name;
|
|
for (const fieldName in model.fields) {
|
|
if (fieldName === "id") {
|
|
continue;
|
|
}
|
|
if (!(fieldName in record)) {
|
|
if ("default" in model.fields[fieldName]) {
|
|
const def = model.fields[fieldName].default;
|
|
record[fieldName] = typeof def === "function" ? def.call(this) : def;
|
|
} else if (["one2many", "many2many"].includes(model.fields[fieldName].type)) {
|
|
record[fieldName] = [];
|
|
} else {
|
|
record[fieldName] = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// MockServer deployment helper
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export function makeMockServer(serverData, mockRPC) {
|
|
serverData = serverData || {};
|
|
const mockServer = new MockServer(serverData, {
|
|
debug: QUnit.config.debug,
|
|
});
|
|
const _mockRPC = async (route, args = {}) => {
|
|
let res;
|
|
const performRPC = (route, args) => mockServer.performRPC(route, args);
|
|
if (mockRPC) {
|
|
res = await mockRPC(route, args, performRPC);
|
|
}
|
|
if (res === undefined) {
|
|
res = await performRPC(route, args);
|
|
}
|
|
return res;
|
|
};
|
|
const rpcService = makeFakeRPCService(_mockRPC);
|
|
patchWithCleanup(browser, {
|
|
fetch: makeMockFetch(_mockRPC),
|
|
});
|
|
// Replace RPC service
|
|
serviceRegistry.add("rpc", rpcService, { force: true });
|
|
}
|