summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/views')
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js27
-rw-r--r--addons/web/static/src/js/views/abstract_model.js226
-rw-r--r--addons/web/static/src/js/views/abstract_view.js10
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js1
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js82
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js2
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js3
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js6
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js6
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js10
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js39
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js61
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js14
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js30
-rw-r--r--addons/web/static/src/js/views/list/list_model.js2
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js119
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js3
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js6
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js6
-rw-r--r--addons/web/static/src/js/views/qweb/qweb_view.js6
-rw-r--r--addons/web/static/src/js/views/sample_server.js609
21 files changed, 1120 insertions, 148 deletions
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index 32257202649..3df1d48410b 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -259,23 +259,23 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
* updating the renderer and wait for the rendering to complete.
*
* @param {Object} params will be given to the model and to the renderer
- * @param {Object} [options]
+ * @param {Object} [options={}]
* @param {boolean} [options.reload=true] if true, the model will reload data
*
* @returns {Promise}
*/
- update: async function (params, options = {}) {
+ async update(params, options = {}) {
const shouldReload = 'reload' in options ? options.reload : true;
if (shouldReload) {
this.handle = await this.dp.add(this.model.reload(this.handle, params));
}
const localState = this.renderer.getLocalState();
- const state = this.model.get(this.handle);
+ const state = this.model.get(this.handle, { withSampleData: true });
const promises = [
this.updateRendererState(state, params).then(() => {
this.renderer.setLocalState(localState);
}),
- this._update(state, params),
+ this._update(this.model.get(this.handle), params)
];
await this.dp.add(Promise.all(promises));
this.updateButtons();
@@ -340,6 +340,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
});
},
/**
+ * @private
+ * @param {function} callback function to execute before removing classname
+ * 'o_view_sample_data' (may be async). This allows to reload and/or
+ * rerender before removing the className, thus preventing the view from
+ * flickering.
+ */
+ async _removeSampleData(callback) {
+ this.model.leaveSampleMode();
+ if (callback) {
+ await callback();
+ }
+ this.el.classList.remove('o_view_sample_data');
+ },
+ /**
* Renders the html provided by the route specified by the
* bannerRoute attribute on the controller (banner_route in the template).
* Renders it before the view output and add a css class 'o_has_banner' to it.
@@ -409,7 +423,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
* @param {Object} [params.shouldUpdateControlPanel]
* @returns {Promise}
*/
- _update: function (state, params) {
+ async _update(state, params) {
// AAB: update the control panel -> this will be moved elsewhere at some point
if (!this.$buttons) {
this.renderButtons();
@@ -423,7 +437,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
promises.push(this.updateControlPanel());
}
this._pushState();
- return Promise.all(promises);
+ await Promise.all(promises);
+ this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode());
},
/**
* Can be used to update the key 'cp_content'. This method is called in start and _update methods.
diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js
index d18367b1756..db2cce99935 100644
--- a/addons/web/static/src/js/views/abstract_model.js
+++ b/addons/web/static/src/js/views/abstract_model.js
@@ -12,25 +12,199 @@ odoo.define('web.AbstractModel', function (require) {
* Note that the model is not a widget, it does not need to be rendered or
* appended to the dom. However, it inherits from the EventDispatcherMixin,
* in order to be able to notify its parent by bubbling events up.
+ *
+ * The model is able to generate sample (fake) data when there is no actual data
+ * in database. This feature can be activated by instantiating the model with
+ * param "useSampleModel" set to true. In this case, the model instantiates a
+ * duplicated version of itself, parametrized to call a SampleServer (JS)
+ * instead of doing RPCs. Here is how it works: the main model first load the
+ * data normally (from database), and then checks whether the result is empty or
+ * not. If it is, it asks the sample model to load with the exact same params,
+ * and it thus enters in "sample" mode. The model keeps doing this at reload,
+ * but only if the (re)load params haven't changed: as soon as a param changes,
+ * the "sample" mode is left, and it never enters it again in the future (in the
+ * lifetime of the model instance). To access those sample data from the outside,
+ * 'get' must be called with the the option "withSampleData" set to true. In
+ * this case, if the main model is in "sample" mode, it redirects the call to the
+ * sample model.
*/
var fieldUtils = require('web.field_utils');
var mvc = require('web.mvc');
+const SampleServer = require('web.SampleServer');
+
var AbstractModel = mvc.Model.extend({
+ /**
+ * @param {Widget} parent
+ * @param {Object} [params={}]
+ * @param {Object} [params.fields]
+ * @param {string} [params.modelName]
+ * @param {boolean} [params.isSampleModel=false] if true, will fetch data
+ * from a SampleServer instead of doing RPCs
+ * @param {boolean} [params.useSampleModel=false] if true, will use a sample
+ * model to generate sample data when there is no "real" data in database
+ * @param {AbstractModel} [params.SampleModel] the AbstractModel class
+ * to instantiate as sample model. This model won't do any rpc, but will
+ * rather call a SampleServer that will generate sample data. This param
+ * must be set when params.useSampleModel is true.
+ */
+ init(parent, params = {}) {
+ this._super(...arguments);
+ this.useSampleModel = params.useSampleModel || false;
+ if (params.isSampleModel) {
+ this.isSampleModel = true;
+ this.sampleServer = new SampleServer(params.modelName, params.fields);
+ } else if (this.useSampleModel) {
+ const sampleModelParams = Object.assign({}, params, {
+ isSampleModel: true,
+ SampleModel: null,
+ useSampleModel: false,
+ });
+ this.sampleModel = new params.SampleModel(this, sampleModelParams);
+ this._isInSampleMode = false;
+ }
+ },
+
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
+ * Override to call get on the sampleModel when we are in sample mode, and
+ * option 'withSampleData' is set to true.
+ *
+ * @override
+ * @param {any} _
+ * @param {Object} [options]
+ * @param {boolean} [options.withSampleData=false]
+ */
+ get(_, options) {
+ let state;
+ if (options && options.withSampleData && this._isInSampleMode) {
+ state = this.sampleModel.__get(...arguments);
+ } else {
+ state = this.__get(...arguments);
+ }
+ return state;
+ },
+ /**
+ * Under some conditions, the model is designed to generate sample data if
+ * there is no real data in database. This function returns a boolean which
+ * indicates the mode of the model: if true, we are in "sample" mode.
+ *
+ * @returns {boolean}
+ */
+ isInSampleMode() {
+ return !!this._isInSampleMode;
+ },
+ /**
+ * Disables the sample data (forever) on this model instance.
+ */
+ leaveSampleMode() {
+ if (this.useSampleModel) {
+ this.useSampleModel = false;
+ this._isInSampleMode = false;
+ this.sampleModel.destroy();
+ }
+ },
+ /**
+ * Override to check if we need to call the sample model (and if so, to do
+ * it) after loading the data, in the case where there is no real data to
+ * display.
+ *
+ * @override
+ */
+ async load(params) {
+ this.loadParams = params;
+ const handle = await this.__load(...arguments);
+ await this._callSampleModel('__load', handle, ...arguments);
+ return handle;
+ },
+ /**
* When something changes, the data may need to be refetched. This is the
* job for this method: reloading (only if necessary) all the data and
* making sure that they are ready to be redisplayed.
+ * Sometimes, we reload the data with the "same" params as the initial load
+ * params (see '_haveParamsChanged'). When we do, if we were in "sample" mode,
+ * we call again the sample server after the reload if there is still no data
+ * to display. When the parameters change, we automatically leave "sample"
+ * mode.
+ *
+ * @param {any} _
+ * @param {Object} [params]
+ * @returns {Promise}
+ */
+ async reload(_, params) {
+ const handle = await this.__reload(...arguments);
+ if (this._isInSampleMode) {
+ if (!this._haveParamsChanged(params)) {
+ await this._callSampleModel('__reload', handle, ...arguments);
+ } else {
+ this.leaveSampleMode();
+ }
+ }
+ return handle;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} method
+ * @param {any} handle
+ * @param {...any} args
+ * @returns {Promise}
+ */
+ async _callSampleModel(method, handle, ...args) {
+ if (this.useSampleModel && this._isEmpty(handle)) {
+ try {
+ if (method === '__load') {
+ await this.sampleModel.__load(...args);
+ } else if (method === '__reload') {
+ await this.sampleModel.__reload(...args);
+ }
+ this._isInSampleMode = true;
+ } catch (error) {
+ if (error instanceof SampleServer.UnimplementedRouteError) {
+ this.leaveSampleMode();
+ } else {
+ throw error;
+ }
+ }
+ } else {
+ this.leaveSampleMode();
+ }
+ },
+ /**
+ * @private
+ * @returns {Object}
+ */
+ __get() {
+ return {};
+ },
+ /**
+ * This function can be overriden to determine if the result of a load or
+ * a reload is empty. In the affirmative, we will try to generate sample
+ * data to prevent from having an empty state to display.
+ *
+ * @private
+ * @params {any} handle, the value returned by a load or a reload
+ * @returns {boolean}
+ */
+ _isEmpty(/* handle */) {
+ return false;
+ },
+ /**
+ * To override to do the initial load of the data (this function is supposed
+ * to be called only once).
*
- * @param {Object} params
+ * @private
* @returns {Promise}
*/
- reload: function (params) {
+ async __load() {
return Promise.resolve();
},
/**
@@ -57,6 +231,54 @@ var AbstractModel = mvc.Model.extend({
}
return value;
},
+ /**
+ * To override to reload data (this function may be called several times,
+ * after the initial load has been done).
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async __reload() {
+ return Promise.resolve();
+ },
+ /**
+ * Determines whether or not the given params (reload params) differ from
+ * the initial ones (this.loadParams). This is used to leave "sample" mode
+ * as soon as a parameter (e.g. domain) changes.
+ *
+ * @private
+ * @param {Object} [params={}]
+ * @param {Object} [params.context]
+ * @param {Array[]} [params.domain]
+ * @param {Object} [params.timeRanges]
+ * @param {string[]} [params.groupBy]
+ * @returns {boolean}
+ */
+ _haveParamsChanged(params = {}) {
+ for (const key of ['context', 'domain', 'timeRanges']) {
+ if (key in params) {
+ const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]);
+ if (diff) {
+ return true;
+ }
+ }
+ }
+ if (this.useSampleModel && 'groupBy' in params) {
+ return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy);
+ }
+ },
+ /**
+ * Override to redirect all rpcs to the SampleServer if this.isSampleModel
+ * is true.
+ *
+ * @override
+ */
+ async _rpc() {
+ if (this.isSampleModel) {
+ return this.sampleServer.mockRpc(...arguments);
+ }
+ return this._super(...arguments);
+ },
});
return AbstractModel;
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 4cd503ff480..6504314ddd1 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -162,6 +162,16 @@ var AbstractView = Factory.extend({
res_id: currentId,
res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
};
+
+ this.modelParams = {
+ fields: this.fields,
+ modelName: params.modelName,
+ useSampleModel: !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample))
+ };
+ if (this.modelParams.useSampleModel) {
+ this.modelParams.SampleModel = this.config.Model;
+ }
+
var defaultOrder = this.arch.attrs.default_order;
if (defaultOrder) {
this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) {
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 99da98f4301..0849cf5508d 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -43,7 +43,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
this.hasButtons = params.hasButtons;
FieldManagerMixin.init.call(this, this.model);
this.mode = params.mode || 'readonly';
- this.handle = this.initialState.id;
// savingDef is used to ensure that we always wait for pending save
// operations to complete before checking if there are changes to
// discard when discardChanges is called
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 9771fcc63e9..5b58dc4d7e7 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -164,6 +164,13 @@ var BasicModel = AbstractModel.extend({
this.batchedRPCsRequests = [];
this.localData = Object.create(null);
+ // used to generate dataPoint ids. Note that the counter is set to 0 for
+ // each instance, and this is mandatory for the sample data feature to
+ // work: we need both the main model and the sample model to generate the
+ // same datapoint ids for their common data (groups, when there are real
+ // groups in database), so that we can easily do the mapping between
+ // real and sample data.
+ this.__id = 0;
this._super.apply(this, arguments);
},
@@ -549,7 +556,7 @@ var BasicModel = AbstractModel.extend({
this.localData[listID].orderedResIDs = list.res_ids;
},
/**
- * The get method first argument is the handle returned by the load method.
+ * The __get method first argument is the handle returned by the load method.
* It is optional (the handle can be undefined). In some case, it makes
* sense to use the handle as a key, for example the BasicModel holds the
* data for various records, each with its local ID.
@@ -563,7 +570,7 @@ var BasicModel = AbstractModel.extend({
* @param {boolean} [options.raw=false] if true, will not follow relations
* @returns {Object}
*/
- get: function (id, options) {
+ __get: function (id, options) {
var self = this;
options = options || {};
@@ -601,7 +608,7 @@ var BasicModel = AbstractModel.extend({
relDataPoint = this.localData[data[fieldName]];
data[fieldName] = relDataPoint ? relDataPoint.res_id : false;
} else {
- data[fieldName] = this.get(data[fieldName]) || false;
+ data[fieldName] = this.__get(data[fieldName]) || false;
}
} else if (field.type === 'reference') {
if (options.raw) {
@@ -610,7 +617,7 @@ var BasicModel = AbstractModel.extend({
relDataPoint.model + ',' + relDataPoint.res_id :
false;
} else {
- data[fieldName] = this.get(data[fieldName]) || false;
+ data[fieldName] = this.__get(data[fieldName]) || false;
}
} else if (field.type === 'one2many' || field.type === 'many2many') {
if (options.raw) {
@@ -625,7 +632,7 @@ var BasicModel = AbstractModel.extend({
data[fieldName] = data[fieldName] || [];
}
} else {
- data[fieldName] = this.get(data[fieldName]) || [];
+ data[fieldName] = this.__get(data[fieldName]) || [];
}
}
}
@@ -687,7 +694,7 @@ var BasicModel = AbstractModel.extend({
context: _.extend({}, element.context),
count: element.count,
data: _.map(element.data, function (elemID) {
- return self.get(elemID, options);
+ return self.__get(elemID, options);
}),
domain: element.domain.slice(0),
fields: element.fields,
@@ -701,6 +708,7 @@ var BasicModel = AbstractModel.extend({
id: element.id,
isDirty: element.isDirty,
isOpen: element.isOpen,
+ isSample: this.isSampleModel,
limit: element.limit,
model: element.model,
offset: element.offset,
@@ -751,6 +759,27 @@ var BasicModel = AbstractModel.extend({
return isDirty;
},
/**
+ * Returns true iff the datapoint is of type list and either:
+ * - is not grouped, and contains no records
+ * - is grouped, and contains columns, but all columns are empty
+ * In these cases, we will generate sample data to display, instead of an
+ * empty state.
+ *
+ * @override
+ */
+ _isEmpty(dataPointID) {
+ const dataPoint = this.localData[dataPointID];
+ if (dataPoint.type === 'list') {
+ const hasRecords = dataPoint.count === 0;
+ if (dataPoint.groupedBy.length) {
+ return dataPoint.data.length > 0 && hasRecords;
+ } else {
+ return hasRecords;
+ }
+ }
+ return false;
+ },
+ /**
* Check if a localData is new, meaning if it is in the process of being
* created and no actual record exists in db. Note: if the localData is not
* of the "record" type, then it is always considered as not new.
@@ -782,6 +811,7 @@ var BasicModel = AbstractModel.extend({
*
* @todo document all params
*
+ * @private
* @param {any} params
* @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field
* @param {Object} params.fields contains the description of each field
@@ -789,7 +819,8 @@ var BasicModel = AbstractModel.extend({
* @param {string} [params.recordID] an ID for an existing resource.
* @returns {Promise<string>} resolves to a local id, or handle
*/
- load: function (params) {
+ __load: async function (params) {
+ await this._super(...arguments);
params.type = params.type || (params.res_id !== undefined ? 'record' : 'list');
// FIXME: the following seems only to be used by the basic_model_tests
// so it should probably be removed and the tests should be adapted
@@ -954,13 +985,15 @@ var BasicModel = AbstractModel.extend({
* Reload all data for a given resource. At any time there is at most one
* reload operation active.
*
+ * @private
* @param {string} id local id for a resource
* @param {Object} [options]
* @param {boolean} [options.keepChanges=false] if true, doesn't discard the
* changes on the record before reloading it
* @returns {Promise<string>} resolves to the id of the resource
*/
- reload: function (id, options) {
+ __reload: async function (id, options) {
+ await this._super(...arguments);
return this.mutex.exec(this._reload.bind(this, id, options));
},
/**
@@ -2196,6 +2229,20 @@ var BasicModel = AbstractModel.extend({
return hasOnchange ? specs : false;
},
/**
+ * Ensures that dataPoint ids are always synchronized between the main and
+ * sample models when being in sample mode. Here, we now that __id in the
+ * sample model is always greater than __id in the main model (as it
+ * contains strictly more datapoints).
+ *
+ * @override
+ */
+ async _callSampleModel() {
+ await this._super(...arguments);
+ if (this._isInSampleMode) {
+ this.__id = this.sampleModel.__id;
+ }
+ },
+ /**
* Compute the default value that the handle field should take.
* We need to compute this in order for new lines to be added at the correct position.
*
@@ -3968,7 +4015,7 @@ var BasicModel = AbstractModel.extend({
groupsCount: 0,
groupsLimit: type === 'list' && params.groupsLimit || null,
groupsOffset: 0,
- id: _.uniqueId(params.modelName + '_'),
+ id: `${params.modelName}_${++this.__id}`,
isOpen: params.isOpen,
limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER),
loadMoreOffset: 0,
@@ -4848,6 +4895,23 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * Override to handle the case where we want sample data, and we are in a
+ * grouped kanban or list view with real groups, but all groups are empty.
+ * In this case, we use the result of the web_read_group rpc to tweak the
+ * data in the SampleServer instance of the sampleModel (so that calls to
+ * that server will return the same groups).
+ *
+ * @override
+ */
+ async _rpc(params) {
+ const result = await this._super(...arguments);
+ if (this.sampleModel && params.method === 'web_read_group' && result.length) {
+ const sampleServer = this.sampleModel.sampleServer;
+ sampleServer.setExistingGroups(result.groups);
+ }
+ return result;
+ },
+ /**
* Allows to save a value in the specialData cache associated to a given
* record and fieldName. If the value in the cache was already the given
* one, nothing is done and the method indicates it by returning false
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index fd285010054..8db6058f242 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -455,7 +455,7 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
* @returns {boolean}
*/
_hasContent: function () {
- return this.state.count !== 0;
+ return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample);
},
/**
* Force the resequencing of the records after moving one of them to a given
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index f1e093e0479..eb6ef5a2549 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -197,7 +197,8 @@ var BasicView = AbstractView.extend({
}
}
return Promise.resolve(def).then(function () {
- return model.get(record.id);
+ const handle = record.id;
+ return { state: model.get(handle), handle };
});
}
return this._super.apply(this, arguments);
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
index 403d828d824..530f4775aa1 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -173,7 +173,7 @@ return AbstractModel.extend({
* @override
* @returns {Object}
*/
- get: function () {
+ __get: function () {
return _.extend({}, this.data, {
fields: this.fields
});
@@ -183,7 +183,7 @@ return AbstractModel.extend({
* @param {any} params
* @returns {Promise}
*/
- load: function (params) {
+ __load: function (params) {
var self = this;
this.modelName = params.modelName;
this.fields = params.fields;
@@ -256,7 +256,7 @@ return AbstractModel.extend({
* @param {Array} [params.domain]
* @returns {Promise}
*/
- reload: function (handle, params) {
+ __reload: function (handle, params) {
if (params.domain) {
this.data.domain = params.domain;
}
diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js
index ccaa008f3f7..8679fafe4ff 100644
--- a/addons/web/static/src/js/views/graph/graph_model.js
+++ b/addons/web/static/src/js/views/graph/graph_model.js
@@ -34,7 +34,7 @@ return AbstractModel.extend({
* @override
* @returns {Object}
*/
- get: function () {
+ __get: function () {
return _.extend({}, this.chart);
},
/**
@@ -57,7 +57,7 @@ return AbstractModel.extend({
* @returns {Promise} The promise does not return a handle, we don't need
* to keep track of various entities.
*/
- load: function (params) {
+ __load: function (params) {
var groupBys = params.context.graph_groupbys || params.groupBys;
this.initialGroupBys = groupBys;
this.fields = params.fields;
@@ -97,7 +97,7 @@ return AbstractModel.extend({
* @param {Object} [params.timeRanges]
* @returns {Promise}
*/
- reload: function (handle, params) {
+ __reload: function (handle, params) {
if ('context' in params) {
this.chart.context = params.context;
this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js
index ab994628c8c..03c34b358e0 100644
--- a/addons/web/static/src/js/views/kanban/kanban_column.js
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -191,7 +191,7 @@ var KanbanColumn = Widget.extend({
*
* @returns {Promise}
*/
- addQuickCreate: function () {
+ addQuickCreate: async function () {
if (this.folded) {
// first open the column, and then add the quick create
this.trigger_up('column_toggle_fold', {
@@ -204,7 +204,6 @@ var KanbanColumn = Widget.extend({
return Promise.reject();
}
this.trigger_up('close_quick_create'); // close other quick create widgets
- this.trigger_up('start_quick_create');
var context = this.data.getContext();
context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy);
this.quickCreateWidget = new RecordQuickCreate(this, {
@@ -212,7 +211,10 @@ var KanbanColumn = Widget.extend({
formViewRef: this.quickCreateView,
model: this.modelName,
});
- return this.quickCreateWidget.insertAfter(this.$header);
+ await this.quickCreateWidget.appendTo(document.createDocumentFragment());
+ this.trigger_up('start_quick_create');
+ this.quickCreateWidget.$el.insertAfter(this.$header);
+ this.quickCreateWidget.on_attach_callback();
},
/**
* Closes the quick create widget if it isn't dirty.
@@ -285,7 +287,7 @@ var KanbanColumn = Widget.extend({
* @private
*/
_onAddQuickCreate: function () {
- this.addQuickCreate();
+ this.trigger_up('add_quick_create', { groupId: this.db_id });
},
/**
* @private
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 3cf075141b2..ea45c58f253 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -21,6 +21,7 @@ var qweb = core.qweb;
var KanbanController = BasicController.extend({
buttons_template: 'KanbanView.buttons',
custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ add_quick_create: '_onAddQuickCreate',
quick_create_add_column: '_onAddColumn',
quick_create_record: '_onQuickCreateRecord',
resequence_columns: '_onResequenceColumn',
@@ -91,6 +92,20 @@ var KanbanController = BasicController.extend({
//--------------------------------------------------------------------------
/**
+ * Displays the record quick create widget in the requested column, given its
+ * id (in the first column by default). Ensures that we removed sample data
+ * if any, before displaying the quick create.
+ *
+ * @private
+ * @param {string} [groupId]
+ */
+ _addQuickCreate(groupId) {
+ this._removeSampleData(async () => {
+ await this.update({ shouldUpdateControlPanel: false }, { reload: false });
+ return this.renderer.addQuickCreate(groupId);
+ });
+ },
+ /**
* @override method comes from field manager mixin
* @private
* @param {string} id local id from the basic record data
@@ -205,12 +220,13 @@ var KanbanController = BasicController.extend({
*/
_shouldBounceOnClick(element) {
const state = this.model.get(this.handle, {raw: true});
- if (!state.count) {
+ if (!state.count || state.isSample) {
const classesList = [
'o_kanban_view',
'o_kanban_group',
+ 'o_kanban_header',
'o_column_quick_create',
- 'o_view_nocontent_smiling_face'
+ 'o_view_nocontent_smiling_face',
];
return classesList.some(c => element.classList.contains(c));
}
@@ -269,6 +285,15 @@ var KanbanController = BasicController.extend({
/**
* @private
* @param {OdooEvent} ev
+ * @returns {string} ev.data.groupId
+ */
+ _onAddQuickCreate(ev) {
+ ev.stopPropagation();
+ this._addQuickCreate(ev.data.groupId);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
*/
_onButtonClicked: function (ev) {
var self = this;
@@ -309,16 +334,13 @@ var KanbanController = BasicController.extend({
* @private
*/
_onButtonNew: function () {
- var self = this;
var state = this.model.get(this.handle, {raw: true});
var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state);
if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) {
// activate the quick create in the first column when the mutex is
// unlocked, to ensure that there is no pending re-rendering that
// would remove it (e.g. if we are currently adding a new column)
- this.mutex.getUnlockedDef().then(function () {
- self.renderer.addQuickCreate();
- });
+ this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null));
} else if (this.on_create && this.on_create !== 'quick_create') {
// Execute the given action
this.do_action(this.on_create, {
@@ -357,15 +379,12 @@ var KanbanController = BasicController.extend({
* @param {OdooEvent} ev
*/
_onDeleteColumn: function (ev) {
- var self = this;
var column = ev.target;
var state = this.model.get(this.handle, {raw: true});
var relatedModelName = state.fields[state.groupedBy[0]].relation;
this.model
.deleteRecords([column.db_id], relatedModelName)
- .then(function () {
- self.update({}, {reload: !column.isEmpty()});
- });
+ .then(this.update.bind(this, {}, {}));
},
/**
* @private
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index 3025adde2cb..7dcfe4081aa 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -10,7 +10,6 @@ var BasicModel = require('web.BasicModel');
var viewUtils = require('web.viewUtils');
var KanbanModel = BasicModel.extend({
-
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -69,28 +68,36 @@ var KanbanModel = BasicModel.extend({
context: parent.context, // todo: combine with view context
})
.then(function (result) {
- var newGroup = self._makeDataPoint({
- modelName: parent.model,
- context: parent.context,
- domain: parent.domain.concat([[groupBy,"=",result[0]]]),
- fields: parent.fields,
- fieldsInfo: parent.fieldsInfo,
- isOpen: true,
- limit: parent.limit,
- parentID: parent.id,
- openGroupByDefault: true,
- orderedBy: parent.orderedBy,
- value: result,
- viewType: parent.viewType,
- });
- if (parent.progressBar) {
- newGroup.progressBarValues = _.extend({
- counts: {},
- }, parent.progressBar);
- }
-
- // newGroup.is_open = true;
+ const createGroupDataPoint = (model, parent) => {
+ const newGroup = model._makeDataPoint({
+ modelName: parent.model,
+ context: parent.context,
+ domain: parent.domain.concat([[groupBy, "=", result[0]]]),
+ fields: parent.fields,
+ fieldsInfo: parent.fieldsInfo,
+ isOpen: true,
+ limit: parent.limit,
+ parentID: parent.id,
+ openGroupByDefault: true,
+ orderedBy: parent.orderedBy,
+ value: result,
+ viewType: parent.viewType,
+ });
+ if (parent.progressBar) {
+ newGroup.progressBarValues = _.extend({
+ counts: {},
+ }, parent.progressBar);
+ }
+ return newGroup;
+ };
+ const newGroup = createGroupDataPoint(self, parent);
parent.data.push(newGroup.id);
+ if (self.isInSampleMode()) {
+ // in sample mode, create the new group in both models (main + sample)
+ const sampleParent = self.sampleModel.localData[parentID];
+ const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent);
+ sampleParent.data.push(newSampleGroup.id);
+ }
return newGroup.id;
});
},
@@ -143,7 +150,7 @@ var KanbanModel = BasicModel.extend({
* @see _readTooltipFields
* @returns {Object}
*/
- get: function () {
+ __get: function () {
var result = this._super.apply(this, arguments);
var dp = result && this.localData[result.id];
if (dp) {
@@ -178,7 +185,7 @@ var KanbanModel = BasicModel.extend({
/**
* @override
*/
- load: function (params) {
+ __load: function (params) {
this.defaultGroupedBy = params.groupBy || [];
params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy;
return this._super(params);
@@ -262,6 +269,12 @@ var KanbanModel = BasicModel.extend({
if (options && options.groupBy && !options.groupBy.length) {
options.groupBy = this.defaultGroupedBy;
}
+ return this._super(id, options);
+ },
+ /**
+ * @override
+ */
+ __reload: function (id, options) {
var def = this._super(id, options);
if (options && options.loadMoreOffset) {
return def;
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
index 1b1cfc5c147..8152d899150 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -152,12 +152,20 @@ var KanbanRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
/**
- * Displays the quick create record in the first column.
+ * Displays the quick create record in the requested column (first one by
+ * default)
*
+ * @params {string} [groupId] local id of the group in which the quick create
+ * must be inserted
* @returns {Promise}
*/
- addQuickCreate: function () {
- return this.widgets[0].addQuickCreate();
+ addQuickCreate: function (groupId) {
+ let kanbanColumn;
+ if (groupId) {
+ kanbanColumn = this.widgets.find(column => column.db_id === groupId);
+ }
+ kanbanColumn = kanbanColumn || this.widgets[0];
+ return kanbanColumn.addQuickCreate();
},
/**
* Focuses the first kanban record
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 709deced4df..51f95e5a9ff 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -213,20 +213,22 @@ var ListController = BasicController.extend({
_addRecord: function (dataPointId) {
var self = this;
this._disableButtons();
- return this.renderer.unselectRow().then(function () {
- return self.model.addDefaultRecord(dataPointId, {
- position: self.editable,
- });
- }).then(function (recordID) {
- var state = self.model.get(self.handle);
- self.renderer.updateState(state, {keepWidths: true})
- .then(function () {
- self.renderer.editRecord(recordID);
- })
- .then(() => {
- self._updatePaging(state);
+ return this._removeSampleData(() => {
+ return this.renderer.unselectRow().then(function () {
+ return self.model.addDefaultRecord(dataPointId, {
+ position: self.editable,
});
- }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
+ }).then(function (recordID) {
+ var state = self.model.get(self.handle);
+ self._updateRendererState(state, { keepWidths: true })
+ .then(function () {
+ self.renderer.editRecord(recordID);
+ })
+ .then(() => {
+ self._updatePaging(state);
+ });
+ }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
+ });
},
/**
* Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard
@@ -524,7 +526,7 @@ var ListController = BasicController.extend({
*/
_shouldBounceOnClick() {
const state = this.model.get(this.handle, {raw: true});
- return !state.count;
+ return !state.count || state.isSample;
},
/**
* Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js
index 9ba0b473a5a..5d40343ef8d 100644
--- a/addons/web/static/src/js/views/list/list_model.js
+++ b/addons/web/static/src/js/views/list/list_model.js
@@ -25,7 +25,7 @@ odoo.define('web.ListModel', function (require) {
* @override
* @see _readGroupExtraFields
*/
- get: function () {
+ __get: function () {
var result = this._super.apply(this, arguments);
var dp = result && this.localData[result.id];
if (dp && dp.groupData) {
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
index c2c57250022..428a781319f 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -35,6 +35,7 @@ var FIELD_CLASSES = {
};
var ListRenderer = BasicRenderer.extend({
+ className: 'o_list_view',
events: {
"mousedown": "_onMouseDown",
"click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn",
@@ -1021,80 +1022,82 @@ var ListRenderer = BasicRenderer.extend({
* this method does not wait for the field widgets to be ready.
*
* @override
- * @private
* @returns {Promise} resolved when the view has been rendered
*/
- _renderView: function () {
- var self = this;
-
+ async _renderView() {
const oldPagers = this.pagers;
- this.pagers = [];
+ let prom;
+ let tableWrapper;
+ if (this.state.count > 0 || !this.noContentHelp) {
+ // render a table if there are records, or if there is no no content
+ // helper (empty table in this case)
+ this.pagers = [];
- // display the no content helper if there is no data to display
- var displayNoContentHelper = !this._hasContent() && !!this.noContentHelp;
- this.$el.toggleClass('o_list_view', !displayNoContentHelper);
- if (displayNoContentHelper) {
- // destroy the previously instantiated pagers, if any
- oldPagers.forEach(pager => pager.destroy());
+ const orderedBy = this.state.orderedBy;
+ this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
+ this._computeAggregates();
- this.$el.removeClass('table-responsive');
- this.$el.html(this._renderNoContentHelper());
- return this._super.apply(this, arguments);
+ const $table = $(
+ '<table class="o_list_table table table-sm table-hover table-striped"/>'
+ );
+ $table.toggleClass('o_list_table_grouped', this.isGrouped);
+ $table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
+ const defs = [];
+ this.defs = defs;
+ if (this.isGrouped) {
+ $table.append(this._renderHeader());
+ $table.append(this._renderGroups(this.state.data));
+ $table.append(this._renderFooter());
+
+ } else {
+ $table.append(this._renderHeader());
+ $table.append(this._renderBody());
+ $table.append(this._renderFooter());
+ }
+ tableWrapper = Object.assign(document.createElement('div'), {
+ className: 'table-responsive',
+ });
+ tableWrapper.appendChild($table[0]);
+ delete this.defs;
+ prom = Promise.all(defs);
}
- var orderedBy = this.state.orderedBy;
- this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
- this._computeAggregates();
+ await Promise.all([this._super.apply(this, arguments), prom]);
- var $table = $('<table>').addClass('o_list_table table table-sm table-hover table-striped');
- $table.toggleClass('o_list_table_grouped', this.isGrouped);
- $table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
- var defs = [];
- this.defs = defs;
- if (this.isGrouped) {
- $table.append(this._renderHeader());
- $table.append(this._renderGroups(this.state.data));
- $table.append(this._renderFooter());
+ this.el.innerHTML = "";
+ this.el.classList.remove('o_list_optional_columns');
- } else {
- $table.append(this._renderHeader());
- $table.append(this._renderBody());
- $table.append(this._renderFooter());
- }
- const tableWrapper = Object.assign(document.createElement('div'), {
- className: 'table-responsive',
- });
- tableWrapper.appendChild($table[0]);
- delete this.defs;
-
- var prom = Promise.all(defs).then(() => {
- // destroy the previously instantiated pagers, if any
- oldPagers.forEach(pager => pager.destroy());
+ // destroy the previously instantiated pagers, if any
+ oldPagers.forEach(pager => pager.destroy());
- // Append the table to the main element
- self.el.innerHTML = "";
- dom.append(self.el, tableWrapper, {
+ // append the table (if any) to the main element
+ if (tableWrapper) {
+ dom.append(this.el, tableWrapper, {
callbacks: [{ widget: this }],
- in_DOM: document.body.contains(self.el),
+ in_DOM: document.body.contains(this.el),
});
-
- if (self.optionalColumns.length) {
- self.$el.addClass('o_list_optional_columns');
- self.$('table').append($('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>'));
- self.$el.append(self._renderOptionalColumnsDropdown());
+ if (this.optionalColumns.length) {
+ this.el.classList.add('o_list_optional_columns');
+ this.$('table').append(
+ $('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>')
+ );
+ this.$el.append(this._renderOptionalColumnsDropdown());
}
-
- if (self.selection.length) {
- var $checked_rows = self.$('tr').filter(function (index, el) {
- return _.contains(self.selection, $(el).data('id'));
- });
+ if (this.selection.length) {
+ const $checked_rows = this.$('tr').filter(
+ (i, el) => this.selection.includes(el.dataset.id)
+ );
$checked_rows.find('.o_list_record_selector input').prop('checked', true);
- if ($checked_rows.length === self.$('.o_data_row').length) { // all rows are checked
- self.$('thead .o_list_record_selector input').prop('checked', true);
+ if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked
+ this.$('thead .o_list_record_selector input').prop('checked', true);
}
}
- });
- return Promise.all([this._super.apply(this, arguments), prom]);
+ }
+
+ // display the no content helper if necessary
+ if (!this._hasContent() && !!this.noContentHelp) {
+ this.$el.append(this._renderNoContentHelper());
+ }
},
/**
* Each line or cell can be decorated according to a few simple rules. The
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index 3eeed181795..f1937655952 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -41,6 +41,7 @@ odoo.define('web.PivotController', function (require) {
init: function (parent, model, renderer, params) {
this._super(...arguments);
+ this.disableLinking = params.disableLinking;
this.measures = params.measures;
this.title = params.title;
// views to use in the action triggered when a data cell is clicked
@@ -273,7 +274,7 @@ odoo.define('web.PivotController', function (require) {
_onOpenView: function (ev) {
ev.stopPropagation();
const cell = ev.data;
- if (cell.value === undefined || this.initialState.disableLinking) {
+ if (cell.value === undefined || this.disableLinking) {
return;
}
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index d00f75f5079..ceb15f24018 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -497,7 +497,7 @@ var PivotModel = AbstractModel.extend({
* @param {boolean} [options.raw=false]
* @returns {Object}
*/
- get: function (options) {
+ __get: function (options) {
options = options || {};
var raw = options.raw || false;
var groupBys = this._getGroupBys();
@@ -546,7 +546,7 @@ var PivotModel = AbstractModel.extend({
* @param {Object} params.timeRanges
* @returns {Promise}
*/
- load: function (params) {
+ __load: function (params) {
this.initialDomain = params.domain;
this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys;
this.defaultGroupedBy = params.groupedBy;
@@ -592,7 +592,7 @@ var PivotModel = AbstractModel.extend({
* @param {Object} [params.timeRanges]
* @returns {Promise}
*/
- reload: function (handle, params) {
+ __reload: function (handle, params) {
var self = this;
var oldColGroupBys = this.data.colGroupBys;
var oldRowGroupBys = this.data.rowGroupBys;
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index 7ff00d1fb77..7c021d6e3c0 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -114,9 +114,13 @@ odoo.define('web.PivotView', function (require) {
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
this.loadParams.groupableFields = groupableFields;
+ const disableLinking = !!(this.arch.attrs.disable_linking &&
+ JSON.stringify(this.arch.attrs.disable_linking));
+
this.rendererParams.widgets = widgets;
- this.rendererParams.disableLinking = this.arch.attrs.disable_linking;
+ this.rendererParams.disableLinking = disableLinking;
+ this.controllerParams.disableLinking = disableLinking;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
this.controllerParams.measures = measures;
diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js
index 21ad867c286..4e7f802447d 100644
--- a/addons/web/static/src/js/views/qweb/qweb_view.js
+++ b/addons/web/static/src/js/views/qweb/qweb_view.js
@@ -51,13 +51,13 @@ var Model = AbstractModel.extend({
/**
* get
*/
- get: function () {
+ __get: function () {
return this._state;
},
/**
* load
*/
- load: function (params) {
+ __load: function (params) {
_.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context']));
return this._fetch();
@@ -65,7 +65,7 @@ var Model = AbstractModel.extend({
/**
* reload
*/
- reload: function (_id, params) {
+ __reload: function (_id, params) {
_.extend(this._state, _.pick(params, ['domain', 'context']));
return this._fetch();
diff --git a/addons/web/static/src/js/views/sample_server.js b/addons/web/static/src/js/views/sample_server.js
new file mode 100644
index 00000000000..6eaa023c47a
--- /dev/null
+++ b/addons/web/static/src/js/views/sample_server.js
@@ -0,0 +1,609 @@
+odoo.define('web.SampleServer', function (require) {
+ "use strict";
+
+ const session = require('web.session');
+ const utils = require('web.utils');
+ const Registry = require('web.Registry');
+
+ class UnimplementedRouteError extends Error {}
+
+ /**
+ * Helper function returning the value from a list of sample strings
+ * corresponding to the given ID.
+ * @param {number} id
+ * @param {string[]} sampleTexts
+ * @returns {string}
+ */
+ function getSampleFromId(id, sampleTexts) {
+ return sampleTexts[(id - 1) % sampleTexts.length];
+ }
+
+ /**
+ * Helper function returning a regular expression specifically matching
+ * a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
+ * will match:
+ * - "abc"
+ * - "field_abc__def"
+ * will not match:
+ * - "aabc"
+ * - "abcd_ef"
+ * @param {...string} term
+ * @returns {RegExp}
+ */
+ function fieldNameRegex(...terms) {
+ return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`);
+ }
+
+ const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message');
+ const EMAIL_REGEX = fieldNameRegex('email');
+ const PHONE_REGEX = fieldNameRegex('phone');
+ const URL_REGEX = fieldNameRegex('url');
+
+ /**
+ * Sample server class
+ *
+ * Represents a static instance of the server used when a RPC call sends
+ * empty values/groups while the attribute 'sample' is set to true on the
+ * view.
+ *
+ * This server will generate fake data and send them in the adequate format
+ * according to the route/method used in the RPC.
+ */
+ class SampleServer {
+
+ /**
+ * @param {string} modelName
+ * @param {Object} fields
+ */
+ constructor(modelName, fields) {
+ this.mainModel = modelName;
+ this.data = {};
+ this.data[modelName] = {
+ fields,
+ records: [],
+ };
+ // Generate relational fields' co models
+ for (const fieldName in fields) {
+ const field = fields[fieldName];
+ if (['many2one', 'one2many', 'many2many'].includes(field.type)) {
+ this.data[field.relation] = this.data[field.relation] || {
+ fields: {
+ display_name: { type: 'char' },
+ id: { type: 'integer' },
+ color: { type: 'integer' },
+ },
+ records: [],
+ };
+ }
+ }
+ // On some models, empty grouped Kanban or List view still contain
+ // real (empty) groups. In this case, we re-use the result of the
+ // web_read_group rpc to tweak sample data s.t. those real groups
+ // contain sample records.
+ this.existingGroups = null;
+ // Sample records generation is only done if necessary, so we delay
+ // it to the first "mockRPC" call. These flags allow us to know if
+ // the records have been generated or not.
+ this.populated = false;
+ this.existingGroupsPopulated = false;
+ }
+
+ //---------------------------------------------------------------------
+ // Public
+ //---------------------------------------------------------------------
+
+ /**
+ * This is the main entry point of the SampleServer. Mocks a request to
+ * the server with sample data.
+ * @param {Object} params
+ * @returns {any} the result obtained with the sample data
+ * @throws {Error} If called on a route/method we do not handle
+ */
+ mockRpc(params) {
+ if (!(params.model in this.data)) {
+ throw new Error(`SampleServer: unknown model ${params.model}`);
+ }
+ this._populateModels();
+ switch (params.method || params.route) {
+ case '/web/dataset/search_read':
+ return this._mockSearchReadController(params);
+ case 'web_read_group':
+ return this._mockWebReadGroup(params);
+ case 'read_group':
+ return this._mockReadGroup(params);
+ case 'read_progress_bar':
+ return this._mockReadProgressBar(params);
+ case 'read':
+ return this._mockRead(params);
+ }
+ const mock = SampleServer.mockRegistry.get(params.model);
+ const mockFunction = mock && mock[params.method || params.route];
+ if (mockFunction) {
+ return mockFunction.call(this, params);
+ }
+ console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
+ throw new SampleServer.UnimplementedRouteError();
+ }
+
+ setExistingGroups(groups) {
+ this.existingGroups = groups;
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @param {Object[]} measures, each measure has the form { fieldName, type }
+ * @param {Object[]} records
+ * @returns {Object}
+ */
+ _aggregateFields(measures, records) {
+ const values = {};
+ for (const { fieldName, type } of measures) {
+ if (type === 'float' || type === 'integer') {
+ if (records.length) {
+ let value = 0;
+ for (const record of records) {
+ value += record[fieldName];
+ }
+ values[fieldName] = value;
+ } else {
+ values[fieldName] = null;
+ }
+ }
+ if (type === 'many2one') {
+ const ids = new Set(records.map(r => r[fieldName]));
+ values.fieldName = ids.size || null;
+ }
+ }
+ return values;
+ }
+
+ /**
+ * @param {any} value
+ * @param {Object} options
+ * @param {string} [options.interval]
+ * @param {string} [options.relation]
+ * @param {string} [options.type]
+ * @returns {any}
+ */
+ _formatValue(value, options) {
+ if (!value) {
+ return false;
+ }
+ const { type, interval, relation } = options;
+ if (['date', 'datetime'].includes(type)) {
+ const fmt = SampleServer.FORMATS[interval];
+ return moment(value).format(fmt);
+ } else if (type === 'many2one') {
+ const rec = this.data[relation].records.find(({id}) => id === value);
+ return [value, rec.display_name];
+ } else {
+ return value;
+ }
+ }
+
+ /**
+ * Generates field values based on heuristics according to field types
+ * and names.
+ *
+ * @private
+ * @param {string} modelName
+ * @param {string} fieldName
+ * @param {number} id the record id
+ * @returns {any} the field value
+ */
+ _generateFieldValue(modelName, fieldName, id) {
+ const field = this.data[modelName].fields[fieldName];
+ switch (field.type) {
+ case "boolean":
+ return fieldName === 'active' ? true : Math.random() < 0.5;
+ case "char":
+ case "text":
+ if (["display_name", "name"].includes(fieldName)) {
+ if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
+ return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
+ } else if (modelName === 'res.country') {
+ return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
+ }
+ }
+ if (fieldName === 'display_name') {
+ return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
+ } else if (["name", "reference"].includes(fieldName)) {
+ return `REF${String(id).padStart(4, '0')}`;
+ } else if (DESCRIPTION_REGEX.test(fieldName)) {
+ return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
+ } else if (EMAIL_REGEX.test(fieldName)) {
+ const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
+ .replace(/ /, ".")
+ .toLowerCase();
+ return `${emailName}@sample.demo`;
+ } else if (PHONE_REGEX.test(fieldName)) {
+ return `+1 555 754 ${String(id).padStart(4, '0')}`;
+ } else if (URL_REGEX.test(fieldName)) {
+ return `http://sample${id}.com`;
+ }
+ return false;
+ case "date":
+ case "datetime": {
+ const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA);
+ const date = new moment().add(delta, "hour").format(
+ field.type === "date" ? "YYYY-MM-DD" : "YYYY-MM-DD HH:mm:ss"
+ );
+ return date;
+ }
+ case "float":
+ return Math.random() * SampleServer.MAX_FLOAT;
+ case "integer": {
+ let max = SampleServer.MAX_INTEGER;
+ if (fieldName.includes('color')) {
+ max = Math.random() < 0.5 ? SampleServer.MAX_COLOR_INT : 0;
+ }
+ return Math.floor(Math.random() * max);
+ }
+ case "monetary":
+ return Math.floor(Math.random() * SampleServer.MAX_MONETARY);
+ case "many2one":
+ if (field.relation === 'res.currency') {
+ return session.company_currency_id;
+ }
+ return this._getRandomSubRecordId();
+ case "one2many":
+ case "many2many": {
+ const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
+ return [...new Set(ids)];
+ }
+ case "selection": {
+ if (field.selection.length > 0) {
+ const index = Math.floor(Math.random() * field.selection.length);
+ return field.selection[index][0];
+ }
+ return false;
+ }
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Generates a random id in the range of ids generated for sub models.
+ * @private
+ * @returns {number} id in [1, SUB_RECORDSET_SIZE]
+ */
+ _getRandomSubRecordId() {
+ return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
+ }
+
+ /**
+ * Mocks calls to the read method.
+ * @private
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {Array[]} params.args (args[0] is the list of ids, args[1] is
+ * the list of fields)
+ * @returns {Object[]}
+ */
+ _mockRead(params) {
+ const model = this.data[params.model];
+ const ids = params.args[0];
+ const fieldNames = params.args[1];
+ const records = [];
+ for (const r of model.records) {
+ if (!ids.includes(r.id)) {
+ continue;
+ }
+ const record = { id: r.id };
+ for (const fieldName of fieldNames) {
+ const field = model.fields[fieldName];
+ if (!field) {
+ record[fieldName] = false; // unknown field
+ } else if (field.type === 'many2one') {
+ const relModel = this.data[field.relation];
+ const relRecord = relModel.records.find(
+ relR => r[fieldName] === relR.id
+ );
+ record[fieldName] = relRecord ?
+ [relRecord.id, relRecord.display_name] :
+ false;
+ } else {
+ record[fieldName] = r[fieldName];
+ }
+ }
+ records.push(record);
+ }
+ return records;
+ }
+
+ /**
+ * Mocks calls to the read_group method.
+ *
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} [params.fields] defaults to the list of all fields
+ * @param {string[]} params.groupBy
+ * @param {boolean} [params.lazy=true]
+ * @returns {Object[]} Object with keys groups and length
+ */
+ _mockReadGroup(params) {
+ const lazy = 'lazy' in params ? params.lazy : true;
+ const model = params.model;
+ const fields = this.data[model].fields;
+ const records = this.data[model].records;
+
+ const normalizedGroupBys = [];
+ let groupBy = [];
+ if (params.groupBy.length) {
+ groupBy = lazy ? [params.groupBy[0]] : params.groupBy;
+ }
+ for (const groupBySpec of groupBy) {
+ let [fieldName, interval] = groupBySpec.split(':');
+ const { type, relation } = fields[fieldName];
+ if (type) {
+ const gb = { fieldName, type, interval, relation };
+ normalizedGroupBys.push(gb);
+ }
+ }
+ const groups = utils.groupBy(records, (record) => {
+ const vals = {};
+ for (const gb of normalizedGroupBys) {
+ const { fieldName, type } = gb;
+ let value;
+ if (['date', 'datetime'].includes(type)) {
+ value = this._formatValue(record[fieldName], gb);
+ } else {
+ value = record[fieldName];
+ }
+ vals[fieldName] = value;
+ }
+ return JSON.stringify(vals);
+ });
+ const measures = [];
+ for (const measureSpec of (params.fields || Object.keys(fields))) {
+ const [fieldName, aggregateFunction] = measureSpec.split(':');
+ const { type } = fields[fieldName];
+ if (!params.groupBy.includes(fieldName) && type &&
+ (type !== 'many2one' || aggregateFunction !== 'count_distinct')) {
+ measures.push({ fieldName, type });
+ }
+ }
+
+ const result = [];
+ for (const id in groups) {
+ const records = groups[id];
+ const group = { __domain: [] };
+ let countKey = `__count`;
+ if (normalizedGroupBys.length && lazy) {
+ countKey = `${normalizedGroupBys[0].fieldName}_count`;
+ }
+ group[countKey] = records.length;
+ const firstElem = records[0];
+ for (const gb of normalizedGroupBys) {
+ const { fieldName } = gb;
+ group[fieldName] = this._formatValue(firstElem[fieldName], gb);
+ }
+ Object.assign(group, this._aggregateFields(measures, records));
+ result.push(group);
+ }
+ return result;
+ }
+
+ /**
+ * Mocks calls to the read_progress_bar method.
+ * @private
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {Object} params.kwargs
+ * @return {Object}
+ */
+ _mockReadProgressBar(params) {
+ const groupBy = params.kwargs.group_by;
+ const progress_bar = params.kwargs.progress_bar;
+ const groupByField = this.data[params.model].fields[groupBy];
+ const data = {};
+ for (const record of this.data[params.model].records) {
+ let groupByValue = record[groupBy];
+ if (groupByField.type === "many2one") {
+ const relatedRecords = this.data[groupByField.relation].records;
+ const relatedRecord = relatedRecords.find(r => r.id === groupByValue);
+ groupByValue = relatedRecord.display_name;
+ }
+ if (!(groupByValue in data)) {
+ data[groupByValue] = {};
+ for (const key in progress_bar.colors) {
+ data[groupByValue][key] = 0;
+ }
+ }
+ const fieldValue = record[progress_bar.field];
+ if (fieldValue in data[groupByValue]) {
+ data[groupByValue][fieldValue]++;
+ }
+ }
+ return data;
+ }
+
+ /**
+ * Mocks calls to the /web/dataset/search_read route to return sample
+ * records.
+ * @private
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} params.fields
+ * @returns {{ records: Object[], length: number }}
+ */
+ _mockSearchReadController(params) {
+ const model = this.data[params.model];
+ const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT);
+ const records = this._mockRead({
+ model: params.model,
+ args: [rawRecords.map(r => r.id), params.fields],
+ });
+ return { records, length: records.length };
+ }
+
+ /**
+ * Mocks calls to the web_read_group method to return groups populated
+ * with sample records. Only handles the case where the real call to
+ * web_read_group returned groups, but none of these groups contain
+ * records. In this case, we keep the real groups, and populate them
+ * with sample records.
+ * @private
+ * @param {Object} params
+ * @param {Object} [result] the result of a real call to web_read_group
+ * @returns {{ groups: Object[], length: number }}
+ */
+ _mockWebReadGroup(params) {
+ let groups;
+ if (this.existingGroups) {
+ this._tweakExistingGroups(params);
+ groups = this.existingGroups;
+ } else {
+ groups = this._mockReadGroup(params);
+ }
+ return {
+ groups,
+ length: groups.length,
+ };
+ }
+
+ /**
+ * Updates the sample data such that the existing groups (in database)
+ * also exists in the sample, and such that there are sample records in
+ * those groups.
+ * @private
+ * @param {Object[]} groups empty groups returned by the server
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} params.groupBy
+ */
+ _populateExistingGroups(params) {
+ if (!this.existingGroupsPopulated) {
+ const groups = this.existingGroups;
+ this.groupsInfo = groups;
+ const groupBy = params.groupBy[0];
+ const values = groups.map(g => g[groupBy]);
+ const groupByField = this.data[params.model].fields[groupBy];
+ const groupedByM2O = groupByField.type === 'many2one';
+ if (groupedByM2O) { // re-populate co model with relevant records
+ this.data[groupByField.relation].records = values.map(v => {
+ return { id: v[0], display_name: v[1] };
+ });
+ }
+ for (const r of this.data[params.model].records) {
+ const value = getSampleFromId(r.id, values);
+ r[groupBy] = groupedByM2O ? value[0] : value;
+ }
+ this.existingGroupsPopulated = true;
+ }
+ }
+
+ /**
+ * Generates sample records for the models in this.data. Records will be
+ * generated once, and subsequent calls to this function will be skipped.
+ * @private
+ */
+ _populateModels() {
+ if (!this.populated) {
+ for (const modelName in this.data) {
+ const model = this.data[modelName];
+ const fieldNames = Object.keys(model.fields).filter(f => f !== 'id');
+ const size = modelName === this.mainModel ?
+ SampleServer.MAIN_RECORDSET_SIZE :
+ SampleServer.SUB_RECORDSET_SIZE;
+ for (let id = 1; id <= size; id++) {
+ const record = { id };
+ for (const fieldName of fieldNames) {
+ record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
+ }
+ model.records.push(record);
+ }
+ }
+ this.populated = true;
+ }
+ }
+
+ /**
+ * A real (web_)read_group call has been done, and it has returned groups,
+ * but they are all empty. This function updates the sample data such
+ * that those group values exist and those groups contain sample records.
+ * @private
+ * @param {Object[]} groups empty groups returned by the server
+ * @param {Object} params
+ * @param {string} params.model
+ * @param {string[]} params.fields
+ * @param {string[]} params.groupBy
+ * @returns {Object[]} groups with count and aggregate values updated
+ *
+ * TODO: rename
+ */
+ _tweakExistingGroups(params) {
+ const groups = this.existingGroups;
+ this._populateExistingGroups(params);
+
+ // update count and aggregates for each group
+ const groupBy = params.groupBy[0].split(':')[0];
+ const groupByField = this.data[params.model].fields[groupBy];
+ const groupedByM2O = groupByField.type === 'many2one';
+ const records = this.data[params.model].records;
+ for (const g of groups) {
+ const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy];
+ const recordsInGroup = records.filter(r => r[groupBy] === groupValue);
+ g[`${groupBy}_count`] = recordsInGroup.length;
+ for (const field of params.fields) {
+ const fieldType = this.data[params.model].fields[field].type;
+ if (['integer, float', 'monetary'].includes(fieldType)) {
+ g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
+ }
+ }
+ g.__data = {
+ records: this._mockRead({
+ model: params.model,
+ args: [recordsInGroup.map(r => r.id), params.fields],
+ }),
+ length: recordsInGroup.length,
+ };
+ }
+ }
+ }
+
+ SampleServer.FORMATS = {
+ day: 'YYYY-MM-DD',
+ week: 'ww YYYY',
+ month: 'MMMM YYYY',
+ quarter: '[Q]Q YYYY',
+ year: 'Y',
+ };
+
+ SampleServer.MAIN_RECORDSET_SIZE = 16;
+ SampleServer.SUB_RECORDSET_SIZE = 5;
+ SampleServer.SEARCH_READ_LIMIT = 10;
+
+ SampleServer.MAX_FLOAT = 100;
+ SampleServer.MAX_INTEGER = 50;
+ SampleServer.MAX_COLOR_INT = 7;
+ SampleServer.MAX_MONETARY = 100000;
+ SampleServer.DATE_DELTA = 24 * 7; // in hours -> 7 days
+
+ SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
+ SampleServer.SAMPLE_PEOPLE = [
+ "John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot",
+ ];
+ SampleServer.SAMPLE_TEXTS = [
+ "Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa",
+ ];
+ SampleServer.PEOPLE_MODELS = [
+ 'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact'
+ ];
+
+ SampleServer.UnimplementedRouteError = UnimplementedRouteError;
+
+ // mockRegistry allows to register mock version of methods or routes for
+ // specific models, for instance
+ // SampleServer.mockRegistry.registry('res.partner' {
+ // some_method: () => 23,
+ // some_route: () => "abcd",
+ // });
+ SampleServer.mockRegistry = new Registry();
+
+ return SampleServer;
+});