summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/web/static/src/js/core/mvc.js17
-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
-rw-r--r--addons/web/static/src/scss/list_view.scss1
-rw-r--r--addons/web/static/src/scss/views.scss37
-rw-r--r--addons/web/static/tests/helpers/test_utils_create.js2
-rw-r--r--addons/web/static/tests/views/abstract_model_tests.js130
-rw-r--r--addons/web/static/tests/views/basic_model_tests.js14
-rw-r--r--addons/web/static/tests/views/kanban_tests.js525
-rw-r--r--addons/web/static/tests/views/list_tests.js279
-rw-r--r--addons/web/static/tests/views/sample_server_tests.js461
-rw-r--r--addons/web/views/webclient_templates.xml3
-rw-r--r--doc/reference/views.rst12
32 files changed, 2586 insertions, 163 deletions
diff --git a/addons/web/static/src/js/core/mvc.js b/addons/web/static/src/js/core/mvc.js
index d7091cc4843..23f2b44b274 100644
--- a/addons/web/static/src/js/core/mvc.js
+++ b/addons/web/static/src/js/core/mvc.js
@@ -109,9 +109,11 @@ var Controller = Widget.extend({
* @param {Model} model
* @param {Renderer} renderer
* @param {Object} params
+ * @param {any} [params.handle=null]
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
+ this.handle = params.handle || null;
this.model = model;
this.renderer = renderer;
},
@@ -178,11 +180,13 @@ var Factory = Class.extend({
var self = this;
var model = this.getModel(parent);
return Promise.all([this._loadData(model), ajax.loadLibs(this)]).then(function (result) {
- var state = result[0];
+ const { state, handle } = result[0];
var renderer = self.getRenderer(parent, state);
var Controller = self.Controller || self.config.Controller;
+ const initialState = model.get(handle);
var controllerParams = _.extend({
- initialState: state,
+ initialState,
+ handle,
}, self.controllerParams);
var controller = new Controller(parent, model, renderer, controllerParams);
model.setParent(controller);
@@ -221,13 +225,16 @@ var Factory = Class.extend({
*
* @private
* @param {Model} model a Model instance
+ * @param {Object} [options={}]
+ * @param {boolean} [options.withSampleData=true]
* @returns {Promise<*>} a promise that resolves to the value returned by
* the get method from the model
* @todo: get rid of loadParams (use modelParams instead)
*/
- _loadData: function (model) {
- return model.load(this.loadParams).then(function () {
- return model.get.apply(model, arguments);
+ _loadData: function (model, options = {}) {
+ options.withSampleData = 'withSampleData' in options ? options.withSampleData : true;
+ return model.load(this.loadParams).then(function (handle) {
+ return { state: model.get(handle, options), handle };
});
},
});
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;
+});
diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss
index 61381d57160..7eea7e98ed2 100644
--- a/addons/web/static/src/scss/list_view.scss
+++ b/addons/web/static/src/scss/list_view.scss
@@ -1,5 +1,6 @@
.o_list_view {
position: relative;
+ height: 100%;
.o_list_table {
position: relative;
diff --git a/addons/web/static/src/scss/views.scss b/addons/web/static/src/scss/views.scss
index d917b3fd1d6..584ec66faed 100644
--- a/addons/web/static/src/scss/views.scss
+++ b/addons/web/static/src/scss/views.scss
@@ -40,7 +40,7 @@
// No content helper
.o_view_nocontent {
- @include o-position-absolute(20%, 0, 0, 0);
+ @include o-position-absolute(30%, 0, 0, 0);
pointer-events: none;
z-index: 1;
@@ -72,3 +72,38 @@
}
}
}
+
+// Sample data
+@mixin web_view_sample_data {
+ opacity: 0.33;
+ pointer-events: none;
+ user-select: none;
+}
+.o_view_sample_data {
+ .o_list_view {
+ .o_data_row, .o_group_header, .o_list_table > tfoot {
+ @include web_view_sample_data;
+ }
+ .o_list_table {
+ cursor: default !important;
+ > thead .o_list_record_selector {
+ pointer-events: none;
+ }
+ }
+ }
+ .o_kanban_view {
+ .o_kanban_counter,
+ .o_kanban_record,
+ .o_kanban_toggle_fold,
+ .o_column_folded,
+ .o_column_archive_records,
+ .o_column_unarchive_records {
+ @include web_view_sample_data;
+ }
+ }
+ .o_nocontent_help {
+ border-radius: 100%;
+ background-color: #f9f9f9;
+ box-shadow: 0 0 120px 100px #f9f9f9;
+ }
+}
diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js
index 801120bf64b..341ace4fba8 100644
--- a/addons/web/static/tests/helpers/test_utils_create.js
+++ b/addons/web/static/tests/helpers/test_utils_create.js
@@ -282,7 +282,7 @@ odoo.define('web.test_utils_create', function (require) {
async function createModel(params) {
const widget = new Widget();
- const model = new params.Model(widget);
+ const model = new params.Model(widget, params);
await testUtilsMock.addMockEnvironment(widget, params);
diff --git a/addons/web/static/tests/views/abstract_model_tests.js b/addons/web/static/tests/views/abstract_model_tests.js
new file mode 100644
index 00000000000..544a908b063
--- /dev/null
+++ b/addons/web/static/tests/views/abstract_model_tests.js
@@ -0,0 +1,130 @@
+odoo.define('web.abstract_model_tests', function (require) {
+ "use strict";
+
+ const AbstractModel = require('web.AbstractModel');
+ const Domain = require('web.Domain');
+
+ QUnit.module('Views', {}, function () {
+ QUnit.module('AbstractModel');
+
+ QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) {
+ assert.expect(4);
+
+ const Model = AbstractModel.extend({
+ _isEmpty() {
+ return true;
+ },
+ async __load() {
+ if (this.isSampleModel) {
+ await this._rpc({ model: 'partner', method: 'unknown' });
+ }
+ },
+ });
+
+ const model = new Model(null, {
+ modelName: 'partner',
+ fields: {},
+ useSampleModel: true,
+ SampleModel: Model,
+ });
+
+ assert.ok(model.useSampleModel);
+ assert.notOk(model._isInSampleMode);
+
+ await model.load({});
+
+ assert.notOk(model.useSampleModel);
+ assert.notOk(model._isInSampleMode);
+
+ model.destroy();
+ });
+
+ QUnit.test("don't cath general error on sample server in sample mode", async function (assert) {
+ assert.expect(5);
+
+ const error = new Error();
+
+ const Model = AbstractModel.extend({
+ _isEmpty() {
+ return true;
+ },
+ async __reload() {
+ if (this.isSampleModel) {
+ await this._rpc({ model: 'partner', method: 'read_group' });
+ }
+ },
+ async _rpc() {
+ throw error;
+ },
+ });
+
+ const model = new Model(null, {
+ modelName: 'partner',
+ fields: {},
+ useSampleModel: true,
+ SampleModel: Model,
+ });
+
+ assert.ok(model.useSampleModel);
+ assert.notOk(model._isInSampleMode);
+
+ await model.load({});
+
+ assert.ok(model.useSampleModel);
+ assert.ok(model._isInSampleMode);
+
+ async function reloadModel() {
+ try {
+ await model.reload();
+ } catch (e) {
+ assert.strictEqual(e, error);
+ }
+ }
+
+ await reloadModel();
+
+ model.destroy();
+ });
+
+ QUnit.test('fetch sample data: concurrency', async function (assert) {
+ assert.expect(3);
+
+ const Model = AbstractModel.extend({
+ _isEmpty() {
+ return true;
+ },
+ __get() {
+ return { isSample: !!this.isSampleModel };
+ },
+ });
+
+ const model = new Model(null, {
+ modelName: 'partner',
+ fields: {},
+ useSampleModel: true,
+ SampleModel: Model,
+ });
+
+ await model.load({ domain: Domain.FALSE_DOMAIN, });
+
+ const beforeReload = model.get(null, { withSampleData: true });
+
+ const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN });
+ const duringReload = model.get(null, { withSampleData: true });
+
+ await reloaded;
+
+ const afterReload = model.get(null, { withSampleData: true });
+
+ assert.strictEqual(beforeReload.isSample, true,
+ "Sample data flag must be true before reload"
+ );
+ assert.strictEqual(duringReload.isSample, true,
+ "Sample data flag must be true during reload"
+ );
+ assert.strictEqual(afterReload.isSample, false,
+ "Sample data flag must be true after reload"
+ );
+ });
+ });
+});
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index 3d28a32cf2e..f57478e1252 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -1309,7 +1309,7 @@ odoo.define('web.basic_model_tests', function (require) {
res_id: 1,
};
- model.load(params).then(function (resultID) {
+ await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.ok(record.data.date instanceof moment,
"fetched date field should have been formatted");
@@ -1317,7 +1317,7 @@ odoo.define('web.basic_model_tests', function (require) {
params.res_id = 2;
- model.load(params).then(function (resultID) {
+ await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.date, false,
"unset date field should be false");
@@ -1340,7 +1340,7 @@ odoo.define('web.basic_model_tests', function (require) {
type: 'list',
};
- model.load(params).then(function (resultID) {
+ await model.load(params).then(function (resultID) {
var record = model.get(resultID);
var firstRecord = record.data[0];
var secondRecord = record.data[1];
@@ -1367,7 +1367,7 @@ odoo.define('web.basic_model_tests', function (require) {
type: 'record',
};
- model.load(params).then(function (resultID) {
+ await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.date, false, "date default value should be false");
});
@@ -1391,7 +1391,7 @@ odoo.define('web.basic_model_tests', function (require) {
type: 'record',
};
- model.load(params).then(function (resultID) {
+ await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.ok(_.isEqual(record.data.category.res_ids, [12, 14]),
"category field should have correct default value");
@@ -1958,7 +1958,7 @@ odoo.define('web.basic_model_tests', function (require) {
},
});
- model.load(this.params);
+ await model.load(this.params);
assert.verifySteps(['read'],
"there should be only one read");
model.destroy();
@@ -1996,7 +1996,7 @@ odoo.define('web.basic_model_tests', function (require) {
data: this.data,
});
- model.load(params).then(function (resultID) {
+ await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.product_ids.data[0].data.date, false,
"date value should be in data, and should be false");
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 795d3b54324..ec9bc62d6fc 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -2,6 +2,7 @@ odoo.define('web.kanban_tests', function (require) {
"use strict";
var AbstractField = require('web.AbstractField');
+const Domain = require('web.Domain');
var fieldRegistry = require('web.field_registry');
var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
@@ -4045,6 +4046,529 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('empty grouped kanban with sample data and no columns', async function (assert) {
+ assert.expect(3);
+
+ this.data.partner.records = [];
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsNone(kanban, '.o_view_nocontent');
+ assert.containsOnce(kanban, '.o_quick_create_unfolded');
+ assert.containsOnce(kanban, '.o_kanban_example_background_container');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and click quick create', async function (assert) {
+ assert.expect(11);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "there should be two columns");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+ assert.containsN(kanban, '.o_kanban_record', 16,
+ "there should be 8 sample records by column");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record');
+ assert.containsNone(kanban, '.o_view_nocontent');
+ assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
+
+ await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add'));
+
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_record');
+ assert.containsNone(kanban, '.o_view_nocontent');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and cancel quick create', async function (assert) {
+ assert.expect(12);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2,
+ "there should be two columns");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+ assert.containsN(kanban, '.o_kanban_record', 16,
+ "there should be 8 sample records by column");
+
+ await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record');
+ assert.containsNone(kanban, '.o_view_nocontent');
+ assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
+
+ await testUtils.dom.click(kanban.$('.o_kanban_view'));
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_quick_create');
+ assert.containsNone(kanban, '.o_kanban_record');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty kanban with sample data', async function (assert) {
+ assert.expect(6);
+
+ this.data.partner.records = [];
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 10,
+ "there should be 10 sample records");
+ assert.containsOnce(kanban, '.o_view_nocontent');
+
+ await kanban.reload({ domain: [['id', '<', 0]]});
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+ assert.containsOnce(kanban, '.o_view_nocontent');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data and many2many_tags', async function (assert) {
+ assert.expect(6);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="int_field"/>
+ <field name="category_ids" widget="many2many_tags"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ assert.step(method || route);
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ });
+
+ assert.containsN(kanban, '.o_kanban_group', 2, "there should be 2 'real' columns");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.ok(kanban.$('.o_kanban_record').length >= 1, "there should be sample records");
+ assert.ok(kanban.$('.o_field_many2manytags .o_tag').length >= 1, "there should be tags");
+
+ assert.verifySteps(["web_read_group"], "should not read the tags");
+ kanban.destroy();
+ });
+
+ QUnit.test('sample data does not change after reload with sample data', async function (assert) {
+ assert.expect(4);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="int_field"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ groupBy: ['product_id'],
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return empty groups, as this is
+ // the case for several models (e.g. project.task grouped
+ // by stage_id)
+ result.groups.forEach(group => {
+ group[`${kwargs.groupby[0]}_count`] = 0;
+ });
+ }
+ return result;
+ },
+ });
+
+ const columns = kanban.el.querySelectorAll('.o_kanban_group');
+
+ assert.ok(columns.length >= 1, "there should be at least 1 sample column");
+ assert.hasClass(kanban.$el, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_record', 16);
+
+ const kanbanText = kanban.el.innerText;
+ await kanban.reload();
+
+ assert.strictEqual(kanbanText, kanban.el.innerText,
+ "the content should be the same after reloading the view");
+
+ kanban.destroy();
+ });
+
+ QUnit.test('non empty kanban with sample data', async function (assert) {
+ assert.expect(5);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <t t-name="kanban-box">
+ <div><field name="foo"/></div>
+ </t>
+ </templates>
+ </kanban>`,
+ viewOptions: {
+ action: {
+ help: "No content helper",
+ },
+ },
+ });
+
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsNone(kanban, '.o_view_nocontent');
+
+ await kanban.reload({ domain: [['id', '<', 0]]});
+ assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
+ assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: add a column', async function (assert) {
+ assert.expect(6);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ result.groups = this.data.product.records.map(r => {
+ return {
+ product_id: [r.id, r.display_name],
+ product_id_count: 0,
+ __domain: ['product_id', '=', r.id],
+ };
+ });
+ result.length = result.groups.length;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
+ await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
+ await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 3);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ kanban.destroy();
+ });
+
+ QUnit.skip('empty grouped kanban with sample data: fold/unfold a column', async function (assert) {
+ // folding/unfolding of grouped kanban with sample data is currently disabled
+ assert.expect(8);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { kwargs, method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return a single, empty group
+ result.groups = result.groups.slice(0, 1);
+ result.groups[0][`${kwargs.groupby[0]}_count`] = 0;
+ result.length = 1;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // Fold the column
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
+ await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_kanban_toggle_fold'));
+
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.hasClass(kanban.$('.o_kanban_group'), 'o_column_folded');
+
+ // Unfold the column
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group.o_column_folded'));
+
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.doesNotHaveClass(kanban.$('.o_kanban_group'), 'o_column_folded');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: delete a column', async function (assert) {
+ assert.expect(5);
+
+ this.data.partner.records = [];
+
+ let groups = [{
+ product_id: [1, 'New'],
+ product_id_count: 0,
+ __domain: [],
+ }];
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { method }) {
+ let result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ // override read_group to return a single, empty group
+ return {
+ groups,
+ length: groups.length,
+ };
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsOnce(kanban, '.o_kanban_group');
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // Delete the first column
+ groups = [];
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
+ await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_column_delete'));
+ await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
+
+ assert.containsNone(kanban, '.o_kanban_group');
+ assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('empty grouped kanban with sample data: add a column and delete it right away', async function (assert) {
+ assert.expect(9);
+
+ const kanban = await createView({
+ arch: `
+ <kanban sample="1">
+ <field name="product_id"/>
+ <templates>
+ <div t-name="kanban-box">
+ <field name="foo"/>
+ </div>
+ </templates>
+ </kanban>`,
+ data: this.data,
+ groupBy: ['product_id'],
+ model: 'partner',
+ View: KanbanView,
+ async mockRPC(route, { method }) {
+ const result = await this._super(...arguments);
+ if (method === 'web_read_group') {
+ result.groups = this.data.product.records.map(r => {
+ return {
+ product_id: [r.id, r.display_name],
+ product_id_count: 0,
+ __domain: ['product_id', '=', r.id],
+ };
+ });
+ result.length = result.groups.length;
+ }
+ return result;
+ },
+ });
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // add a new column
+ await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
+ await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
+ await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 3);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ // delete the column we just created
+ const newColumn = kanban.el.querySelectorAll('.o_kanban_group')[2];
+ await testUtils.dom.click(newColumn.querySelector('.o_kanban_config > a'));
+ await testUtils.dom.click(newColumn.querySelector('.dropdown-item.o_column_delete'));
+ await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
+
+ assert.hasClass(kanban, 'o_view_sample_data');
+ assert.containsN(kanban, '.o_kanban_group', 2);
+ assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
+
+ kanban.destroy();
+ });
+
QUnit.test('bounce create button when no data and click on empty area', async function (assert) {
assert.expect(2);
@@ -4535,7 +5059,6 @@ QUnit.module('Views', {
await testUtils.dom.click(kanban.$('.o_column_quick_create'));
kanban.$('.o_column_quick_create input').val('new column');
await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
-
assert.isVisible(kanban.$buttons.find('.o-kanban-button-new'),
"Create button should now be visible");
kanban.destroy();
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 67c1a7962a0..3d49b2079cc 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -5,6 +5,7 @@ var AbstractFieldOwl = require('web.AbstractFieldOwl');
var AbstractStorageService = require('web.AbstractStorageService');
var BasicModel = require('web.BasicModel');
var core = require('web.core');
+const Domain = require('web.Domain')
var basicFields = require('web.basic_fields');
var fieldRegistry = require('web.field_registry');
var fieldRegistryOwl = require('web.field_registry_owl');
@@ -3764,6 +3765,284 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test("empty list with sample data", async function (assert) {
+ assert.expect(19);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ <field name="m2o"/>
+ <field name="m2m" widget="many2many_tags"/>
+ <field name="date"/>
+ <field name="datetime"/>
+ </tree>`,
+ domain: [['id', '<', 0]], // such that no record matches the domain
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ }
+ },
+ });
+
+ assert.hasClass(list.$el, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help .hello');
+
+ // Check list sample data
+ const firstRow = list.el.querySelector('.o_data_row');
+ const cells = firstRow.querySelectorAll(':scope > .o_data_cell');
+ assert.strictEqual(cells[0].innerText.trim(), "",
+ "Char field should yield an empty element"
+ );
+ assert.containsOnce(cells[1], '.custom-checkbox',
+ "Boolean field has been instantiated"
+ );
+ assert.notOk(isNaN(cells[2].innerText.trim()), "Intger value is a number");
+ assert.ok(cells[3].innerText.trim(), "Many2one field is a string");
+
+ const firstM2MTag = cells[4].querySelector(
+ ':scope span.o_badge_text'
+ ).innerText.trim();
+ assert.ok(firstM2MTag.length > 0, "Many2many contains at least one string tag");
+
+ assert.ok(/\d{2}\/\d{2}\/\d{4}/.test(cells[5].innerText.trim()),
+ "Date field should have the right format"
+ );
+ assert.ok(/\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}/.test(cells[6].innerText.trim()),
+ "Datetime field should have the right format"
+ );
+
+ const textContent = list.$el.text();
+ await list.reload();
+ assert.strictEqual(textContent, list.$el.text(),
+ 'The content should be the same after reloading the view without change'
+ );
+
+ // reload with another domain -> should no longer display the sample records
+ await list.reload({ domain: Domain.FALSE_DOMAIN });
+
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help .hello');
+
+ // reload with another domain matching records
+ await list.reload({ domain: Domain.TRUE_DOMAIN });
+
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_nocontent_help .hello');
+
+ list.destroy();
+ });
+
+ QUnit.test("non empty list with sample data", async function (assert) {
+ assert.expect(6);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ domain: Domain.TRUE_DOMAIN,
+ });
+
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+
+ // reload with another domain matching no record (should not display the sample records)
+ await list.reload({ domain: Domain.FALSE_DOMAIN });
+
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsNone(list, '.o_data_row');
+ assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
+
+ list.destroy();
+ });
+
+ QUnit.test('click on header in empty list with sample data', async function (assert) {
+ assert.expect(4);
+
+ const list = await createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: `
+ <tree sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ domain: Domain.FALSE_DOMAIN,
+ });
+
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+
+ const content = list.$el.text();
+ await testUtils.dom.click(list.$('tr:first .o_column_sortable:first'));
+ assert.strictEqual(list.$el.text(), content, "the content should still be the same");
+
+ list.destroy();
+ });
+
+ QUnit.test("non empty editable list with sample data: delete all records", async function (assert) {
+ assert.expect(7);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.TRUE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ hasActionMenus: true,
+ },
+ });
+
+ // Initial state: all records displayed
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 4);
+ assert.containsNone(list, '.o_nocontent_help');
+
+ // Delete all records
+ await testUtils.dom.click(list.el.querySelector('thead .o_list_record_selector input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ // Final state: no more sample data, but nocontent helper displayed
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty editable list with sample data: start create record and cancel", async function (assert) {
+ assert.expect(10);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ },
+ });
+
+ // Initial state: sample data and nocontent helper displayed
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ // Start creating a record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_data_row');
+
+ // Discard temporary record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_discard'));
+
+ // Final state: table should be displayed with no data at all
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsNone(list, '.o_data_row');
+ assert.containsNone(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
+ QUnit.test("empty editable list with sample data: create and delete record", async function (assert) {
+ assert.expect(13);
+
+ const list = await createView({
+ arch: `
+ <tree editable="top" sample="1">
+ <field name="foo"/>
+ <field name="bar"/>
+ <field name="int_field"/>
+ </tree>`,
+ data: this.data,
+ domain: Domain.FALSE_DOMAIN,
+ model: 'foo',
+ View: ListView,
+ viewOptions: {
+ action: {
+ help: '<p class="hello">click to add a partner</p>'
+ },
+ hasActionMenus: true,
+ },
+ });
+
+ // Initial state: sample data and nocontent helper displayed
+ assert.hasClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsN(list, '.o_data_row', 10);
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ // Start creating a record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_data_row');
+
+ // Save temporary record
+ await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_save'));
+
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsOnce(list, '.o_list_table');
+ assert.containsOnce(list, '.o_data_row');
+ assert.containsNone(list, '.o_nocontent_help');
+
+ // Delete newly created record
+ await testUtils.dom.click(list.el.querySelector('.o_data_row input'));
+ await cpHelpers.toggleActionMenu(list);
+ await cpHelpers.toggleMenuItem(list, "Delete");
+ await testUtils.dom.click($('.modal-footer .btn-primary'));
+
+ // Final state: there should be no table, but the no content helper
+ assert.doesNotHaveClass(list, 'o_view_sample_data');
+ assert.containsNone(list, '.o_list_table');
+ assert.containsOnce(list, '.o_nocontent_help');
+
+ list.destroy();
+ });
+
QUnit.test('groupby node with a button', async function (assert) {
assert.expect(14);
diff --git a/addons/web/static/tests/views/sample_server_tests.js b/addons/web/static/tests/views/sample_server_tests.js
new file mode 100644
index 00000000000..5582585a0a1
--- /dev/null
+++ b/addons/web/static/tests/views/sample_server_tests.js
@@ -0,0 +1,461 @@
+odoo.define('web.sample_server_tests', function (require) {
+ "use strict";
+
+ const SampleServer = require('web.SampleServer');
+ const session = require('web.session');
+ const { mock } = require('web.test_utils');
+
+ const {
+ MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits
+ SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values
+ MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY // Number values
+ } = SampleServer;
+
+ QUnit.module("Sample Server", {
+ beforeEach() {
+ this.fields = {
+ 'res.users': {
+ display_name: { string: "Name", type: 'char' },
+ name: { string: "Reference", type: 'char' },
+ email: { string: "Email", type: 'char' },
+ phone_number: { string: "Phone number", type: 'char' },
+ brol_machin_url_truc: { string: "URL", type: 'char' },
+ urlemailphone: { string: "Whatever", type: 'char' },
+ active: { string: "Active", type: 'boolean' },
+ is_alive: { string: "Is alive", type: 'boolean' },
+ description: { string: "Description", type: 'text' },
+ birthday: { string: "Birthday", type: 'date' },
+ arrival_date: { string: "Date of arrival", type: 'datetime' },
+ height: { string: "Height", type: 'float' },
+ color: { string: "Color", type: 'integer' },
+ age: { string: "Age", type: 'integer' },
+ salary: { string: "Salary", type: 'monetary' },
+ currency: { string: "Currency", type: 'many2one', relation: 'res.currency' },
+ manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' },
+ managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' },
+ tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' },
+ type: { string: "Type", type: 'selection', selection: [
+ ['client', "Client"], ['partner', "Partner"], ['employee', "Employee"]
+ ] },
+ },
+ 'res.country': {
+ display_name: { string: "Name", type: 'char' },
+ },
+ 'hobbit': {
+ display_name: { string: "Name", type: 'char' },
+ profession: { string: "Profession", type: 'selection', selection: [
+ ['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"]
+ ] },
+ age: { string: "Age", type: 'integer' },
+ },
+ };
+ },
+ }, function () {
+
+ QUnit.module("Basic behaviour");
+
+ QUnit.test("Sample data: people type + all field names", async function (assert) {
+ assert.expect(24);
+
+ mock.patch(session, {
+ company_currency_id: 4,
+ });
+
+ const allFieldNames = Object.keys(this.fields['res.users']);
+ const server = new SampleServer('res.users', this.fields['res.users']);
+ const { records } = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'res.users',
+ fields: allFieldNames,
+ });
+ const rec = records[0];
+
+ function assertFormat(fieldName, regex) {
+ if (regex instanceof RegExp) {
+ assert.ok(
+ regex.test(rec[fieldName].toString()),
+ `Field "${fieldName}" has the correct format`
+ );
+ } else {
+ assert.strictEqual(
+ typeof rec[fieldName], regex,
+ `Field "${fieldName}" is of type ${regex}`
+ );
+ }
+ }
+ function assertBetween(fieldName, min, max, decimal = 1) {
+ assert.ok(
+ min <= rec[fieldName] && rec[fieldName] < max &&
+ rec[fieldName].toString().split(".").length === decimal,
+ `Field "${fieldName}" is between ${min} and ${max} and is ${
+ decimal === 1 ? "an integer" : "a float number"
+ }`
+ );
+ }
+
+ // Basic fields
+ assert.ok(SAMPLE_PEOPLE.includes(rec.display_name));
+ assert.ok(SAMPLE_PEOPLE.includes(rec.name));
+ assert.strictEqual(rec.email,
+ `${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo`
+ );
+ assertFormat('phone_number', /\+1 555 754 000\d/);
+ assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/);
+ assert.strictEqual(rec.urlemailphone, false);
+ assert.strictEqual(rec.active, true);
+ assertFormat('is_alive', 'boolean');
+ assert.ok(SAMPLE_TEXTS.includes(rec.description));
+ assertFormat('birthday', /\d{4}-\d{2}-\d{2}/);
+ assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
+ assertBetween('height', 0, MAX_FLOAT, 2);
+ assertBetween('color', 0, MAX_COLOR_INT);
+ assertBetween('age', 0, MAX_INTEGER);
+ assertBetween('salary', 0, MAX_MONETARY);
+
+ const selectionValues = this.fields['res.users'].type.selection.map(
+ (sel) => sel[0]
+ );
+ assert.ok(selectionValues.includes(rec.type));
+
+ // Relational fields
+ assert.strictEqual(rec.currency[0], 4);
+ // Currently we expect the currency name to be a latin string, which
+ // is not important; in most case we only need the ID. The following
+ // assertion can be removed if needed.
+ assert.ok(SAMPLE_TEXTS.includes(rec.currency[1]));
+
+ assert.strictEqual(typeof rec.manager_id[0], 'number');
+ assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1]));
+
+ assert.ok([1, 2].includes(rec.managed_ids.length));
+ assert.ok(rec.managed_ids.every(
+ (id) => typeof id === 'number')
+ );
+
+ assert.ok([1, 2].includes(rec.tag_ids.length));
+ assert.ok(rec.tag_ids.every(
+ (id) => typeof id === 'number')
+ );
+
+ mock.unpatch(session);
+ });
+
+ QUnit.test("Sample data: country type", async function (assert) {
+ assert.expect(1);
+
+ const server = new SampleServer('res.country', this.fields['res.country']);
+ const { records } = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'res.country',
+ fields: ['display_name'],
+ });
+
+ assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name));
+ });
+
+ QUnit.test("Sample data: any type", async function (assert) {
+ assert.expect(1);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const { records } = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'hobbit',
+ fields: ['display_name'],
+ });
+
+ assert.ok(SAMPLE_TEXTS.includes(records[0].display_name));
+ });
+
+ QUnit.module("RPC calls");
+
+ QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) {
+ assert.expect(3);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'hobbit',
+ fields: ['display_name'],
+ });
+
+ assert.deepEqual(
+ Object.keys(result.records[0]),
+ ['id', 'display_name']
+ );
+ assert.strictEqual(result.length, SEARCH_READ_LIMIT);
+ assert.ok(/\w+/.test(result.records[0].display_name),
+ "Display name has been mocked"
+ );
+ });
+
+ QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) {
+ assert.expect(3);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: '/web/dataset/search_read',
+ model: 'hobbit',
+ fields: ['name'],
+ });
+
+ assert.deepEqual(
+ Object.keys(result.records[0]),
+ ['id', 'name']
+ );
+ assert.strictEqual(result.length, SEARCH_READ_LIMIT);
+ assert.strictEqual(result.records[0].name, false,
+ `Field "name" doesn't exist => returns false`
+ );
+ });
+
+ QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) {
+ assert.expect(1);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+ server.setExistingGroups([]);
+
+ const result = await server.mockRpc({
+ method: 'web_read_group',
+ model: 'hobbit',
+ groupBy: ['profession'],
+ });
+
+ assert.deepEqual(result, { groups: [], length: 0 });
+ });
+
+ QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) {
+ assert.expect(6);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+ const existingGroups = [
+ { profession: 'gardener', profession_count: 0 },
+ { profession: 'adventurer', profession_count: 0 },
+ ];
+ server.setExistingGroups(existingGroups);
+
+ const result = await server.mockRpc({
+ method: 'web_read_group',
+ model: 'hobbit',
+ groupBy: ['profession'],
+ fields: [],
+ });
+
+ assert.strictEqual(result.length, 2);
+ assert.strictEqual(result.groups.length, 2);
+
+ const professions = result.groups.map((g) => g.profession);
+ assert.ok(professions.includes("gardener"));
+ assert.ok(professions.includes("adventurer"));
+
+ assert.strictEqual(
+ result.groups.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE
+ );
+ assert.ok(
+ result.groups.every((g) => g.profession_count === g.__data.length)
+ );
+ });
+
+ QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) {
+ assert.expect(7);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+ const existingGroups = [
+ { profession: 'gardener', profession_count: 0 },
+ { profession: 'brewer', profession_count: 0 },
+ { profession: 'adventurer', profession_count: 0 },
+ ];
+ server.setExistingGroups(existingGroups);
+
+ const result = await server.mockRpc({
+ method: 'web_read_group',
+ model: 'hobbit',
+ groupBy: ['profession'],
+ fields: [],
+ });
+
+ assert.strictEqual(result.length, 3);
+ assert.strictEqual(result.groups.length, 3);
+
+ const professions = result.groups.map((g) => g.profession);
+ assert.ok(professions.includes("gardener"));
+ assert.ok(professions.includes("brewer"));
+ assert.ok(professions.includes("adventurer"));
+
+ assert.strictEqual(
+ result.groups.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE
+ );
+ assert.ok(
+ result.groups.every((g) => g.profession_count === g.__data.length)
+ );
+ });
+
+ QUnit.test("Send 'read_group' RPC: no group", async function (assert) {
+ assert.expect(1);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: [],
+ });
+
+ assert.deepEqual(result, [{
+ __count: MAIN_RECORDSET_SIZE,
+ __domain: [],
+ }]);
+ });
+
+ QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) {
+ assert.expect(5);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: ['profession'],
+ });
+
+ assert.strictEqual(result.length, 3);
+
+ const professions = result.map((g) => g.profession);
+ assert.ok(professions.includes("gardener"));
+ assert.ok(professions.includes("brewer"));
+ assert.ok(professions.includes("adventurer"));
+
+ assert.strictEqual(
+ result.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE,
+ );
+ });
+
+ QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) {
+ assert.expect(6);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: ['age'],
+ groupBy: ['profession'],
+ });
+
+ assert.strictEqual(result.length, 3);
+
+ const professions = result.map((g) => g.profession);
+ assert.ok(professions.includes("gardener"));
+ assert.ok(professions.includes("brewer"));
+ assert.ok(professions.includes("adventurer"));
+
+ assert.strictEqual(
+ result.reduce((acc, g) => acc + g.profession_count, 0),
+ MAIN_RECORDSET_SIZE,
+ );
+
+ assert.strictEqual(
+ result.reduce((acc, g) => acc + g.age, 0),
+ server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0)
+ );
+ });
+
+ QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) {
+ assert.expect(2);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: ['profession', 'age'],
+ });
+
+ assert.ok('profession' in result[0]);
+ assert.notOk('age' in result[0]);
+ });
+
+ QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) {
+ assert.expect(2);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read_group',
+ model: 'hobbit',
+ fields: [],
+ groupBy: ['profession', 'age'],
+ lazy: false,
+ });
+
+ assert.ok('profession' in result[0]);
+ assert.ok('age' in result[0]);
+ });
+
+ QUnit.test("Send 'read' RPC: no id", async function (assert) {
+ assert.expect(1);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read',
+ model: 'hobbit',
+ args: [
+ [], ['display_name']
+ ],
+ });
+
+ assert.deepEqual(result, []);
+ });
+
+ QUnit.test("Send 'read' RPC: one id", async function (assert) {
+ assert.expect(3);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const result = await server.mockRpc({
+ method: 'read',
+ model: 'hobbit',
+ args: [
+ [1], ['display_name']
+ ],
+ });
+
+ assert.strictEqual(result.length, 1);
+ assert.ok(
+ /\w+/.test(result[0].display_name),
+ "Display name has been mocked"
+ );
+ assert.strictEqual(result[0].id, 1);
+ });
+
+ QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) {
+ assert.expect(1);
+
+ const server = new SampleServer('hobbit', this.fields.hobbit);
+
+ const amount = MAIN_RECORDSET_SIZE + 3;
+ const ids = new Array(amount).fill().map((_, i) => i + 1);
+ const result = await server.mockRpc({
+ method: 'read',
+ model: 'hobbit',
+ args: [
+ ids, ['display_name']
+ ],
+ });
+
+ assert.strictEqual(result.length, MAIN_RECORDSET_SIZE);
+ });
+
+ // To be implemented if needed
+ // QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... });
+ });
+});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 3cb081e7e37..caa6f06a2c5 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -337,6 +337,7 @@
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_bar.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_card.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/sample_server.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/select_create_controllers_registry.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/signature_dialog.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script>
@@ -701,6 +702,7 @@
<script type="text/javascript" src="/web/static/tests/fields/special_fields_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/upgrade_fields_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/views/sample_server_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_controller_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/form_tests.js"></script>
@@ -710,6 +712,7 @@
<script type="text/javascript" src="/web/static/tests/views/kanban_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/calendar_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/qweb_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/views/abstract_model_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/basic_model_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script>
diff --git a/doc/reference/views.rst b/doc/reference/views.rst
index 79bd92b5a73..97d96fb7062 100644
--- a/doc/reference/views.rst
+++ b/doc/reference/views.rst
@@ -121,6 +121,18 @@ an impact on all view types.
``info``, ``warning``, ``danger`` and ``secondary`` displays. The list view supports ``bf``,
``it``, ``success``, ``info``, ``warning``, ``danger``, ``muted`` and ``primary`` displays.
+* ``sample`` (``kanban`` & ``list`` & ``gantt``)
+
+ Populate the view with a set of sample records if none are found for the current model.
+ This attribute is false by default.
+
+ These fake records will have heuristics for certain field names/models. For example,
+ a field 'display_name' on the model 'res.users' will be populated with sample people names
+ while an 'email' field will be in the form 'firstname.lastname@sample.demo'.
+
+ The user will not be able to interact with these data and they will be discarded as soon as
+ an action is performed (record created, column added, etc.)
+
* ``banner_route``
a route address to be fetched and prepended to the view.