Commitb743fdadoesn't seem to work properly. Steps to reproduce: - Open a spreadsheet A in one tab - Open a spreadsheet B in another tab => the master tab should poll both spreadsheet channels but it does not. The condition here sheems wrong https://github.com/odoo/odoo/blob/bf2ce0aa0c7d45c90d7ff104d5af5f99b29d59ae/addons/bus/static/src/js/crosstab_bus.js#L299 `peerChannelsBefore` is not really "before the channel was added". It's "before outdated channels are cleared". It already contains the new channel added to local storage by the slave tab! Hence it wrongly returns `false` which means the poll request is not restarted. Additional fix required sincee2aeb5f------------------------------------- Commite2aeb5fbroke a little more the feature. It is assumed the ids in `channels` are matching the ids in `lastPresenceByTab` (previously named `peers`). It was no longer true since `lastPresenceByTab` is now managed by multi_tab service (with it's own id). With those non-matching ids, channels from other tabs were always considered outdated and therefore cleaned up. Small comment on the change in the mock server ---------------------------------------------- Cross tab bus tests were not working properly. The error message string "XmlHttpRequestError abort" was stringified (stringifying a string), leading to "\"XmlHttpRequestError abort\"". Since the implementation depends on the exact error message, tests didn't propely reflect reality. This is technically not needed anymore since bus tests are no longer using the legacy mock server but it can't hurt to fix it. (the new mock server doesn't have this issue) closes odoo/odoo#97036 X-original-commit: f25f36a Signed-off-by: David Monjoie (dmo) <dmo@odoo.com> Co-authored-by: Goaman <nby@odoo.com> Co-authored-by: tsm-odoo <tsm@odoo.com>
2258 lines
88 KiB
JavaScript
2258 lines
88 KiB
JavaScript
odoo.define('web.MockServer', function (require) {
|
|
"use strict";
|
|
|
|
var Class = require('web.Class');
|
|
var Domain = require('web.Domain');
|
|
var pyUtils = require('web.py_utils');
|
|
|
|
var MockServer = Class.extend({
|
|
/**
|
|
* @constructor
|
|
* @param {Object} data
|
|
* @param {Object} options
|
|
* @param {Object[]} [options.actions=[]]
|
|
* @param {Object} [options.archs={}] dict of archs with keys being strings like
|
|
* 'model,id,viewType'
|
|
* @param {boolean} [options.debug=false] logs RPCs if set to true
|
|
* @param {string} [options.currentDate] formatted string, default to
|
|
* current day
|
|
*/
|
|
init: function (data, options) {
|
|
options = options || {};
|
|
this.data = data;
|
|
for (var modelName in this.data) {
|
|
var model = this.data[modelName];
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
// used to prevent _updateComodelRelationalFields to be trigerred during
|
|
// initial record creation.
|
|
this.isInitialized = true;
|
|
|
|
// fill relational fields' inverse.
|
|
for (const modelName in this.data) {
|
|
this.data[modelName].records.forEach(record => this._updateComodelRelationalFields(modelName, record));
|
|
}
|
|
this.debug = options.debug;
|
|
|
|
this.currentDate = options.currentDate || moment().format("YYYY-MM-DD");
|
|
|
|
this.actions = options.actions || [];
|
|
this.archs = options.archs || {};
|
|
},
|
|
|
|
/**
|
|
* Perform asynchronous setup after the initialization of the mockServer.
|
|
*/
|
|
setup: async function () {},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Public
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Mocks a fields_get RPC for a given model.
|
|
*
|
|
* @param {string} model
|
|
* @returns {Object}
|
|
*/
|
|
fieldsGet: function (model) {
|
|
return this.data[model].fields;
|
|
},
|
|
/**
|
|
* helper: read a string describing an arch, and returns a simulated
|
|
* 'view_get' call to the server. Calls processViews() of data_manager
|
|
* to mimick the real behavior of a call to loadViews().
|
|
*
|
|
* @param {Object} params
|
|
* @param {string|Object} params.arch a string OR a parsed xml document
|
|
* @param {Number} [params.view_id] the id of the arch's view
|
|
* @param {string} params.model a model name (that should be in this.data)
|
|
* @param {Object} params.toolbar the actions possible in the toolbar
|
|
* @param {Object} [params.viewOptions] the view options set in the test (optional)
|
|
* @returns {Object} an object with 2 keys: arch and fields
|
|
*/
|
|
getView: function (params) {
|
|
var model = params.model;
|
|
var toolbar = params.toolbar;
|
|
var viewId = params.view_id;
|
|
var viewOptions = params.viewOptions || {};
|
|
if (!(model in this.data)) {
|
|
throw new Error('Model ' + model + ' was not defined in mock server data');
|
|
}
|
|
var fields = $.extend(true, {}, this.data[model].fields);
|
|
var view = this._getView(params.arch, model, fields, viewOptions.context || {});
|
|
if (toolbar) {
|
|
view.toolbar = toolbar;
|
|
}
|
|
if (viewId) {
|
|
view.id = viewId;
|
|
}
|
|
return view;
|
|
},
|
|
/**
|
|
* Simulates a complete fetch call.
|
|
*
|
|
* @param {string} resource
|
|
* @param {Object} init
|
|
* @returns {any}
|
|
*/
|
|
async performFetch(resource, init) {
|
|
if (this.debug) {
|
|
console.log(
|
|
'%c[fetch] request ' + resource, 'color: blue; font-weight: bold;',
|
|
JSON.parse(JSON.stringify(init))
|
|
);
|
|
}
|
|
const res = await this._performFetch(resource, init);
|
|
if (this.debug) {
|
|
console.log('%c[fetch] response' + resource, 'color: blue; font-weight: bold;', res);
|
|
}
|
|
return res;
|
|
},
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param {string} route
|
|
* @param {Object} args
|
|
* @returns {Promise<any>}
|
|
* Resolved with the result of the RPC, stringified then parsed.
|
|
* If the RPC should fail, the promise will be rejected with the
|
|
* error object, stringified then parsed.
|
|
*/
|
|
performRpc: function (route, args) {
|
|
var debug = this.debug;
|
|
args = JSON.parse(JSON.stringify(args));
|
|
if (debug) {
|
|
console.log('%c[rpc] request ' + route, 'color: blue; font-weight: bold;', args);
|
|
args = JSON.parse(JSON.stringify(args));
|
|
}
|
|
var def = this._performRpc(route, args);
|
|
|
|
var abort = def.abort || def.reject;
|
|
if (abort) {
|
|
abort = abort.bind(def);
|
|
} else {
|
|
abort = function () {
|
|
throw new Error("Can't abort this request");
|
|
};
|
|
}
|
|
|
|
def = def.then(function (result) {
|
|
var resultString = JSON.stringify(result || false);
|
|
if (debug) {
|
|
console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString));
|
|
}
|
|
return JSON.parse(resultString);
|
|
}, function (result) {
|
|
var message = result && result.message;
|
|
var event = result && result.event;
|
|
var errorString = typeof message !== "string" ? JSON.stringify(message || false) : message;
|
|
if (debug) {
|
|
console.warn(
|
|
'%c[rpc] response (error) %s%s, during test %s',
|
|
'color: orange; font-weight: bold;',
|
|
route,
|
|
message != null && ` -> ${errorString}`,
|
|
JSON.stringify(QUnit.config.current.testName)
|
|
);
|
|
}
|
|
return Promise.reject({message: errorString, event: event || $.Event()});
|
|
});
|
|
|
|
def.abort = abort;
|
|
return def;
|
|
},
|
|
|
|
//--------------------------------------------------------------------------
|
|
// Private
|
|
//--------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Apply the default values when creating an object in the local database.
|
|
*
|
|
* @private
|
|
* @param {Object} model a model object from the local database
|
|
* @param {Object} record
|
|
*/
|
|
_applyDefaults: function (model, record) {
|
|
record.display_name = record.display_name || record.name;
|
|
for (var 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 (_.contains(['one2many', 'many2many'], model.fields[fieldName].type)) {
|
|
record[fieldName] = [];
|
|
} else {
|
|
record[fieldName] = false;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* 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.
|
|
*
|
|
* @private
|
|
* @param {string} model: the model's name
|
|
* @param {Object} values: an object representing a record
|
|
* @returns {Object}
|
|
*/
|
|
_convertToOnChange(model, values) {
|
|
Object.entries(values).forEach(([fname, val]) => {
|
|
const field = this.data[model].fields[fname];
|
|
if (field.type === 'many2one' && typeof val === 'number') {
|
|
// implicit name_get
|
|
const m2oRecord = this.data[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 = val.length && 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;
|
|
},
|
|
/**
|
|
* helper to evaluate a domain for given field values.
|
|
* Currently, this is only a wrapper of the Domain.compute function in
|
|
* "web.Domain".
|
|
*
|
|
* @param {Array} domain
|
|
* @param {Object} fieldValues
|
|
* @returns {boolean}
|
|
*/
|
|
_evaluateDomain: function (domain, fieldValues) {
|
|
return new Domain(domain).compute(fieldValues);
|
|
},
|
|
/**
|
|
* helper: read a string describing an arch, and returns a simulated
|
|
* 'get_view' call to the server.
|
|
*
|
|
* @private
|
|
* @param {string} arch a string OR a parsed xml document
|
|
* @param {string} model a model name (that should be in this.data)
|
|
* @param {Object} fields
|
|
* @param {Object} context
|
|
* @returns {Object} an object with 2 keys: arch and fields (the fields
|
|
* appearing in the views)
|
|
*/
|
|
_getView: function (arch, model, fields, context) {
|
|
var self = this;
|
|
var modifiersNames = ['invisible', 'readonly', 'required'];
|
|
var onchanges = this.data[model].onchanges || {};
|
|
var fieldNodes = {};
|
|
var groupbyNodes = {};
|
|
const relatedModels = new Set([model]);
|
|
|
|
var doc;
|
|
if (typeof arch === 'string') {
|
|
doc = $.parseXML(arch).documentElement;
|
|
} else {
|
|
doc = arch;
|
|
}
|
|
|
|
var inTreeView = (doc.tagName === 'tree');
|
|
|
|
// mock _postprocess_access_rights
|
|
const isBaseModel = !context.base_model_name || (model === context.base_model_name);
|
|
var views = ['kanban', 'tree', 'form', 'gantt', 'activity'];
|
|
if (isBaseModel && views.indexOf(doc.tagName) !== -1) {
|
|
for (let action of ['create', 'delete', 'edit', 'write']) {
|
|
if (!doc.getAttribute(action) && action in context && !context[action]) {
|
|
doc.setAttribute(action, 'false');
|
|
}
|
|
}
|
|
}
|
|
|
|
this._traverse(doc, function (node) {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return false;
|
|
}
|
|
var modifiers = {};
|
|
|
|
var isField = (node.tagName === 'field');
|
|
var isGroupby = (node.tagName === 'groupby');
|
|
|
|
if (isField) {
|
|
var fieldName = node.getAttribute('name');
|
|
fieldNodes[fieldName] = node;
|
|
|
|
// 'transfer_field_to_modifiers' simulation
|
|
var field = fields[fieldName];
|
|
|
|
if (!field) {
|
|
throw new Error("Field " + fieldName + " does not exist");
|
|
}
|
|
var defaultValues = {};
|
|
var stateExceptions = {};
|
|
_.each(modifiersNames, function (attr) {
|
|
stateExceptions[attr] = [];
|
|
defaultValues[attr] = !!field[attr];
|
|
});
|
|
_.each(field.states || {}, function (modifs, state) {
|
|
_.each(modifs, function (modif) {
|
|
if (defaultValues[modif[0]] !== modif[1]) {
|
|
stateExceptions[modif[0]].append(state);
|
|
}
|
|
});
|
|
});
|
|
_.each(defaultValues, function (defaultValue, attr) {
|
|
if (stateExceptions[attr].length) {
|
|
modifiers[attr] = [("state", defaultValue ? "not in" : "in", stateExceptions[attr])];
|
|
} else {
|
|
modifiers[attr] = defaultValue;
|
|
}
|
|
});
|
|
} else if (isGroupby && !node._isProcessed) {
|
|
var groupbyName = node.getAttribute('name');
|
|
fieldNodes[groupbyName] = node;
|
|
groupbyNodes[groupbyName] = node;
|
|
}
|
|
|
|
// 'transfer_node_to_modifiers' simulation
|
|
var attrs = node.getAttribute('attrs');
|
|
if (attrs) {
|
|
attrs = pyUtils.py_eval(attrs);
|
|
_.extend(modifiers, attrs);
|
|
}
|
|
|
|
var 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');
|
|
_.each(modifiersNames, function (a) {
|
|
var mod = node.getAttribute(a);
|
|
if (mod) {
|
|
var pyevalContext = window.py.dict.fromJSON(context || {});
|
|
var v = pyUtils.py_eval(mod, {context: pyevalContext}) ? true: false;
|
|
if (inTreeView && !inListHeader && a === 'invisible') {
|
|
modifiers.column_invisible = v;
|
|
} else if (v || !(a in modifiers) || !_.isArray(modifiers[a])) {
|
|
modifiers[a] = v;
|
|
}
|
|
}
|
|
});
|
|
|
|
_.each(modifiersNames, function (a) {
|
|
if (a in modifiers && (!!modifiers[a] === false || (_.isArray(modifiers[a]) && !modifiers[a].length))) {
|
|
delete modifiers[a];
|
|
}
|
|
});
|
|
|
|
if (Object.keys(modifiers).length) {
|
|
node.setAttribute('modifiers', JSON.stringify(modifiers));
|
|
}
|
|
|
|
if (isGroupby && !node._isProcessed) {
|
|
return false;
|
|
}
|
|
|
|
return !isField;
|
|
});
|
|
|
|
let relModel, relFields;
|
|
_.each(fieldNodes, function (node, name) {
|
|
var field = fields[name];
|
|
if (field.type === "many2one" || field.type === "many2many") {
|
|
var canCreate = node.getAttribute('can_create');
|
|
node.setAttribute('can_create', canCreate || "true");
|
|
var canWrite = node.getAttribute('can_write');
|
|
node.setAttribute('can_write', canWrite || "true");
|
|
}
|
|
if (field.type === "one2many" || field.type === "many2many") {
|
|
relModel = field.relation;
|
|
relatedModels.add(relModel);
|
|
_.each(node.childNodes, function (childNode) {
|
|
if (childNode.tagName) { // skip text nodes
|
|
relFields = $.extend(true, {}, self.data[relModel].fields);
|
|
// this is hackhish, but _getView modifies the subview document in place,
|
|
// especially to generate the "modifiers" attribute
|
|
const { models } = self._getView(childNode, relModel,
|
|
relFields, _.extend({}, context, {base_model_name: model}));
|
|
[...models].forEach((modelName) => relatedModels.add(modelName));
|
|
}
|
|
});
|
|
}
|
|
|
|
// add onchanges
|
|
if (name in onchanges) {
|
|
node.setAttribute('on_change', "1");
|
|
}
|
|
});
|
|
_.each(groupbyNodes, function (node, name) {
|
|
var field = fields[name];
|
|
if (field.type !== 'many2one') {
|
|
throw new Error('groupby can only target many2one');
|
|
}
|
|
field.views = {};
|
|
relModel = field.relation;
|
|
relatedModels.add(relModel);
|
|
relFields = $.extend(true, {}, self.data[relModel].fields);
|
|
node._isProcessed = true;
|
|
// postprocess simulation
|
|
const { models } = self._getView(node, relModel, relFields, context);
|
|
[...models].forEach((modelName) => relatedModels.add(modelName));
|
|
});
|
|
|
|
var xmlSerializer = new XMLSerializer();
|
|
var processedArch = xmlSerializer.serializeToString(doc);
|
|
return {
|
|
arch: processedArch,
|
|
model: model,
|
|
type: doc.tagName === 'tree' ? 'list' : doc.tagName,
|
|
models: relatedModels,
|
|
};
|
|
},
|
|
/**
|
|
* 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.
|
|
*
|
|
* @private
|
|
* @param {string} model a model name
|
|
* @param {any[]} domain
|
|
* @param {Object} [params={}]
|
|
* @param {boolean} [params.active_test=true]
|
|
* @returns {Object[]} a list of records
|
|
*/
|
|
_getRecords: function (model, domain, { active_test = true } = {}) {
|
|
if (!_.isArray(domain)) {
|
|
throw new Error("MockServer._getRecords: given domain has to be an array.");
|
|
}
|
|
|
|
var self = this;
|
|
var records = this.data[model].records;
|
|
|
|
if (active_test && 'active' in this.data[model].fields) {
|
|
// add ['active', '=', true] to the domain if 'active' is not yet present in domain
|
|
var activeInDomain = false;
|
|
_.each(domain, function (subdomain) {
|
|
activeInDomain = activeInDomain || subdomain[0] === 'active';
|
|
});
|
|
if (!activeInDomain) {
|
|
domain = [['active', '=', true]].concat(domain);
|
|
}
|
|
}
|
|
|
|
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') {
|
|
var oldLength = 0;
|
|
var childIDs = [criterion[2]];
|
|
while (childIDs.length > oldLength) {
|
|
oldLength = childIDs.length;
|
|
_.each(records, function (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 = this.data[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 = _.filter(records, function (record) {
|
|
return self._evaluateDomain(domain, record);
|
|
});
|
|
}
|
|
|
|
return records;
|
|
},
|
|
/**
|
|
* Helper function, to find an available ID. The current algorithm is to
|
|
* return the currently highest id + 1.
|
|
*
|
|
* @private
|
|
* @param {string} modelName
|
|
* @returns {integer} a valid ID (> 0)
|
|
*/
|
|
_getUnusedID: function (modelName) {
|
|
var model = this.data[modelName];
|
|
return model.records.reduce((max, record) => {
|
|
if (!Number.isInteger(record.id)) {
|
|
return max;
|
|
}
|
|
return Math.max(record.id, max);
|
|
}, 0) + 1;
|
|
},
|
|
/**
|
|
* Simulate a 'call_button' operation from a view.
|
|
*
|
|
* @private
|
|
* @param {Object} param0
|
|
* @param {Array<integer[]>} param0.args
|
|
* @param {Object} [param0.kargs]
|
|
* @param {string} param0.method
|
|
* @param {string} param0.model
|
|
* @returns {any}
|
|
* @throws {Error} in case the call button of provided model/method is not
|
|
* implemented.
|
|
*/
|
|
_mockCallButton({ args, kwargs, method, model }) {
|
|
throw new Error(`Unimplemented mocked call button on "${model}"/"${method}"`);
|
|
},
|
|
/**
|
|
* Simulate a 'copy' operation, so we simply try to duplicate a record in
|
|
* memory
|
|
*
|
|
* @private
|
|
* @param {string} modelName
|
|
* @param {integer} id the ID of a valid record
|
|
* @returns {integer} the ID of the duplicated record
|
|
*/
|
|
_mockCopy: function (modelName, id) {
|
|
var model = this.data[modelName];
|
|
var newID = this._getUnusedID(modelName);
|
|
var originalRecord = _.findWhere(model.records, {id: id});
|
|
var duplicateRecord = _.extend({}, originalRecord, {id: newID});
|
|
duplicateRecord.display_name = originalRecord.display_name + ' (copy)';
|
|
model.records.push(duplicateRecord);
|
|
return newID;
|
|
},
|
|
/**
|
|
* Simulate a 'create' operation. This is basically a 'write' with the
|
|
* added work of getting a valid ID and applying default values.
|
|
*
|
|
* @private
|
|
* @param {string} modelName
|
|
* @param {Object} values
|
|
* @returns {integer}
|
|
*/
|
|
_mockCreate: function (modelName, values) {
|
|
if ('id' in values) {
|
|
throw new Error("Cannot create a record with a predefinite id");
|
|
}
|
|
var model = this.data[modelName];
|
|
var id = this._getUnusedID(modelName);
|
|
var record = {id: id};
|
|
model.records.push(record);
|
|
this._applyDefaults(model, values);
|
|
this._writeRecord(modelName, values, id);
|
|
if (this.isInitialized) {
|
|
this._updateComodelRelationalFields(modelName, record);
|
|
}
|
|
return id;
|
|
},
|
|
/**
|
|
* Simulate a 'default_get' operation
|
|
*
|
|
* @private
|
|
* @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: function (modelName, args, kwargs = {}) {
|
|
const fields = args[0];
|
|
const model = this.data[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.data[field.relation].records.some(
|
|
(r) => r.id === result[fieldName]
|
|
);
|
|
if (!recordExists) {
|
|
delete result[fieldName];
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
/**
|
|
* Simulate a 'fields_get' operation
|
|
*
|
|
* @private
|
|
* @param {string} modelName
|
|
* @param {any} args
|
|
* @returns {Object}
|
|
*/
|
|
_mockFieldsGet: function (modelName, args) {
|
|
var modelFields = this.data[modelName].fields;
|
|
// Get only the asked fields (args[0] could be the field names)
|
|
if (args[0] && args[0].length) {
|
|
modelFields = _.pick.apply(_, [modelFields].concat(args[0]));
|
|
}
|
|
// Get only the asked attributes (args[1] could be the attribute names)
|
|
if (args[1] && args[1].length) {
|
|
modelFields = _.mapObject(modelFields, function (field) {
|
|
return _.pick.apply(_, [field].concat(args[1]));
|
|
});
|
|
}
|
|
return modelFields;
|
|
},
|
|
/**
|
|
* 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 = Domain.prototype.normalizeArray(extraDomain);
|
|
const noExtra = JSON.stringify(normalizedExtra) === "[]";
|
|
const modelDomain = kwargs.model_domain || [];
|
|
const countDomain = Domain.prototype.normalizeArray([
|
|
...modelDomain,
|
|
...extraDomain,
|
|
]);
|
|
|
|
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: function (model, fieldName, domain, setCount=false, limit=false) {
|
|
const field = this.data[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.data[model].fields[fieldName].selection) {
|
|
selection[value] = label;
|
|
}
|
|
groupIdName = value => [value, selection[value]];
|
|
}
|
|
domain = Domain.prototype.normalizeArray([
|
|
...domain,
|
|
[fieldName, '!=', false],
|
|
]);
|
|
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: function (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: function (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: function (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.data[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: function (model, [fieldName], kwargs) {
|
|
const field = this.data[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 = Domain.prototype.normalizeArray([
|
|
...(kwargs.category_domain || []),
|
|
...(kwargs.filter_domain || []),
|
|
]);
|
|
|
|
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.data[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.data[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 = Domain.prototype.normalizeArray([
|
|
...comodelDomain,
|
|
condition,
|
|
]);
|
|
}
|
|
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: function (model, [fieldName], kwargs) {
|
|
const field = this.data[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 = Domain.prototype.normalizeArray([
|
|
...(kwargs.category_domain || []),
|
|
...(kwargs.filter_domain || []),
|
|
]);
|
|
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.data[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.data[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 = Domain.prototype.normalizeArray([
|
|
...modelDomain,
|
|
[fieldName, "in", record.id]
|
|
]);
|
|
let localExtraDomain = extraDomain;
|
|
if (groupBy && groupDomain) {
|
|
localExtraDomain = Domain.prototype.normalizeArray([
|
|
...localExtraDomain,
|
|
...(groupDomain[JSON.stringify(groupId)] || []),
|
|
]);
|
|
}
|
|
const searchCountDomain = Domain.prototype.normalizeArray([
|
|
...searchDomain,
|
|
...localExtraDomain,
|
|
]);
|
|
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 = Domain.prototype.normalizeArray([
|
|
...extraDomain,
|
|
...(kwargs.group_domain || []),
|
|
]);
|
|
modelDomain = Domain.prototype.normalizeArray([
|
|
...modelDomain,
|
|
...(kwargs.group_domain || []),
|
|
]);
|
|
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 = Domain.prototype.normalizeArray([
|
|
...comodelDomain,
|
|
['id', 'in', imageElementIds],
|
|
]);
|
|
}
|
|
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 };
|
|
}
|
|
},
|
|
/**
|
|
* Simulate a call to the '/web/action/load' route
|
|
*
|
|
* @private
|
|
* @param {Object} kwargs
|
|
* @param {integer} kwargs.action_id
|
|
* @returns {Object}
|
|
*/
|
|
_mockLoadAction: function (kwargs) {
|
|
var action = _.findWhere(this.actions, {id: parseInt(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} (legacy)`);
|
|
}
|
|
return action || false;
|
|
},
|
|
/**
|
|
* Simulate a 'get_views' operation
|
|
*
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @param {Object} kwargs
|
|
* @param {Array} kwargs.views
|
|
* @param {Object} kwargs.options
|
|
* @param {Object} kwargs.context
|
|
* @returns {Object}
|
|
*/
|
|
_mockGetViews: function (model, kwargs) {
|
|
var self = this;
|
|
var views = {};
|
|
_.each(kwargs.views, function (view_descr) {
|
|
var viewID = view_descr[0] || false;
|
|
var viewType = view_descr[1];
|
|
if (!viewID) {
|
|
var contextKey = (viewType === 'list' ? 'tree' : viewType) + '_view_ref';
|
|
if (contextKey in kwargs.context) {
|
|
viewID = parseInt(kwargs.context[contextKey]);
|
|
}
|
|
}
|
|
var key = [model, viewID, viewType].join(',');
|
|
var arch = self.archs[key] || _.find(self.archs, function (_v, k) {
|
|
var ka = k.split(',');
|
|
viewID = parseInt(ka[1], 10);
|
|
return ka[0] === model && ka[2] === viewType;
|
|
});
|
|
if (!arch) {
|
|
throw new Error('No arch found for key ' + key);
|
|
}
|
|
views[viewType] = {
|
|
arch: arch,
|
|
view_id: viewID,
|
|
model: model,
|
|
viewOptions: {
|
|
context: kwargs.context,
|
|
},
|
|
};
|
|
});
|
|
return views;
|
|
},
|
|
/**
|
|
* Simulate a 'name_get' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {Array[]} a list of [id, display_name]
|
|
*/
|
|
_mockNameGet: function (model, args) {
|
|
var ids = args[0];
|
|
if (!args.length) {
|
|
throw new Error("name_get: expected one argument");
|
|
}
|
|
else if (!ids) {
|
|
return []
|
|
}
|
|
if (!_.isArray(ids)) {
|
|
ids = [ids];
|
|
}
|
|
var records = this.data[model].records;
|
|
var names = _.map(ids, function (id) {
|
|
return id ? [id, _.findWhere(records, {id: id}).display_name] : [null, ""];
|
|
});
|
|
return names;
|
|
},
|
|
/**
|
|
* Simulate a 'name_create' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {Array} a couple [id, name]
|
|
*/
|
|
_mockNameCreate: function (model, args) {
|
|
var name = args[0];
|
|
var values = {
|
|
name: name,
|
|
display_name: name,
|
|
};
|
|
var id = this._mockCreate(model, values);
|
|
return [id, name];
|
|
},
|
|
/**
|
|
* Simulate a 'name_search' operation.
|
|
*
|
|
* not yet fully implemented (missing: limit, and evaluate operators)
|
|
* domain works but only to filter on ids
|
|
*
|
|
* @private
|
|
* @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: function (model, args, _kwargs) {
|
|
var str = args && typeof args[0] === 'string' ? args[0] : _kwargs.name;
|
|
const limit = _kwargs.limit || 100;
|
|
var domain = (args && args[1]) || _kwargs.args || [];
|
|
var records = this._getRecords(model, domain);
|
|
if (str.length) {
|
|
records = _.filter(records, function (record) {
|
|
return record.display_name.indexOf(str) !== -1;
|
|
});
|
|
}
|
|
var result = _.map(records, function (record) {
|
|
return [record.id, record.display_name];
|
|
});
|
|
return result.slice(0, limit);
|
|
},
|
|
/**
|
|
* Simulate an 'onchange' rpc
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Object} args
|
|
* @param {Object} args[1] the current record data
|
|
* @param {string|string[]} [args[2]] a list of field names, or just a field name
|
|
* @param {Object} args[3] the onchange spec
|
|
* @param {Object} [kwargs]
|
|
* @returns {Object}
|
|
*/
|
|
_mockOnchange: function (model, args, kwargs) {
|
|
const currentData = args[1];
|
|
let fields = args[2];
|
|
const onChangeSpec = args[3];
|
|
var onchanges = this.data[model].onchanges || {};
|
|
|
|
if (fields && !(fields instanceof Array)) {
|
|
fields = [fields];
|
|
}
|
|
const firstOnChange = !fields || !fields.length;
|
|
const onchangeVals = {};
|
|
let defaultVals;
|
|
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(model, [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(model, Object.assign({}, defaultVals, onchangeVals)),
|
|
};
|
|
},
|
|
/**
|
|
* Simulate a 'read' operation.
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @param {Object} _kwargs ignored... is that correct?
|
|
* @returns {Object}
|
|
*/
|
|
_mockRead: function (model, args, _kwargs) {
|
|
var self = this;
|
|
var ids = args[0];
|
|
if (!_.isArray(ids)) {
|
|
ids = [ids];
|
|
}
|
|
var fields = args[1] && args[1].length ? _.uniq(args[1].concat(['id'])) : Object.keys(this.data[model].fields);
|
|
var records = _.reduce(ids, function (records, id) {
|
|
if (!id) {
|
|
throw new Error("mock read: falsy value given as id, would result in an access error in actual server !");
|
|
}
|
|
var record = _.findWhere(self.data[model].records, {id: id});
|
|
return record ? records.concat(record) : records;
|
|
}, []);
|
|
var results = _.map(records, function (record) {
|
|
var result = {};
|
|
for (var i = 0; i < fields.length; i++) {
|
|
var field = self.data[model].fields[fields[i]];
|
|
if (!field) {
|
|
// the field doens't exist on the model, so skip it
|
|
continue;
|
|
}
|
|
if (field.type === 'float' ||
|
|
field.type === 'integer' ||
|
|
field.type === 'monetary') {
|
|
// read should return 0 for unset numeric fields
|
|
result[fields[i]] = record[fields[i]] || 0;
|
|
} else if (field.type === 'many2one') {
|
|
var relatedRecord = _.findWhere(self.data[field.relation].records, {
|
|
id: record[fields[i]]
|
|
});
|
|
if (relatedRecord) {
|
|
result[fields[i]] =
|
|
[record[fields[i]], relatedRecord.display_name];
|
|
} else {
|
|
result[fields[i]] = false;
|
|
}
|
|
} else if (field.type === 'one2many' || field.type === 'many2many') {
|
|
result[fields[i]] = record[fields[i]] || [];
|
|
} else {
|
|
result[fields[i]] = record[fields[i]] || false;
|
|
}
|
|
}
|
|
return result;
|
|
});
|
|
return results;
|
|
},
|
|
/**
|
|
* Simulate a 'read_group' call to the server.
|
|
*
|
|
* Note: most of the keys in kwargs are still ignored
|
|
*
|
|
* @private
|
|
* @param {string} model a string describing an existing model
|
|
* @param {Object} kwargs various options supported by read_group
|
|
* @param {string[]} kwargs.groupby fields that we are grouping
|
|
* @param {string[]} kwargs.fields fields that we are aggregating
|
|
* @param {Array} kwargs.domain the domain used for the read_group
|
|
* @param {boolean} kwargs.lazy still mostly ignored
|
|
* @param {integer} [kwargs.limit]
|
|
* @param {integer} [kwargs.offset]
|
|
* @returns {Object[]}
|
|
*/
|
|
_mockReadGroup: function (model, kwargs) {
|
|
if (!('lazy' in kwargs)) {
|
|
kwargs.lazy = true;
|
|
}
|
|
var self = this;
|
|
var fields = this.data[model].fields;
|
|
var aggregatedFields = [];
|
|
_.each(kwargs.fields, function (field) {
|
|
var split = field.split(":");
|
|
var fieldName = split[0];
|
|
if (kwargs.groupby.indexOf(fieldName) > 0) {
|
|
// grouped fields are not aggregated
|
|
return;
|
|
}
|
|
if (fields[fieldName] && (fields[fieldName].type === 'many2one') && split[1] !== 'count_distinct') {
|
|
return;
|
|
}
|
|
aggregatedFields.push(fieldName);
|
|
});
|
|
var groupBy = [];
|
|
if (kwargs.groupby.length) {
|
|
groupBy = kwargs.lazy ? [kwargs.groupby[0]] : kwargs.groupby;
|
|
}
|
|
var records = this._getRecords(model, kwargs.domain);
|
|
|
|
// if no fields have been given, the server picks all stored fields
|
|
if (kwargs.fields.length === 0) {
|
|
aggregatedFields = _.keys(this.data[model].fields);
|
|
}
|
|
|
|
var groupByFieldNames = _.map(groupBy, function (groupByField) {
|
|
return groupByField.split(":")[0];
|
|
});
|
|
|
|
// filter out non existing fields
|
|
aggregatedFields = _.filter(aggregatedFields, function (name) {
|
|
return name in self.data[model].fields && !(_.contains(groupByFieldNames,name));
|
|
});
|
|
|
|
function aggregateFields(group, records) {
|
|
var type;
|
|
for (var i = 0; i < aggregatedFields.length; i++) {
|
|
type = fields[aggregatedFields[i]].type;
|
|
if (type === 'float' || type === 'integer') {
|
|
group[aggregatedFields[i]] = null;
|
|
for (var j = 0; j < records.length; j++) {
|
|
var value = group[aggregatedFields[i]] || 0;
|
|
group[aggregatedFields[i]] = value + records[j][aggregatedFields[i]];
|
|
}
|
|
}
|
|
if (type === 'many2one') {
|
|
var ids = _.pluck(records, aggregatedFields[i]);
|
|
group[aggregatedFields[i]] = _.uniq(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") {
|
|
if (aggregateFunction === 'day') {
|
|
return moment(val).format('YYYY-MM-DD');
|
|
} else if (aggregateFunction === 'week') {
|
|
return moment(val).format('[W]WW GGGG');
|
|
} else if (aggregateFunction === 'quarter') {
|
|
return moment(val).format('[Q]Q YYYY');
|
|
} else if (aggregateFunction === 'year') {
|
|
return moment(val).format('Y');
|
|
} else {
|
|
return moment(val).format('MMMM YYYY');
|
|
}
|
|
} else if (type === "datetime") {
|
|
if (aggregateFunction === 'hour') {
|
|
return moment(val).format('HH[:00] DD MMM');
|
|
} else if (aggregateFunction === 'day') {
|
|
return moment(val).format('YYYY-MM-DD');
|
|
} else if (aggregateFunction === 'week') {
|
|
return moment(val).format('[W]WW GGGG');
|
|
} else if (aggregateFunction === 'quarter') {
|
|
return moment(val).format('[Q]Q YYYY');
|
|
} else if (aggregateFunction === 'year') {
|
|
return moment(val).format('Y');
|
|
} else {
|
|
return moment(val).format('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) {
|
|
var 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 = _.findWhere(this.data[relation].records, {
|
|
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": {
|
|
startDate = moment(value, "HH[:00] DD MMM");
|
|
endDate = startDate.clone().add(1, "hours");
|
|
break;
|
|
}
|
|
case "day": {
|
|
startDate = moment(value, "YYYY-MM-DD");
|
|
endDate = startDate.clone().add(1, "days");
|
|
break;
|
|
}
|
|
case "week": {
|
|
startDate = moment(value, "[W]WW GGGG");
|
|
endDate = startDate.clone().add(1, "weeks");
|
|
break;
|
|
}
|
|
case "quarter": {
|
|
startDate = moment(value, "[Q]Q YYYY");
|
|
endDate = startDate.clone().add(1, "quarters");
|
|
break;
|
|
}
|
|
case "year": {
|
|
startDate = moment(value, "Y");
|
|
endDate = startDate.clone().add(1, "years");
|
|
break;
|
|
}
|
|
case "month":
|
|
default: {
|
|
startDate = moment(value, "MMMM YYYY");
|
|
endDate = startDate.clone().add(1, "months");
|
|
break;
|
|
}
|
|
}
|
|
const from = type === "date"
|
|
? startDate.format("YYYY-MM-DD")
|
|
: startDate.format("YYYY-MM-DD HH:mm:ss");
|
|
const to = type === "date"
|
|
? endDate.format("YYYY-MM-DD")
|
|
: endDate.format("YYYY-MM-DD HH:mm:ss");
|
|
// NOTE THAT the range and the domain computed here are not really accurate
|
|
// due to a the timezone not really taken into account.
|
|
// FYI, the non legacy version of the mock server handles this correctly.
|
|
group.__range[gbField] = { from, to };
|
|
group.__domain = [
|
|
[fieldName, ">=", from],
|
|
[fieldName, "<", to],
|
|
].concat(group.__domain);
|
|
} else {
|
|
group.__range[gbField] = 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, model, fieldName, order);
|
|
}
|
|
|
|
if (kwargs.limit) {
|
|
const offset = kwargs.offset || 0;
|
|
readGroupResult = readGroupResult.slice(offset, kwargs.limit + offset);
|
|
}
|
|
|
|
return readGroupResult;
|
|
},
|
|
/**
|
|
* Simulates a 'read_progress_bar' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Object} kwargs
|
|
* @returns {Object[][]}
|
|
*/
|
|
_mockReadProgressBar: function (model, kwargs) {
|
|
var domain = kwargs.domain;
|
|
var groupBy = kwargs.group_by;
|
|
var progress_bar = kwargs.progress_bar;
|
|
|
|
var records = this._getRecords(model, domain || []);
|
|
|
|
var data = {};
|
|
_.each(records, function (record) {
|
|
var groupByValue = record[groupBy]; // always technical value here
|
|
|
|
if (!(groupByValue in data)) {
|
|
data[groupByValue] = {};
|
|
_.each(progress_bar.colors, function (val, key) {
|
|
data[groupByValue][key] = 0;
|
|
});
|
|
}
|
|
|
|
var fieldValue = record[progress_bar.field];
|
|
if (fieldValue in data[groupByValue]) {
|
|
data[groupByValue][fieldValue]++;
|
|
}
|
|
});
|
|
|
|
return data;
|
|
},
|
|
/**
|
|
* Simulates a 'resequence' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {string} field
|
|
* @param {Array} ids
|
|
*/
|
|
_mockResequence: function (args) {
|
|
var offset = args.offset ? Number(args.offset) : 0;
|
|
var field = args.field ? args.field : 'sequence';
|
|
var records = this.data[args.model].records;
|
|
if (!(field in this.data[args.model].fields)) {
|
|
return false;
|
|
}
|
|
for (var i in args.ids) {
|
|
var record = _.findWhere(records, {id: args.ids[i]});
|
|
record[field] = Number(i) + offset;
|
|
}
|
|
return true;
|
|
},
|
|
/**
|
|
* Simulate a 'search' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @param {Object} kwargs
|
|
* @param {integer} [kwargs.limit]
|
|
* @returns {integer[]}
|
|
*/
|
|
_mockSearch: function (model, args, kwargs) {
|
|
const limit = kwargs.limit || Number.MAX_VALUE;
|
|
const { context } = kwargs;
|
|
const active_test =
|
|
context && "active_test" in context ? context.active_test : true;
|
|
return this._getRecords(model, args[0], { active_test }).map(r => r.id).slice(0, limit);
|
|
},
|
|
/**
|
|
* Simulate a 'search_count' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {integer}
|
|
*/
|
|
_mockSearchCount: function (model, args) {
|
|
return this._getRecords(model, args[0]).length;
|
|
},
|
|
/**
|
|
* Simulate a 'search_read' operation on a model
|
|
*
|
|
* @private
|
|
* @param {Object} args
|
|
* @param {Array} args.domain
|
|
* @param {string} args.model
|
|
* @param {Array} [args.fields] defaults to the list of all fields
|
|
* @param {integer} [args.limit]
|
|
* @param {integer} [args.offset=0]
|
|
* @param {string[]} [args.sort]
|
|
* @returns {Object}
|
|
*/
|
|
_mockSearchRead: function (model, args, kwargs) {
|
|
var result = this._mockSearchReadController({
|
|
model: model,
|
|
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_read' operation, from the controller point of view
|
|
*
|
|
* @private
|
|
* @private
|
|
* @param {Object} args
|
|
* @param {Array} args.domain
|
|
* @param {string} args.model
|
|
* @param {Array} [args.fields] defaults to the list of all fields
|
|
* @param {integer} [args.limit]
|
|
* @param {integer} [args.offset=0]
|
|
* @param {string[]} [args.sort]
|
|
* @returns {Object}
|
|
*/
|
|
_mockSearchReadController: function (args) {
|
|
var self = this;
|
|
const { context } = args;
|
|
const active_test =
|
|
context && "active_test" in context ? context.active_test : true;
|
|
var records = this._getRecords(args.model, args.domain || [], {
|
|
active_test,
|
|
});
|
|
var fields = args.fields && args.fields.length ? args.fields : _.keys(this.data[args.model].fields);
|
|
var nbRecords = records.length;
|
|
var offset = args.offset || 0;
|
|
if (args.sort) {
|
|
// warning: only consider first level of sort
|
|
args.sort = args.sort.split(',')[0];
|
|
var fieldName = args.sort.split(' ')[0];
|
|
var order = args.sort.split(' ')[1];
|
|
records = this._sortByField(records, args.model, fieldName, order);
|
|
}
|
|
records = records.slice(offset, args.limit ? (offset + args.limit) : nbRecords);
|
|
var processedRecords = _.map(records, function (r) {
|
|
var result = {};
|
|
_.each(_.uniq(fields.concat(['id'])), function (fieldName) {
|
|
var field = self.data[args.model].fields[fieldName];
|
|
if (field.type === 'many2one') {
|
|
var related_record = _.findWhere(self.data[field.relation].records, {
|
|
id: r[fieldName]
|
|
});
|
|
result[fieldName] =
|
|
related_record ? [r[fieldName], related_record.display_name] : false;
|
|
} else {
|
|
result[fieldName] = r[fieldName];
|
|
}
|
|
});
|
|
return result;
|
|
});
|
|
var result = {
|
|
length: nbRecords,
|
|
records: processedRecords,
|
|
};
|
|
return $.extend(true, {}, result);
|
|
},
|
|
/**
|
|
* Simulate a 'unlink' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {boolean} currently, always returns true
|
|
*/
|
|
_mockUnlink: function (model, args) {
|
|
var ids = args[0];
|
|
if (!_.isArray(ids)) {
|
|
ids = [ids];
|
|
}
|
|
this.data[model].records = _.reject(this.data[model].records, function (record) {
|
|
return _.contains(ids, record.id);
|
|
});
|
|
|
|
// update value of relationnal fields pointing to the deleted records
|
|
_.each(this.data, function (d) {
|
|
var relatedFields = _.pick(d.fields, function (field) {
|
|
return field.relation === model;
|
|
});
|
|
_.each(Object.keys(relatedFields), function (relatedField) {
|
|
_.each(d.records, function (record) {
|
|
if (Array.isArray(record[relatedField])) {
|
|
record[relatedField] = _.difference(record[relatedField], ids);
|
|
} else if (ids.includes(record[relatedField])) {
|
|
record[relatedField] = false;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
return true;
|
|
},
|
|
/**
|
|
* Simulate a 'web_read_group' call to the server.
|
|
*
|
|
* Note: some keys in kwargs are still ignored
|
|
*
|
|
* @private
|
|
* @param {string} model a string describing an existing model
|
|
* @param {Object} kwargs various options supported by read_group
|
|
* @param {string[]} kwargs.groupby fields that we are grouping
|
|
* @param {string[]} kwargs.fields fields that we are aggregating
|
|
* @param {Array} kwargs.domain the domain used for the read_group
|
|
* @param {boolean} kwargs.lazy still mostly ignored
|
|
* @param {integer} [kwargs.limit]
|
|
* @param {integer} [kwargs.offset]
|
|
* @param {boolean} [kwargs.expand=false] if true, read records inside each
|
|
* group
|
|
* @param {integer} [kwargs.expand_limit]
|
|
* @param {integer} [kwargs.expand_orderby]
|
|
* @returns {Object[]}
|
|
*/
|
|
_mockWebReadGroup: function (model, kwargs) {
|
|
var self = this;
|
|
var groups = this._mockReadGroup(model, kwargs);
|
|
if (kwargs.expand && kwargs.groupby.length === 1) {
|
|
groups.forEach(function (group) {
|
|
group.__data = self._mockSearchReadController({
|
|
domain: group.__domain,
|
|
model: model,
|
|
fields: kwargs.fields,
|
|
limit: kwargs.expand_limit,
|
|
order: kwargs.expand_orderby,
|
|
});
|
|
});
|
|
}
|
|
var allGroups = this._mockReadGroup(model, {
|
|
domain: kwargs.domain,
|
|
fields: ['display_name'],
|
|
groupby: kwargs.groupby,
|
|
lazy: kwargs.lazy,
|
|
});
|
|
return {
|
|
groups: groups,
|
|
length: allGroups.length,
|
|
};
|
|
},
|
|
/**
|
|
* Simulate a 'write' operation
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Array} args
|
|
* @returns {boolean} currently, always return 'true'
|
|
*/
|
|
_mockWrite: function (model, args) {
|
|
_.each(args[0], id => {
|
|
const originalRecord = this._mockSearchRead(model, [[['id', '=', id]]], {})[0];
|
|
this._writeRecord(model, args[1], id);
|
|
const updatedRecord = this.data[model].records.find(record => record.id === id);
|
|
this._updateComodelRelationalFields(model, updatedRecord, originalRecord);
|
|
});
|
|
return true;
|
|
},
|
|
/**
|
|
* Dispatches a fetch call to the correct helper function.
|
|
*
|
|
* @param {string} resource
|
|
* @param {Object} init
|
|
* @returns {any}
|
|
*/
|
|
_performFetch(resource, init) {
|
|
throw new Error("Unimplemented resource: " + resource);
|
|
},
|
|
/**
|
|
* Dispatch a RPC call to the correct helper function
|
|
*
|
|
* @see performRpc
|
|
*
|
|
* @private
|
|
* @param {string} route
|
|
* @param {Object} args
|
|
* @returns {Promise<any>}
|
|
* Resolved with the result of the RPC. If the RPC should fail, the
|
|
* promise should either be rejected or the call should throw an
|
|
* exception (@see performRpc for error handling).
|
|
*/
|
|
async _performRpc(route, args) {
|
|
switch (route) {
|
|
case '/web/dataset/call_button':
|
|
return this._mockCallButton(args);
|
|
case '/web/action/load':
|
|
return this._mockLoadAction(args);
|
|
|
|
case '/web/dataset/search_read':
|
|
return this._mockSearchReadController(args);
|
|
|
|
case '/web/dataset/resequence':
|
|
return this._mockResequence(args);
|
|
}
|
|
if (route.indexOf('/web/image') >= 0 || _.contains(['.png', '.jpg'], route.substr(route.length - 4))) {
|
|
return;
|
|
}
|
|
switch (args.method) {
|
|
case "render_public_asset": {
|
|
return true;
|
|
}
|
|
case 'copy':
|
|
return this._mockCopy(args.model, args.args[0]);
|
|
|
|
case 'create':
|
|
return this._mockCreate(args.model, args.args[0]);
|
|
|
|
case 'fields_get':
|
|
return this._mockFieldsGet(args.model, args.args);
|
|
|
|
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 'get_views':
|
|
return this._mockGetViews(args.model, args.kwargs);
|
|
|
|
case 'name_get':
|
|
return this._mockNameGet(args.model, args.args);
|
|
|
|
case 'name_create':
|
|
return this._mockNameCreate(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, args.kwargs);
|
|
|
|
case 'read_group':
|
|
return this._mockReadGroup(args.model, args.kwargs);
|
|
|
|
case 'web_read_group':
|
|
return this._mockWebReadGroup(args.model, args.kwargs);
|
|
|
|
case 'read_progress_bar':
|
|
return this._mockReadProgressBar(args.model, args.kwargs);
|
|
|
|
case 'search':
|
|
return this._mockSearch(args.model, args.args, args.kwargs);
|
|
|
|
case 'search_count':
|
|
return this._mockSearchCount(args.model, args.args);
|
|
|
|
case 'search_read':
|
|
return this._mockSearchRead(args.model, args.args, args.kwargs);
|
|
|
|
case 'unlink':
|
|
return this._mockUnlink(args.model, args.args);
|
|
|
|
case 'write':
|
|
return this._mockWrite(args.model, args.args);
|
|
}
|
|
var model = this.data[args.model];
|
|
if (model && typeof model[args.method] === 'function') {
|
|
return this.data[args.model][args.method](args.args, args.kwargs);
|
|
}
|
|
|
|
throw new Error("Unimplemented route: " + route);
|
|
},
|
|
/**
|
|
* @private
|
|
* @param {Object[]} records the records to sort
|
|
* @param {string} model the model of records
|
|
* @param {string} fieldName the field to sort on
|
|
* @param {string} [order="DESC"] "ASC" or "DESC"
|
|
* @returns {Object}
|
|
*/
|
|
_sortByField: function (records, model, fieldName, order) {
|
|
const field = this.data[model].fields[fieldName];
|
|
records.sort((r1, r2) => {
|
|
let v1 = r1[fieldName];
|
|
let v2 = r2[fieldName];
|
|
if (field.type === 'many2one') {
|
|
const coRecords = this.data[field.relation].records;
|
|
if (this.data[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;
|
|
},
|
|
/**
|
|
* helper function: traverse a tree and apply the function f to each of its
|
|
* nodes.
|
|
*
|
|
* Note: this should be abstracted somewhere in web.utils, or in
|
|
* web.tree_utils
|
|
*
|
|
* @param {Object} tree object with a 'children' key, which contains an
|
|
* array of trees.
|
|
* @param {function} f
|
|
*/
|
|
_traverse: function (tree, f) {
|
|
var self = this;
|
|
if (f(tree)) {
|
|
_.each(tree.childNodes, function (c) { self._traverse(c, f); });
|
|
}
|
|
},
|
|
/**
|
|
* Fill all inverse fields of the relational fields present in the record
|
|
* to be created/updated.
|
|
*
|
|
* @param {string} modelName
|
|
* @param {Object} record record that have been created/updated.
|
|
* @param {Object|undefined} originalRecord record before update.
|
|
*/
|
|
_updateComodelRelationalFields(modelName, record, originalRecord) {
|
|
for (const fname in record) {
|
|
const field = this.data[modelName].fields[fname];
|
|
const comodelName = field.relation || record[field['model_name_ref_fname']];
|
|
const inverseFieldName = field['inverse_fname_by_model_name'] && field['inverse_fname_by_model_name'][comodelName];
|
|
if (!inverseFieldName) {
|
|
// field has no inverse, skip it.
|
|
continue;
|
|
}
|
|
const relatedRecordIds = Array.isArray(record[fname]) ? record[fname] : [record[fname]];
|
|
// we only want to set a value for comodel inverse field if the model field has a value.
|
|
if (record[fname]) {
|
|
for (const relatedRecordId of relatedRecordIds) {
|
|
let inverseFieldNewValue = record.id;
|
|
const relatedRecord = this.data[comodelName].records.find(record => record.id === relatedRecordId);
|
|
const relatedFieldValue = relatedRecord && relatedRecord[inverseFieldName];
|
|
if (
|
|
relatedFieldValue === undefined ||
|
|
relatedFieldValue === record.id ||
|
|
field.type !== 'one2many' && relatedFieldValue.includes(record.id)
|
|
) {
|
|
// related record does not exist or the related value is already up to date.
|
|
continue;
|
|
}
|
|
if (Array.isArray(relatedFieldValue)) {
|
|
inverseFieldNewValue = [...relatedFieldValue, record.id];
|
|
}
|
|
this._writeRecord(comodelName, { [inverseFieldName]: inverseFieldNewValue }, relatedRecordId);
|
|
}
|
|
} else if (field.type === 'many2one_reference') {
|
|
// we need to clean the many2one_field as well.
|
|
const comodel_inverse_field = this.data[comodelName].fields[inverseFieldName];
|
|
const model_many2one_field = comodel_inverse_field['inverse_fname_by_model_name'][modelName];
|
|
this._writeRecord(modelName, { [model_many2one_field]: false }, record.id);
|
|
}
|
|
// it's an update, get the records that were originally referenced but are not
|
|
// anymore and update their relational fields.
|
|
if (originalRecord) {
|
|
const originalRecordIds = Array.isArray(originalRecord[fname]) ? originalRecord[fname] : [originalRecord[fname]];
|
|
// search read returns [id, name], let's ensure the removedRecordIds are integers.
|
|
const removedRecordIds = originalRecordIds.filter(recordId => Number.isInteger(recordId) && !relatedRecordIds.includes(recordId));
|
|
for (const removedRecordId of removedRecordIds) {
|
|
const removedRecord = this.data[comodelName].records.find(record => record.id === removedRecordId);
|
|
if (!removedRecord) {
|
|
continue;
|
|
}
|
|
let inverseFieldNewValue = false;
|
|
if (Array.isArray(removedRecord[inverseFieldName])) {
|
|
inverseFieldNewValue = removedRecord[inverseFieldName].filter(id => id !== record.id);
|
|
}
|
|
this._writeRecord(comodelName, { [inverseFieldName]: inverseFieldNewValue }, removedRecordId);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Write a record. The main difficulty is that we have to apply x2many
|
|
* commands
|
|
*
|
|
* @private
|
|
* @param {string} model
|
|
* @param {Object} values
|
|
* @param {integer} id
|
|
* @param {Object} [params={}]
|
|
* @param {boolean} [params.ensureIntegrity=true] writing non-existing id
|
|
* in many2one field will throw if this param is true
|
|
*/
|
|
_writeRecord: function (model, values, id, { ensureIntegrity = true } = {}) {
|
|
var self = this;
|
|
var record = _.findWhere(this.data[model].records, {id: id});
|
|
for (var field_changed in values) {
|
|
var field = this.data[model].fields[field_changed];
|
|
var value = values[field_changed];
|
|
if (!field) {
|
|
throw Error(`Mock: Can't write value "${JSON.stringify(value)}" on field "${field_changed}" on record "${model},${id}" (field is undefined)`);
|
|
}
|
|
if (_.contains(['one2many', 'many2many'], field.type)) {
|
|
var ids = _.clone(record[field_changed]) || [];
|
|
|
|
if (
|
|
Array.isArray(value) &&
|
|
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]];
|
|
}
|
|
// convert commands
|
|
for (const command of value || []) {
|
|
if (command[0] === 0) { // CREATE
|
|
const inverseData = command[2]; // write in place instead of copy, because some tests rely on the object given being updated
|
|
const inverseFieldName = field.inverse_fname_by_model_name && field.inverse_fname_by_model_name[field.relation];
|
|
if (inverseFieldName) {
|
|
inverseData[inverseFieldName] = id;
|
|
}
|
|
const newId = self._mockCreate(field.relation, inverseData);
|
|
ids.push(newId);
|
|
} else if (command[0] === 1) { // UPDATE
|
|
self._mockWrite(field.relation, [[command[1]], command[2]]);
|
|
} else if (command[0] === 2) { // DELETE
|
|
ids = _.without(ids, command[1]);
|
|
} else if (command[0] === 3) { // FORGET
|
|
ids = _.without(ids, command[1]);
|
|
} else if (command[0] === 4) { // LINK_TO
|
|
if (!_.contains(ids, 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 "${field_changed}" on record "${model},${id}"`);
|
|
}
|
|
}
|
|
record[field_changed] = ids;
|
|
} else if (field.type === 'many2one') {
|
|
if (value) {
|
|
var relatedRecord = _.findWhere(this.data[field.relation].records, {
|
|
id: value
|
|
});
|
|
if (!relatedRecord && ensureIntegrity) {
|
|
throw Error(`Wrong id "${JSON.stringify(value)}" for a many2one on field "${field_changed}" on record "${model},${id}"`);
|
|
}
|
|
record[field_changed] = value;
|
|
} else {
|
|
record[field_changed] = false;
|
|
}
|
|
} else {
|
|
record[field_changed] = value;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
return MockServer;
|
|
|
|
});
|