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