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.