From c7964657ecbbad278cec73cf231d7eef55b44347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Hennecart?= Date: Fri, 19 Jun 2020 07:06:31 +0000 Subject: [PATCH] [IMP] web: add support of "sample" attribute in views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With this commit, it is now possible to display sample (fake) data in empty views, in the hope of easing user onboarding. This can be enabled (in list and kanban views) with attribute sample="1" on the arch root element. In this case, if there is no data to display, sample data will be generated based on heuristics (depending on field types and names). This can be used in addition to the no content helper. Task 2232801 X-original-commit: 7c8e627ff5029cb539d702705e5ce53d658c76ed Co-authored-by: Aaron Bohy Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Jérémy Hennecart Co-authored-by: Julien Mougenot --- addons/web/static/src/js/core/mvc.js | 17 +- .../src/js/views/abstract_controller.js | 27 +- .../web/static/src/js/views/abstract_model.js | 226 ++++++- .../web/static/src/js/views/abstract_view.js | 10 + .../src/js/views/basic/basic_controller.js | 1 - .../static/src/js/views/basic/basic_model.js | 82 ++- .../src/js/views/basic/basic_renderer.js | 2 +- .../static/src/js/views/basic/basic_view.js | 3 +- .../src/js/views/calendar/calendar_model.js | 6 +- .../static/src/js/views/graph/graph_model.js | 6 +- .../src/js/views/kanban/kanban_column.js | 10 +- .../src/js/views/kanban/kanban_controller.js | 39 +- .../src/js/views/kanban/kanban_model.js | 61 +- .../src/js/views/kanban/kanban_renderer.js | 14 +- .../src/js/views/list/list_controller.js | 30 +- .../static/src/js/views/list/list_model.js | 2 +- .../static/src/js/views/list/list_renderer.js | 129 ++-- .../src/js/views/pivot/pivot_controller.js | 3 +- .../static/src/js/views/pivot/pivot_model.js | 6 +- .../static/src/js/views/pivot/pivot_view.js | 8 +- .../web/static/src/js/views/qweb/qweb_view.js | 6 +- .../web/static/src/js/views/sample_server.js | 609 ++++++++++++++++++ addons/web/static/src/scss/list_view.scss | 1 + addons/web/static/src/scss/views.scss | 37 +- .../static/tests/helpers/test_utils_create.js | 2 +- .../tests/views/abstract_model_tests.js | 130 ++++ .../static/tests/views/basic_model_tests.js | 14 +- addons/web/static/tests/views/kanban_tests.js | 525 ++++++++++++++- addons/web/static/tests/views/list_tests.js | 279 ++++++++ .../static/tests/views/sample_server_tests.js | 461 +++++++++++++ addons/web/views/webclient_templates.xml | 3 + doc/reference/views.rst | 12 + 32 files changed, 2592 insertions(+), 169 deletions(-) create mode 100644 addons/web/static/src/js/views/sample_server.js create mode 100644 addons/web/static/tests/views/abstract_model_tests.js create mode 100644 addons/web/static/tests/views/sample_server_tests.js 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(); @@ -339,6 +339,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { state: this.getState(), }); }, + /** + * @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). @@ -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 {Object} params + * @param {any} _ + * @param {Object} [params] * @returns {Promise} */ - reload: function (params) { + 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). + * + * @private + * @returns {Promise} + */ + 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, @@ -750,6 +758,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 @@ -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} 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} 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)); }, /** @@ -2195,6 +2228,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, @@ -4847,6 +4894,23 @@ var BasicModel = AbstractModel.extend({ return result.id; }); }, + /** + * 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 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', @@ -90,6 +91,20 @@ var KanbanController = BasicController.extend({ // Private //-------------------------------------------------------------------------- + /** + * 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 @@ -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)); } @@ -266,6 +282,15 @@ var KanbanController = BasicController.extend({ }); }).guardedCatch(this.reload.bind(this)); }, + /** + * @private + * @param {OdooEvent} ev + * @returns {string} ev.data.groupId + */ + _onAddQuickCreate(ev) { + ev.stopPropagation(); + this._addQuickCreate(ev.data.groupId); + }, /** * @private * @param {OdooEvent} ev @@ -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.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()); - var orderedBy = this.state.orderedBy; - this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField; - this._computeAggregates(); - - var $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()); - - } 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()); - - // Append the table to the main element - self.el.innerHTML = ""; - dom.append(self.el, tableWrapper, { - callbacks: [{ widget: this }], - in_DOM: document.body.contains(self.el), - }); - - if (self.optionalColumns.length) { - self.$el.addClass('o_list_optional_columns'); - self.$('table').append($('')); - self.$el.append(self._renderOptionalColumnsDropdown()); + } 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); + } - if (self.selection.length) { - var $checked_rows = self.$('tr').filter(function (index, el) { - return _.contains(self.selection, $(el).data('id')); - }); + await Promise.all([this._super.apply(this, arguments), prom]); + + this.el.innerHTML = ""; + this.el.classList.remove('o_list_optional_columns'); + + // destroy the previously instantiated pagers, if any + oldPagers.forEach(pager => pager.destroy()); + + // append the table (if any) to the main element + if (tableWrapper) { + dom.append(this.el, tableWrapper, { + callbacks: [{ widget: this }], + in_DOM: document.body.contains(this.el), + }); + if (this.optionalColumns.length) { + this.el.classList.add('o_list_optional_columns'); + this.$('table').append( + $('') + ); + this.$el.append(this._renderOptionalColumnsDropdown()); + } + 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; - this.rendererParams.widgets = widgets; - this.rendererParams.disableLinking = this.arch.attrs.disable_linking; + const disableLinking = !!(this.arch.attrs.disable_linking && + JSON.stringify(this.arch.attrs.disable_linking)); + this.rendererParams.widgets = widgets; + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+ + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + + +
+
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + +
+ +
+
+
`, + 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: ` + + + + + + + + + `, + domain: [['id', '<', 0]], // such that no record matches the domain + viewOptions: { + action: { + help: '

click to add a partner

' + } + }, + }); + + 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: ` + + + + + `, + 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: ` + + + + + `, + 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: ` + + + + + `, + data: this.data, + domain: Domain.TRUE_DOMAIN, + model: 'foo', + View: ListView, + viewOptions: { + action: { + help: '

click to add a partner

' + }, + 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: ` + + + + + `, + data: this.data, + domain: Domain.FALSE_DOMAIN, + model: 'foo', + View: ListView, + viewOptions: { + action: { + help: '

click to add a partner

' + }, + }, + }); + + // 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: ` + + + + + `, + data: this.data, + domain: Domain.FALSE_DOMAIN, + model: 'foo', + View: ListView, + viewOptions: { + action: { + help: '

click to add a partner

' + }, + 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 @@ + @@ -701,6 +702,7 @@ + @@ -710,6 +712,7 @@ + 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.