From a6cd6d5a892f5a0501c0e81fe583bd64befff5d0 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Fri, 19 Jun 2020 07:06:28 +0000 Subject: [PATCH 1/7] [IMP] web: utils: add groupBy function This commit introduces a new utility function: 'groupBy'. It is similar in API and result as the underscore method of the same name. X-original-commit: 834a1967161dc537cb2d5543314a062dbac08f9c --- addons/web/static/src/js/core/utils.js | 64 +++++++++++++++++----- addons/web/static/tests/core/util_tests.js | 53 ++++++++++++++++++ 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 560f3a94466..17ce0e080b2 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -205,6 +205,28 @@ var diacriticsMap = { const patchMap = new WeakMap(); +/** + * Helper function returning an extraction handler to use on array elements to + * return a certain attribute or mutated form of the element. + * + * @private + * @param {string | function} criterion + * @returns {(element: any) => any} + */ +function _getExtractorFrom(criterion) { + if (criterion) { + switch (typeof criterion) { + case 'string': return element => element[criterion]; + case 'function': return criterion; + default: throw new Error( + `Expected criterion of type 'string' or 'function' and got '${typeof criterion}'` + ); + } + } else { + return element => element; + } +} + var utils = { /** @@ -303,6 +325,31 @@ var utils = { reader.readAsDataURL(file); }); }, + /** + * Returns an object holding different groups defined by a given criterion + * or a default one. Each group is a subset of the original given list. + * The given criterion can either be: + * - a string: a property name on the list elements which value will be the + * group name, + * - a function: a handler that will return the group name from a given + * element. + * + * @param {any[]} list + * @param {string | function} [criterion] + * @returns {Object} + */ + groupBy: function (list, criterion) { + const extract = _getExtractorFrom(criterion); + const groups = {}; + for (const element of list) { + const group = String(extract(element)); + if (!(group in groups)) { + groups[group] = []; + } + groups[group].push(element); + } + return groups; + }, /** * Returns a human readable number (e.g. 34000 -> 34k). * @@ -656,22 +703,13 @@ var utils = { * - a function: a handler that will return the sortable primitive from a given element. * * @param {any[]} array - * @param {(string|function)} [criterion] + * @param {string | function} [criterion] */ sortBy: function (array, criterion) { - let getter; - if (criterion) { - switch (typeof criterion) { - case 'string': getter = element => element[criterion]; break; - case 'function': getter = criterion; break; - default: throw new Error(`Expected criterion of type 'string' or 'function' and got '${typeof criterion}'`); - } - } else { - getter = element => element; - } + const extract = _getExtractorFrom(criterion); return array.slice().sort((elA, elB) => { - const a = getter(elA); - const b = getter(elB); + const a = extract(elA); + const b = extract(elB); if (isNaN(a) && isNaN(b)) { return a > b ? 1 : a < b ? -1 : 0; } else { diff --git a/addons/web/static/tests/core/util_tests.js b/addons/web/static/tests/core/util_tests.js index da6ac0d7737..6115833e4f2 100644 --- a/addons/web/static/tests/core/util_tests.js +++ b/addons/web/static/tests/core/util_tests.js @@ -7,6 +7,59 @@ QUnit.module('core', {}, function () { QUnit.module('utils'); + QUnit.test('groupBy', function (assert) { + assert.expect(7); + + const { groupBy } = utils; + + // Invalid + assert.throws( + () => groupBy({}), + new TypeError(`list is not iterable`) + ); + assert.throws( + () => groupBy([], true), + new Error(`Expected criterion of type 'string' or 'function' and got 'boolean'`) + ); + assert.throws( + () => groupBy([], 3), + new Error(`Expected criterion of type 'string' or 'function' and got 'number'`) + ); + assert.throws( + () => groupBy([], {}), + new Error(`Expected criterion of type 'string' or 'function' and got 'object'`) + ); + + // criterion = default + assert.deepEqual( + groupBy(["a", "b", 1, true]), + { + 1: [1], + a: ["a"], + b: ["b"], + true: [true], + } + ); + // criterion = string + assert.deepEqual( + groupBy([{ x: "a" }, { x: "a" }, { x: "b" }], "x"), + { + a: [{ x: "a" }, { x: "a" }], + b: [{ x: "b" }], + } + ); + // criterion = function + assert.deepEqual( + groupBy(["a", "b", 1, true], (x) => `el${x}`), + { + ela: ["a"], + elb: ["b"], + el1: [1], + eltrue: [true], + } + ); + }); + QUnit.test('intersperse', function (assert) { assert.expect(27); From 96fcefb6f1fe3fb5a5f197e2635e6d71d9a1d9dd Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Fri, 19 Jun 2020 07:06:29 +0000 Subject: [PATCH 2/7] [IMP] web: qunit: remove tooltips after each test Leftover tooltips may make following tests fail, which isn't what we want as a test shouldn't impact another one. X-original-commit: 17a4c72b9a86186c7690bdba5e62ccd64ab2f77a --- addons/web/static/tests/helpers/qunit_config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/addons/web/static/tests/helpers/qunit_config.js b/addons/web/static/tests/helpers/qunit_config.js index ce235ab3ecf..1cc44ea18ba 100644 --- a/addons/web/static/tests/helpers/qunit_config.js +++ b/addons/web/static/tests/helpers/qunit_config.js @@ -188,6 +188,10 @@ QUnit.on('OdooAfterTestHook', function () { } } + for (const tooltip of document.querySelectorAll('.tooltip')) { + tooltip.remove(); + } + // check for leftovers in #qunit-fixture var qunitFixture = document.getElementById('qunit-fixture'); if (qunitFixture.children.length) { From c7964657ecbbad278cec73cf231d7eef55b44347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Hennecart?= Date: Fri, 19 Jun 2020 07:06:31 +0000 Subject: [PATCH 3/7] [IMP] web: add support of "sample" attribute in views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With this commit, it is now possible to display sample (fake) data in empty views, in the hope of easing user onboarding. This can be enabled (in list and kanban views) with attribute sample="1" on the arch root element. In this case, if there is no data to display, sample data will be generated based on heuristics (depending on field types and names). This can be used in addition to the no content helper. Task 2232801 X-original-commit: 7c8e627ff5029cb539d702705e5ce53d658c76ed Co-authored-by: Aaron Bohy Co-authored-by: Mathieu Duckerts-Antoine Co-authored-by: Jérémy Hennecart Co-authored-by: Julien Mougenot --- addons/web/static/src/js/core/mvc.js | 17 +- .../src/js/views/abstract_controller.js | 27 +- .../web/static/src/js/views/abstract_model.js | 226 ++++++- .../web/static/src/js/views/abstract_view.js | 10 + .../src/js/views/basic/basic_controller.js | 1 - .../static/src/js/views/basic/basic_model.js | 82 ++- .../src/js/views/basic/basic_renderer.js | 2 +- .../static/src/js/views/basic/basic_view.js | 3 +- .../src/js/views/calendar/calendar_model.js | 6 +- .../static/src/js/views/graph/graph_model.js | 6 +- .../src/js/views/kanban/kanban_column.js | 10 +- .../src/js/views/kanban/kanban_controller.js | 39 +- .../src/js/views/kanban/kanban_model.js | 61 +- .../src/js/views/kanban/kanban_renderer.js | 14 +- .../src/js/views/list/list_controller.js | 30 +- .../static/src/js/views/list/list_model.js | 2 +- .../static/src/js/views/list/list_renderer.js | 129 ++-- .../src/js/views/pivot/pivot_controller.js | 3 +- .../static/src/js/views/pivot/pivot_model.js | 6 +- .../static/src/js/views/pivot/pivot_view.js | 8 +- .../web/static/src/js/views/qweb/qweb_view.js | 6 +- .../web/static/src/js/views/sample_server.js | 609 ++++++++++++++++++ addons/web/static/src/scss/list_view.scss | 1 + addons/web/static/src/scss/views.scss | 37 +- .../static/tests/helpers/test_utils_create.js | 2 +- .../tests/views/abstract_model_tests.js | 130 ++++ .../static/tests/views/basic_model_tests.js | 14 +- addons/web/static/tests/views/kanban_tests.js | 525 ++++++++++++++- addons/web/static/tests/views/list_tests.js | 279 ++++++++ .../static/tests/views/sample_server_tests.js | 461 +++++++++++++ addons/web/views/webclient_templates.xml | 3 + doc/reference/views.rst | 12 + 32 files changed, 2592 insertions(+), 169 deletions(-) create mode 100644 addons/web/static/src/js/views/sample_server.js create mode 100644 addons/web/static/tests/views/abstract_model_tests.js create mode 100644 addons/web/static/tests/views/sample_server_tests.js diff --git a/addons/web/static/src/js/core/mvc.js b/addons/web/static/src/js/core/mvc.js index d7091cc4843..23f2b44b274 100644 --- a/addons/web/static/src/js/core/mvc.js +++ b/addons/web/static/src/js/core/mvc.js @@ -109,9 +109,11 @@ var Controller = Widget.extend({ * @param {Model} model * @param {Renderer} renderer * @param {Object} params + * @param {any} [params.handle=null] */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); + this.handle = params.handle || null; this.model = model; this.renderer = renderer; }, @@ -178,11 +180,13 @@ var Factory = Class.extend({ var self = this; var model = this.getModel(parent); return Promise.all([this._loadData(model), ajax.loadLibs(this)]).then(function (result) { - var state = result[0]; + const { state, handle } = result[0]; var renderer = self.getRenderer(parent, state); var Controller = self.Controller || self.config.Controller; + const initialState = model.get(handle); var controllerParams = _.extend({ - initialState: state, + initialState, + handle, }, self.controllerParams); var controller = new Controller(parent, model, renderer, controllerParams); model.setParent(controller); @@ -221,13 +225,16 @@ var Factory = Class.extend({ * * @private * @param {Model} model a Model instance + * @param {Object} [options={}] + * @param {boolean} [options.withSampleData=true] * @returns {Promise<*>} a promise that resolves to the value returned by * the get method from the model * @todo: get rid of loadParams (use modelParams instead) */ - _loadData: function (model) { - return model.load(this.loadParams).then(function () { - return model.get.apply(model, arguments); + _loadData: function (model, options = {}) { + options.withSampleData = 'withSampleData' in options ? options.withSampleData : true; + return model.load(this.loadParams).then(function (handle) { + return { state: model.get(handle, options), handle }; }); }, }); diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index 32257202649..3df1d48410b 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -259,23 +259,23 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * updating the renderer and wait for the rendering to complete. * * @param {Object} params will be given to the model and to the renderer - * @param {Object} [options] + * @param {Object} [options={}] * @param {boolean} [options.reload=true] if true, the model will reload data * * @returns {Promise} */ - update: async function (params, options = {}) { + async update(params, options = {}) { const shouldReload = 'reload' in options ? options.reload : true; if (shouldReload) { this.handle = await this.dp.add(this.model.reload(this.handle, params)); } const localState = this.renderer.getLocalState(); - const state = this.model.get(this.handle); + const state = this.model.get(this.handle, { withSampleData: true }); const promises = [ this.updateRendererState(state, params).then(() => { this.renderer.setLocalState(localState); }), - this._update(state, params), + this._update(this.model.get(this.handle), params) ]; await this.dp.add(Promise.all(promises)); this.updateButtons(); @@ -339,6 +339,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { state: this.getState(), }); }, + /** + * @private + * @param {function} callback function to execute before removing classname + * 'o_view_sample_data' (may be async). This allows to reload and/or + * rerender before removing the className, thus preventing the view from + * flickering. + */ + async _removeSampleData(callback) { + this.model.leaveSampleMode(); + if (callback) { + await callback(); + } + this.el.classList.remove('o_view_sample_data'); + }, /** * Renders the html provided by the route specified by the * bannerRoute attribute on the controller (banner_route in the template). @@ -409,7 +423,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @param {Object} [params.shouldUpdateControlPanel] * @returns {Promise} */ - _update: function (state, params) { + async _update(state, params) { // AAB: update the control panel -> this will be moved elsewhere at some point if (!this.$buttons) { this.renderButtons(); @@ -423,7 +437,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { promises.push(this.updateControlPanel()); } this._pushState(); - return Promise.all(promises); + await Promise.all(promises); + this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode()); }, /** * Can be used to update the key 'cp_content'. This method is called in start and _update methods. diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js index d18367b1756..db2cce99935 100644 --- a/addons/web/static/src/js/views/abstract_model.js +++ b/addons/web/static/src/js/views/abstract_model.js @@ -12,25 +12,199 @@ odoo.define('web.AbstractModel', function (require) { * Note that the model is not a widget, it does not need to be rendered or * appended to the dom. However, it inherits from the EventDispatcherMixin, * in order to be able to notify its parent by bubbling events up. + * + * The model is able to generate sample (fake) data when there is no actual data + * in database. This feature can be activated by instantiating the model with + * param "useSampleModel" set to true. In this case, the model instantiates a + * duplicated version of itself, parametrized to call a SampleServer (JS) + * instead of doing RPCs. Here is how it works: the main model first load the + * data normally (from database), and then checks whether the result is empty or + * not. If it is, it asks the sample model to load with the exact same params, + * and it thus enters in "sample" mode. The model keeps doing this at reload, + * but only if the (re)load params haven't changed: as soon as a param changes, + * the "sample" mode is left, and it never enters it again in the future (in the + * lifetime of the model instance). To access those sample data from the outside, + * 'get' must be called with the the option "withSampleData" set to true. In + * this case, if the main model is in "sample" mode, it redirects the call to the + * sample model. */ var fieldUtils = require('web.field_utils'); var mvc = require('web.mvc'); +const SampleServer = require('web.SampleServer'); + var AbstractModel = mvc.Model.extend({ + /** + * @param {Widget} parent + * @param {Object} [params={}] + * @param {Object} [params.fields] + * @param {string} [params.modelName] + * @param {boolean} [params.isSampleModel=false] if true, will fetch data + * from a SampleServer instead of doing RPCs + * @param {boolean} [params.useSampleModel=false] if true, will use a sample + * model to generate sample data when there is no "real" data in database + * @param {AbstractModel} [params.SampleModel] the AbstractModel class + * to instantiate as sample model. This model won't do any rpc, but will + * rather call a SampleServer that will generate sample data. This param + * must be set when params.useSampleModel is true. + */ + init(parent, params = {}) { + this._super(...arguments); + this.useSampleModel = params.useSampleModel || false; + if (params.isSampleModel) { + this.isSampleModel = true; + this.sampleServer = new SampleServer(params.modelName, params.fields); + } else if (this.useSampleModel) { + const sampleModelParams = Object.assign({}, params, { + isSampleModel: true, + SampleModel: null, + useSampleModel: false, + }); + this.sampleModel = new params.SampleModel(this, sampleModelParams); + this._isInSampleMode = false; + } + }, + //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- + /** + * Override to call get on the sampleModel when we are in sample mode, and + * option 'withSampleData' is set to true. + * + * @override + * @param {any} _ + * @param {Object} [options] + * @param {boolean} [options.withSampleData=false] + */ + get(_, options) { + let state; + if (options && options.withSampleData && this._isInSampleMode) { + state = this.sampleModel.__get(...arguments); + } else { + state = this.__get(...arguments); + } + return state; + }, + /** + * Under some conditions, the model is designed to generate sample data if + * there is no real data in database. This function returns a boolean which + * indicates the mode of the model: if true, we are in "sample" mode. + * + * @returns {boolean} + */ + isInSampleMode() { + return !!this._isInSampleMode; + }, + /** + * Disables the sample data (forever) on this model instance. + */ + leaveSampleMode() { + if (this.useSampleModel) { + this.useSampleModel = false; + this._isInSampleMode = false; + this.sampleModel.destroy(); + } + }, + /** + * Override to check if we need to call the sample model (and if so, to do + * it) after loading the data, in the case where there is no real data to + * display. + * + * @override + */ + async load(params) { + this.loadParams = params; + const handle = await this.__load(...arguments); + await this._callSampleModel('__load', handle, ...arguments); + return handle; + }, /** * When something changes, the data may need to be refetched. This is the * job for this method: reloading (only if necessary) all the data and * making sure that they are ready to be redisplayed. + * Sometimes, we reload the data with the "same" params as the initial load + * params (see '_haveParamsChanged'). When we do, if we were in "sample" mode, + * we call again the sample server after the reload if there is still no data + * to display. When the parameters change, we automatically leave "sample" + * mode. * - * @param {Object} params + * @param {any} _ + * @param {Object} [params] * @returns {Promise} */ - reload: function (params) { + async reload(_, params) { + const handle = await this.__reload(...arguments); + if (this._isInSampleMode) { + if (!this._haveParamsChanged(params)) { + await this._callSampleModel('__reload', handle, ...arguments); + } else { + this.leaveSampleMode(); + } + } + return handle; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} method + * @param {any} handle + * @param {...any} args + * @returns {Promise} + */ + async _callSampleModel(method, handle, ...args) { + if (this.useSampleModel && this._isEmpty(handle)) { + try { + if (method === '__load') { + await this.sampleModel.__load(...args); + } else if (method === '__reload') { + await this.sampleModel.__reload(...args); + } + this._isInSampleMode = true; + } catch (error) { + if (error instanceof SampleServer.UnimplementedRouteError) { + this.leaveSampleMode(); + } else { + throw error; + } + } + } else { + this.leaveSampleMode(); + } + }, + /** + * @private + * @returns {Object} + */ + __get() { + return {}; + }, + /** + * This function can be overriden to determine if the result of a load or + * a reload is empty. In the affirmative, we will try to generate sample + * data to prevent from having an empty state to display. + * + * @private + * @params {any} handle, the value returned by a load or a reload + * @returns {boolean} + */ + _isEmpty(/* handle */) { + return false; + }, + /** + * To override to do the initial load of the data (this function is supposed + * to be called only once). + * + * @private + * @returns {Promise} + */ + async __load() { return Promise.resolve(); }, /** @@ -57,6 +231,54 @@ var AbstractModel = mvc.Model.extend({ } return value; }, + /** + * To override to reload data (this function may be called several times, + * after the initial load has been done). + * + * @private + * @returns {Promise} + */ + async __reload() { + return Promise.resolve(); + }, + /** + * Determines whether or not the given params (reload params) differ from + * the initial ones (this.loadParams). This is used to leave "sample" mode + * as soon as a parameter (e.g. domain) changes. + * + * @private + * @param {Object} [params={}] + * @param {Object} [params.context] + * @param {Array[]} [params.domain] + * @param {Object} [params.timeRanges] + * @param {string[]} [params.groupBy] + * @returns {boolean} + */ + _haveParamsChanged(params = {}) { + for (const key of ['context', 'domain', 'timeRanges']) { + if (key in params) { + const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]); + if (diff) { + return true; + } + } + } + if (this.useSampleModel && 'groupBy' in params) { + return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy); + } + }, + /** + * Override to redirect all rpcs to the SampleServer if this.isSampleModel + * is true. + * + * @override + */ + async _rpc() { + if (this.isSampleModel) { + return this.sampleServer.mockRpc(...arguments); + } + return this._super(...arguments); + }, }); return AbstractModel; diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 4cd503ff480..6504314ddd1 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -162,6 +162,16 @@ var AbstractView = Factory.extend({ res_id: currentId, res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined), }; + + this.modelParams = { + fields: this.fields, + modelName: params.modelName, + useSampleModel: !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample)) + }; + if (this.modelParams.useSampleModel) { + this.modelParams.SampleModel = this.config.Model; + } + var defaultOrder = this.arch.attrs.default_order; if (defaultOrder) { this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) { diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 99da98f4301..0849cf5508d 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -43,7 +43,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { this.hasButtons = params.hasButtons; FieldManagerMixin.init.call(this, this.model); this.mode = params.mode || 'readonly'; - this.handle = this.initialState.id; // savingDef is used to ensure that we always wait for pending save // operations to complete before checking if there are changes to // discard when discardChanges is called diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 9771fcc63e9..5b58dc4d7e7 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -164,6 +164,13 @@ var BasicModel = AbstractModel.extend({ this.batchedRPCsRequests = []; this.localData = Object.create(null); + // used to generate dataPoint ids. Note that the counter is set to 0 for + // each instance, and this is mandatory for the sample data feature to + // work: we need both the main model and the sample model to generate the + // same datapoint ids for their common data (groups, when there are real + // groups in database), so that we can easily do the mapping between + // real and sample data. + this.__id = 0; this._super.apply(this, arguments); }, @@ -549,7 +556,7 @@ var BasicModel = AbstractModel.extend({ this.localData[listID].orderedResIDs = list.res_ids; }, /** - * The get method first argument is the handle returned by the load method. + * The __get method first argument is the handle returned by the load method. * It is optional (the handle can be undefined). In some case, it makes * sense to use the handle as a key, for example the BasicModel holds the * data for various records, each with its local ID. @@ -563,7 +570,7 @@ var BasicModel = AbstractModel.extend({ * @param {boolean} [options.raw=false] if true, will not follow relations * @returns {Object} */ - get: function (id, options) { + __get: function (id, options) { var self = this; options = options || {}; @@ -601,7 +608,7 @@ var BasicModel = AbstractModel.extend({ relDataPoint = this.localData[data[fieldName]]; data[fieldName] = relDataPoint ? relDataPoint.res_id : false; } else { - data[fieldName] = this.get(data[fieldName]) || false; + data[fieldName] = this.__get(data[fieldName]) || false; } } else if (field.type === 'reference') { if (options.raw) { @@ -610,7 +617,7 @@ var BasicModel = AbstractModel.extend({ relDataPoint.model + ',' + relDataPoint.res_id : false; } else { - data[fieldName] = this.get(data[fieldName]) || false; + data[fieldName] = this.__get(data[fieldName]) || false; } } else if (field.type === 'one2many' || field.type === 'many2many') { if (options.raw) { @@ -625,7 +632,7 @@ var BasicModel = AbstractModel.extend({ data[fieldName] = data[fieldName] || []; } } else { - data[fieldName] = this.get(data[fieldName]) || []; + data[fieldName] = this.__get(data[fieldName]) || []; } } } @@ -687,7 +694,7 @@ var BasicModel = AbstractModel.extend({ context: _.extend({}, element.context), count: element.count, data: _.map(element.data, function (elemID) { - return self.get(elemID, options); + return self.__get(elemID, options); }), domain: element.domain.slice(0), fields: element.fields, @@ -701,6 +708,7 @@ var BasicModel = AbstractModel.extend({ id: element.id, isDirty: element.isDirty, isOpen: element.isOpen, + isSample: this.isSampleModel, limit: element.limit, model: element.model, offset: element.offset, @@ -750,6 +758,27 @@ var BasicModel = AbstractModel.extend({ }); return isDirty; }, + /** + * Returns true iff the datapoint is of type list and either: + * - is not grouped, and contains no records + * - is grouped, and contains columns, but all columns are empty + * In these cases, we will generate sample data to display, instead of an + * empty state. + * + * @override + */ + _isEmpty(dataPointID) { + const dataPoint = this.localData[dataPointID]; + if (dataPoint.type === 'list') { + const hasRecords = dataPoint.count === 0; + if (dataPoint.groupedBy.length) { + return dataPoint.data.length > 0 && hasRecords; + } else { + return hasRecords; + } + } + return false; + }, /** * Check if a localData is new, meaning if it is in the process of being * created and no actual record exists in db. Note: if the localData is not @@ -782,6 +811,7 @@ var BasicModel = AbstractModel.extend({ * * @todo document all params * + * @private * @param {any} params * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field * @param {Object} params.fields contains the description of each field @@ -789,7 +819,8 @@ var BasicModel = AbstractModel.extend({ * @param {string} [params.recordID] an ID for an existing resource. * @returns {Promise} resolves to a local id, or handle */ - load: function (params) { + __load: async function (params) { + await this._super(...arguments); params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); // FIXME: the following seems only to be used by the basic_model_tests // so it should probably be removed and the tests should be adapted @@ -954,13 +985,15 @@ var BasicModel = AbstractModel.extend({ * Reload all data for a given resource. At any time there is at most one * reload operation active. * + * @private * @param {string} id local id for a resource * @param {Object} [options] * @param {boolean} [options.keepChanges=false] if true, doesn't discard the * changes on the record before reloading it * @returns {Promise} resolves to the id of the resource */ - reload: function (id, options) { + __reload: async function (id, options) { + await this._super(...arguments); return this.mutex.exec(this._reload.bind(this, id, options)); }, /** @@ -2195,6 +2228,20 @@ var BasicModel = AbstractModel.extend({ } return hasOnchange ? specs : false; }, + /** + * Ensures that dataPoint ids are always synchronized between the main and + * sample models when being in sample mode. Here, we now that __id in the + * sample model is always greater than __id in the main model (as it + * contains strictly more datapoints). + * + * @override + */ + async _callSampleModel() { + await this._super(...arguments); + if (this._isInSampleMode) { + this.__id = this.sampleModel.__id; + } + }, /** * Compute the default value that the handle field should take. * We need to compute this in order for new lines to be added at the correct position. @@ -3968,7 +4015,7 @@ var BasicModel = AbstractModel.extend({ groupsCount: 0, groupsLimit: type === 'list' && params.groupsLimit || null, groupsOffset: 0, - id: _.uniqueId(params.modelName + '_'), + id: `${params.modelName}_${++this.__id}`, isOpen: params.isOpen, limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER), loadMoreOffset: 0, @@ -4847,6 +4894,23 @@ var BasicModel = AbstractModel.extend({ return result.id; }); }, + /** + * Override to handle the case where we want sample data, and we are in a + * grouped kanban or list view with real groups, but all groups are empty. + * In this case, we use the result of the web_read_group rpc to tweak the + * data in the SampleServer instance of the sampleModel (so that calls to + * that server will return the same groups). + * + * @override + */ + async _rpc(params) { + const result = await this._super(...arguments); + if (this.sampleModel && params.method === 'web_read_group' && result.length) { + const sampleServer = this.sampleModel.sampleServer; + sampleServer.setExistingGroups(result.groups); + } + return result; + }, /** * Allows to save a value in the specialData cache associated to a given * record and fieldName. If the value in the cache was already the given diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index fd285010054..8db6058f242 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -455,7 +455,7 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { * @returns {boolean} */ _hasContent: function () { - return this.state.count !== 0; + return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample); }, /** * Force the resequencing of the records after moving one of them to a given diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js index f1e093e0479..eb6ef5a2549 100644 --- a/addons/web/static/src/js/views/basic/basic_view.js +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -197,7 +197,8 @@ var BasicView = AbstractView.extend({ } } return Promise.resolve(def).then(function () { - return model.get(record.id); + const handle = record.id; + return { state: model.get(handle), handle }; }); } return this._super.apply(this, arguments); diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js index 403d828d824..530f4775aa1 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -173,7 +173,7 @@ return AbstractModel.extend({ * @override * @returns {Object} */ - get: function () { + __get: function () { return _.extend({}, this.data, { fields: this.fields }); @@ -183,7 +183,7 @@ return AbstractModel.extend({ * @param {any} params * @returns {Promise} */ - load: function (params) { + __load: function (params) { var self = this; this.modelName = params.modelName; this.fields = params.fields; @@ -256,7 +256,7 @@ return AbstractModel.extend({ * @param {Array} [params.domain] * @returns {Promise} */ - reload: function (handle, params) { + __reload: function (handle, params) { if (params.domain) { this.data.domain = params.domain; } diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js index ccaa008f3f7..8679fafe4ff 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -34,7 +34,7 @@ return AbstractModel.extend({ * @override * @returns {Object} */ - get: function () { + __get: function () { return _.extend({}, this.chart); }, /** @@ -57,7 +57,7 @@ return AbstractModel.extend({ * @returns {Promise} The promise does not return a handle, we don't need * to keep track of various entities. */ - load: function (params) { + __load: function (params) { var groupBys = params.context.graph_groupbys || params.groupBys; this.initialGroupBys = groupBys; this.fields = params.fields; @@ -97,7 +97,7 @@ return AbstractModel.extend({ * @param {Object} [params.timeRanges] * @returns {Promise} */ - reload: function (handle, params) { + __reload: function (handle, params) { if ('context' in params) { this.chart.context = params.context; this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy; diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js index ab994628c8c..03c34b358e0 100644 --- a/addons/web/static/src/js/views/kanban/kanban_column.js +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -191,7 +191,7 @@ var KanbanColumn = Widget.extend({ * * @returns {Promise} */ - addQuickCreate: function () { + addQuickCreate: async function () { if (this.folded) { // first open the column, and then add the quick create this.trigger_up('column_toggle_fold', { @@ -204,7 +204,6 @@ var KanbanColumn = Widget.extend({ return Promise.reject(); } this.trigger_up('close_quick_create'); // close other quick create widgets - this.trigger_up('start_quick_create'); var context = this.data.getContext(); context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy); this.quickCreateWidget = new RecordQuickCreate(this, { @@ -212,7 +211,10 @@ var KanbanColumn = Widget.extend({ formViewRef: this.quickCreateView, model: this.modelName, }); - return this.quickCreateWidget.insertAfter(this.$header); + await this.quickCreateWidget.appendTo(document.createDocumentFragment()); + this.trigger_up('start_quick_create'); + this.quickCreateWidget.$el.insertAfter(this.$header); + this.quickCreateWidget.on_attach_callback(); }, /** * Closes the quick create widget if it isn't dirty. @@ -285,7 +287,7 @@ var KanbanColumn = Widget.extend({ * @private */ _onAddQuickCreate: function () { - this.addQuickCreate(); + this.trigger_up('add_quick_create', { groupId: this.db_id }); }, /** * @private diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index 3cf075141b2..ea45c58f253 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -21,6 +21,7 @@ var qweb = core.qweb; var KanbanController = BasicController.extend({ buttons_template: 'KanbanView.buttons', custom_events: _.extend({}, BasicController.prototype.custom_events, { + add_quick_create: '_onAddQuickCreate', quick_create_add_column: '_onAddColumn', quick_create_record: '_onQuickCreateRecord', resequence_columns: '_onResequenceColumn', @@ -90,6 +91,20 @@ var KanbanController = BasicController.extend({ // Private //-------------------------------------------------------------------------- + /** + * Displays the record quick create widget in the requested column, given its + * id (in the first column by default). Ensures that we removed sample data + * if any, before displaying the quick create. + * + * @private + * @param {string} [groupId] + */ + _addQuickCreate(groupId) { + this._removeSampleData(async () => { + await this.update({ shouldUpdateControlPanel: false }, { reload: false }); + return this.renderer.addQuickCreate(groupId); + }); + }, /** * @override method comes from field manager mixin * @private @@ -205,12 +220,13 @@ var KanbanController = BasicController.extend({ */ _shouldBounceOnClick(element) { const state = this.model.get(this.handle, {raw: true}); - if (!state.count) { + if (!state.count || state.isSample) { const classesList = [ 'o_kanban_view', 'o_kanban_group', + 'o_kanban_header', 'o_column_quick_create', - 'o_view_nocontent_smiling_face' + 'o_view_nocontent_smiling_face', ]; return classesList.some(c => element.classList.contains(c)); } @@ -266,6 +282,15 @@ var KanbanController = BasicController.extend({ }); }).guardedCatch(this.reload.bind(this)); }, + /** + * @private + * @param {OdooEvent} ev + * @returns {string} ev.data.groupId + */ + _onAddQuickCreate(ev) { + ev.stopPropagation(); + this._addQuickCreate(ev.data.groupId); + }, /** * @private * @param {OdooEvent} ev @@ -309,16 +334,13 @@ var KanbanController = BasicController.extend({ * @private */ _onButtonNew: function () { - var self = this; var state = this.model.get(this.handle, {raw: true}); var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state); if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) { // activate the quick create in the first column when the mutex is // unlocked, to ensure that there is no pending re-rendering that // would remove it (e.g. if we are currently adding a new column) - this.mutex.getUnlockedDef().then(function () { - self.renderer.addQuickCreate(); - }); + this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null)); } else if (this.on_create && this.on_create !== 'quick_create') { // Execute the given action this.do_action(this.on_create, { @@ -357,15 +379,12 @@ var KanbanController = BasicController.extend({ * @param {OdooEvent} ev */ _onDeleteColumn: function (ev) { - var self = this; var column = ev.target; var state = this.model.get(this.handle, {raw: true}); var relatedModelName = state.fields[state.groupedBy[0]].relation; this.model .deleteRecords([column.db_id], relatedModelName) - .then(function () { - self.update({}, {reload: !column.isEmpty()}); - }); + .then(this.update.bind(this, {}, {})); }, /** * @private diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 3025adde2cb..7dcfe4081aa 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -10,7 +10,6 @@ var BasicModel = require('web.BasicModel'); var viewUtils = require('web.viewUtils'); var KanbanModel = BasicModel.extend({ - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -69,28 +68,36 @@ var KanbanModel = BasicModel.extend({ context: parent.context, // todo: combine with view context }) .then(function (result) { - var newGroup = self._makeDataPoint({ - modelName: parent.model, - context: parent.context, - domain: parent.domain.concat([[groupBy,"=",result[0]]]), - fields: parent.fields, - fieldsInfo: parent.fieldsInfo, - isOpen: true, - limit: parent.limit, - parentID: parent.id, - openGroupByDefault: true, - orderedBy: parent.orderedBy, - value: result, - viewType: parent.viewType, - }); - if (parent.progressBar) { - newGroup.progressBarValues = _.extend({ - counts: {}, - }, parent.progressBar); - } - - // newGroup.is_open = true; + const createGroupDataPoint = (model, parent) => { + const newGroup = model._makeDataPoint({ + modelName: parent.model, + context: parent.context, + domain: parent.domain.concat([[groupBy, "=", result[0]]]), + fields: parent.fields, + fieldsInfo: parent.fieldsInfo, + isOpen: true, + limit: parent.limit, + parentID: parent.id, + openGroupByDefault: true, + orderedBy: parent.orderedBy, + value: result, + viewType: parent.viewType, + }); + if (parent.progressBar) { + newGroup.progressBarValues = _.extend({ + counts: {}, + }, parent.progressBar); + } + return newGroup; + }; + const newGroup = createGroupDataPoint(self, parent); parent.data.push(newGroup.id); + if (self.isInSampleMode()) { + // in sample mode, create the new group in both models (main + sample) + const sampleParent = self.sampleModel.localData[parentID]; + const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent); + sampleParent.data.push(newSampleGroup.id); + } return newGroup.id; }); }, @@ -143,7 +150,7 @@ var KanbanModel = BasicModel.extend({ * @see _readTooltipFields * @returns {Object} */ - get: function () { + __get: function () { var result = this._super.apply(this, arguments); var dp = result && this.localData[result.id]; if (dp) { @@ -178,7 +185,7 @@ var KanbanModel = BasicModel.extend({ /** * @override */ - load: function (params) { + __load: function (params) { this.defaultGroupedBy = params.groupBy || []; params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy; return this._super(params); @@ -262,6 +269,12 @@ var KanbanModel = BasicModel.extend({ if (options && options.groupBy && !options.groupBy.length) { options.groupBy = this.defaultGroupedBy; } + return this._super(id, options); + }, + /** + * @override + */ + __reload: function (id, options) { var def = this._super(id, options); if (options && options.loadMoreOffset) { return def; diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js index 1b1cfc5c147..8152d899150 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -152,12 +152,20 @@ var KanbanRenderer = BasicRenderer.extend({ //-------------------------------------------------------------------------- /** - * Displays the quick create record in the first column. + * Displays the quick create record in the requested column (first one by + * default) * + * @params {string} [groupId] local id of the group in which the quick create + * must be inserted * @returns {Promise} */ - addQuickCreate: function () { - return this.widgets[0].addQuickCreate(); + addQuickCreate: function (groupId) { + let kanbanColumn; + if (groupId) { + kanbanColumn = this.widgets.find(column => column.db_id === groupId); + } + kanbanColumn = kanbanColumn || this.widgets[0]; + return kanbanColumn.addQuickCreate(); }, /** * Focuses the first kanban record diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 709deced4df..51f95e5a9ff 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -213,20 +213,22 @@ var ListController = BasicController.extend({ _addRecord: function (dataPointId) { var self = this; this._disableButtons(); - return this.renderer.unselectRow().then(function () { - return self.model.addDefaultRecord(dataPointId, { - position: self.editable, - }); - }).then(function (recordID) { - var state = self.model.get(self.handle); - self.renderer.updateState(state, {keepWidths: true}) - .then(function () { - self.renderer.editRecord(recordID); - }) - .then(() => { - self._updatePaging(state); + return this._removeSampleData(() => { + return this.renderer.unselectRow().then(function () { + return self.model.addDefaultRecord(dataPointId, { + position: self.editable, }); - }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }).then(function (recordID) { + var state = self.model.get(self.handle); + self._updateRendererState(state, { keepWidths: true }) + .then(function () { + self.renderer.editRecord(recordID); + }) + .then(() => { + self._updatePaging(state); + }); + }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }); }, /** * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard @@ -524,7 +526,7 @@ var ListController = BasicController.extend({ */ _shouldBounceOnClick() { const state = this.model.get(this.handle, {raw: true}); - return !state.count; + return !state.count || state.isSample; }, /** * Called when clicking on 'Archive' or 'Unarchive' in the sidebar. diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js index 9ba0b473a5a..5d40343ef8d 100644 --- a/addons/web/static/src/js/views/list/list_model.js +++ b/addons/web/static/src/js/views/list/list_model.js @@ -25,7 +25,7 @@ odoo.define('web.ListModel', function (require) { * @override * @see _readGroupExtraFields */ - get: function () { + __get: function () { var result = this._super.apply(this, arguments); var dp = result && this.localData[result.id]; if (dp && dp.groupData) { diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index c2c57250022..428a781319f 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -35,6 +35,7 @@ var FIELD_CLASSES = { }; var ListRenderer = BasicRenderer.extend({ + className: 'o_list_view', events: { "mousedown": "_onMouseDown", "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn", @@ -1021,80 +1022,82 @@ var ListRenderer = BasicRenderer.extend({ * this method does not wait for the field widgets to be ready. * * @override - * @private * @returns {Promise} resolved when the view has been rendered */ - _renderView: function () { - var self = this; - + async _renderView() { const oldPagers = this.pagers; - this.pagers = []; + let prom; + let tableWrapper; + if (this.state.count > 0 || !this.noContentHelp) { + // render a table if there are records, or if there is no no content + // helper (empty table in this case) + this.pagers = []; - // display the no content helper if there is no data to display - var displayNoContentHelper = !this._hasContent() && !!this.noContentHelp; - this.$el.toggleClass('o_list_view', !displayNoContentHelper); - if (displayNoContentHelper) { - // destroy the previously instantiated pagers, if any - oldPagers.forEach(pager => pager.destroy()); + const orderedBy = this.state.orderedBy; + this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField; + this._computeAggregates(); - this.$el.removeClass('table-responsive'); - this.$el.html(this._renderNoContentHelper()); - return this._super.apply(this, arguments); - } + const $table = $( + '' + ); + $table.toggleClass('o_list_table_grouped', this.isGrouped); + $table.toggleClass('o_list_table_ungrouped', !this.isGrouped); + const defs = []; + this.defs = defs; + if (this.isGrouped) { + $table.append(this._renderHeader()); + $table.append(this._renderGroups(this.state.data)); + $table.append(this._renderFooter()); - var orderedBy = this.state.orderedBy; - this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField; - this._computeAggregates(); - - var $table = $('
').addClass('o_list_table table table-sm table-hover table-striped'); - $table.toggleClass('o_list_table_grouped', this.isGrouped); - $table.toggleClass('o_list_table_ungrouped', !this.isGrouped); - var defs = []; - this.defs = defs; - if (this.isGrouped) { - $table.append(this._renderHeader()); - $table.append(this._renderGroups(this.state.data)); - $table.append(this._renderFooter()); - - } else { - $table.append(this._renderHeader()); - $table.append(this._renderBody()); - $table.append(this._renderFooter()); - } - const tableWrapper = Object.assign(document.createElement('div'), { - className: 'table-responsive', - }); - tableWrapper.appendChild($table[0]); - delete this.defs; - - var prom = Promise.all(defs).then(() => { - // destroy the previously instantiated pagers, if any - oldPagers.forEach(pager => pager.destroy()); - - // Append the table to the main element - self.el.innerHTML = ""; - dom.append(self.el, tableWrapper, { - callbacks: [{ widget: this }], - in_DOM: document.body.contains(self.el), - }); - - if (self.optionalColumns.length) { - self.$el.addClass('o_list_optional_columns'); - self.$('table').append($('')); - self.$el.append(self._renderOptionalColumnsDropdown()); + } else { + $table.append(this._renderHeader()); + $table.append(this._renderBody()); + $table.append(this._renderFooter()); } + tableWrapper = Object.assign(document.createElement('div'), { + className: 'table-responsive', + }); + tableWrapper.appendChild($table[0]); + delete this.defs; + prom = Promise.all(defs); + } - if (self.selection.length) { - var $checked_rows = self.$('tr').filter(function (index, el) { - return _.contains(self.selection, $(el).data('id')); - }); + await Promise.all([this._super.apply(this, arguments), prom]); + + this.el.innerHTML = ""; + this.el.classList.remove('o_list_optional_columns'); + + // destroy the previously instantiated pagers, if any + oldPagers.forEach(pager => pager.destroy()); + + // append the table (if any) to the main element + if (tableWrapper) { + dom.append(this.el, tableWrapper, { + callbacks: [{ widget: this }], + in_DOM: document.body.contains(this.el), + }); + if (this.optionalColumns.length) { + this.el.classList.add('o_list_optional_columns'); + this.$('table').append( + $('') + ); + this.$el.append(this._renderOptionalColumnsDropdown()); + } + if (this.selection.length) { + const $checked_rows = this.$('tr').filter( + (i, el) => this.selection.includes(el.dataset.id) + ); $checked_rows.find('.o_list_record_selector input').prop('checked', true); - if ($checked_rows.length === self.$('.o_data_row').length) { // all rows are checked - self.$('thead .o_list_record_selector input').prop('checked', true); + if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked + this.$('thead .o_list_record_selector input').prop('checked', true); } } - }); - return Promise.all([this._super.apply(this, arguments), prom]); + } + + // display the no content helper if necessary + if (!this._hasContent() && !!this.noContentHelp) { + this.$el.append(this._renderNoContentHelper()); + } }, /** * Each line or cell can be decorated according to a few simple rules. The diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index 3eeed181795..f1937655952 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -41,6 +41,7 @@ odoo.define('web.PivotController', function (require) { init: function (parent, model, renderer, params) { this._super(...arguments); + this.disableLinking = params.disableLinking; this.measures = params.measures; this.title = params.title; // views to use in the action triggered when a data cell is clicked @@ -273,7 +274,7 @@ odoo.define('web.PivotController', function (require) { _onOpenView: function (ev) { ev.stopPropagation(); const cell = ev.data; - if (cell.value === undefined || this.initialState.disableLinking) { + if (cell.value === undefined || this.disableLinking) { return; } diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index d00f75f5079..ceb15f24018 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -497,7 +497,7 @@ var PivotModel = AbstractModel.extend({ * @param {boolean} [options.raw=false] * @returns {Object} */ - get: function (options) { + __get: function (options) { options = options || {}; var raw = options.raw || false; var groupBys = this._getGroupBys(); @@ -546,7 +546,7 @@ var PivotModel = AbstractModel.extend({ * @param {Object} params.timeRanges * @returns {Promise} */ - load: function (params) { + __load: function (params) { this.initialDomain = params.domain; this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys; this.defaultGroupedBy = params.groupedBy; @@ -592,7 +592,7 @@ var PivotModel = AbstractModel.extend({ * @param {Object} [params.timeRanges] * @returns {Promise} */ - reload: function (handle, params) { + __reload: function (handle, params) { var self = this; var oldColGroupBys = this.data.colGroupBys; var oldRowGroupBys = this.data.rowGroupBys; diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index 7ff00d1fb77..7c021d6e3c0 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -114,9 +114,13 @@ odoo.define('web.PivotView', function (require) { this.loadParams.default_order = params.default_order || this.arch.attrs.default_order; this.loadParams.groupableFields = groupableFields; - this.rendererParams.widgets = widgets; - this.rendererParams.disableLinking = this.arch.attrs.disable_linking; + const disableLinking = !!(this.arch.attrs.disable_linking && + JSON.stringify(this.arch.attrs.disable_linking)); + this.rendererParams.widgets = widgets; + this.rendererParams.disableLinking = disableLinking; + + this.controllerParams.disableLinking = disableLinking; this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled"); this.controllerParams.measures = measures; diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js index 21ad867c286..4e7f802447d 100644 --- a/addons/web/static/src/js/views/qweb/qweb_view.js +++ b/addons/web/static/src/js/views/qweb/qweb_view.js @@ -51,13 +51,13 @@ var Model = AbstractModel.extend({ /** * get */ - get: function () { + __get: function () { return this._state; }, /** * load */ - load: function (params) { + __load: function (params) { _.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context'])); return this._fetch(); @@ -65,7 +65,7 @@ var Model = AbstractModel.extend({ /** * reload */ - reload: function (_id, params) { + __reload: function (_id, params) { _.extend(this._state, _.pick(params, ['domain', 'context'])); return this._fetch(); diff --git a/addons/web/static/src/js/views/sample_server.js b/addons/web/static/src/js/views/sample_server.js new file mode 100644 index 00000000000..6eaa023c47a --- /dev/null +++ b/addons/web/static/src/js/views/sample_server.js @@ -0,0 +1,609 @@ +odoo.define('web.SampleServer', function (require) { + "use strict"; + + const session = require('web.session'); + const utils = require('web.utils'); + const Registry = require('web.Registry'); + + class UnimplementedRouteError extends Error {} + + /** + * Helper function returning the value from a list of sample strings + * corresponding to the given ID. + * @param {number} id + * @param {string[]} sampleTexts + * @returns {string} + */ + function getSampleFromId(id, sampleTexts) { + return sampleTexts[(id - 1) % sampleTexts.length]; + } + + /** + * Helper function returning a regular expression specifically matching + * a given 'term' in a fieldName. For example `fieldNameRegex('abc')`: + * will match: + * - "abc" + * - "field_abc__def" + * will not match: + * - "aabc" + * - "abcd_ef" + * @param {...string} term + * @returns {RegExp} + */ + function fieldNameRegex(...terms) { + return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`); + } + + const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message'); + const EMAIL_REGEX = fieldNameRegex('email'); + const PHONE_REGEX = fieldNameRegex('phone'); + const URL_REGEX = fieldNameRegex('url'); + + /** + * Sample server class + * + * Represents a static instance of the server used when a RPC call sends + * empty values/groups while the attribute 'sample' is set to true on the + * view. + * + * This server will generate fake data and send them in the adequate format + * according to the route/method used in the RPC. + */ + class SampleServer { + + /** + * @param {string} modelName + * @param {Object} fields + */ + constructor(modelName, fields) { + this.mainModel = modelName; + this.data = {}; + this.data[modelName] = { + fields, + records: [], + }; + // Generate relational fields' co models + for (const fieldName in fields) { + const field = fields[fieldName]; + if (['many2one', 'one2many', 'many2many'].includes(field.type)) { + this.data[field.relation] = this.data[field.relation] || { + fields: { + display_name: { type: 'char' }, + id: { type: 'integer' }, + color: { type: 'integer' }, + }, + records: [], + }; + } + } + // On some models, empty grouped Kanban or List view still contain + // real (empty) groups. In this case, we re-use the result of the + // web_read_group rpc to tweak sample data s.t. those real groups + // contain sample records. + this.existingGroups = null; + // Sample records generation is only done if necessary, so we delay + // it to the first "mockRPC" call. These flags allow us to know if + // the records have been generated or not. + this.populated = false; + this.existingGroupsPopulated = false; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * This is the main entry point of the SampleServer. Mocks a request to + * the server with sample data. + * @param {Object} params + * @returns {any} the result obtained with the sample data + * @throws {Error} If called on a route/method we do not handle + */ + mockRpc(params) { + if (!(params.model in this.data)) { + throw new Error(`SampleServer: unknown model ${params.model}`); + } + this._populateModels(); + switch (params.method || params.route) { + case '/web/dataset/search_read': + return this._mockSearchReadController(params); + case 'web_read_group': + return this._mockWebReadGroup(params); + case 'read_group': + return this._mockReadGroup(params); + case 'read_progress_bar': + return this._mockReadProgressBar(params); + case 'read': + return this._mockRead(params); + } + const mock = SampleServer.mockRegistry.get(params.model); + const mockFunction = mock && mock[params.method || params.route]; + if (mockFunction) { + return mockFunction.call(this, params); + } + console.log(`SampleServer: unimplemented route "${params.method || params.route}"`); + throw new SampleServer.UnimplementedRouteError(); + } + + setExistingGroups(groups) { + this.existingGroups = groups; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @param {Object[]} measures, each measure has the form { fieldName, type } + * @param {Object[]} records + * @returns {Object} + */ + _aggregateFields(measures, records) { + const values = {}; + for (const { fieldName, type } of measures) { + if (type === 'float' || type === 'integer') { + if (records.length) { + let value = 0; + for (const record of records) { + value += record[fieldName]; + } + values[fieldName] = value; + } else { + values[fieldName] = null; + } + } + if (type === 'many2one') { + const ids = new Set(records.map(r => r[fieldName])); + values.fieldName = ids.size || null; + } + } + return values; + } + + /** + * @param {any} value + * @param {Object} options + * @param {string} [options.interval] + * @param {string} [options.relation] + * @param {string} [options.type] + * @returns {any} + */ + _formatValue(value, options) { + if (!value) { + return false; + } + const { type, interval, relation } = options; + if (['date', 'datetime'].includes(type)) { + const fmt = SampleServer.FORMATS[interval]; + return moment(value).format(fmt); + } else if (type === 'many2one') { + const rec = this.data[relation].records.find(({id}) => id === value); + return [value, rec.display_name]; + } else { + return value; + } + } + + /** + * Generates field values based on heuristics according to field types + * and names. + * + * @private + * @param {string} modelName + * @param {string} fieldName + * @param {number} id the record id + * @returns {any} the field value + */ + _generateFieldValue(modelName, fieldName, id) { + const field = this.data[modelName].fields[fieldName]; + switch (field.type) { + case "boolean": + return fieldName === 'active' ? true : Math.random() < 0.5; + case "char": + case "text": + if (["display_name", "name"].includes(fieldName)) { + if (SampleServer.PEOPLE_MODELS.includes(modelName)) { + return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE); + } else if (modelName === 'res.country') { + return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES); + } + } + if (fieldName === 'display_name') { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (["name", "reference"].includes(fieldName)) { + return `REF${String(id).padStart(4, '0')}`; + } else if (DESCRIPTION_REGEX.test(fieldName)) { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (EMAIL_REGEX.test(fieldName)) { + const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE) + .replace(/ /, ".") + .toLowerCase(); + return `${emailName}@sample.demo`; + } else if (PHONE_REGEX.test(fieldName)) { + return `+1 555 754 ${String(id).padStart(4, '0')}`; + } else if (URL_REGEX.test(fieldName)) { + return `http://sample${id}.com`; + } + return false; + case "date": + case "datetime": { + const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA); + const date = new moment().add(delta, "hour").format( + field.type === "date" ? "YYYY-MM-DD" : "YYYY-MM-DD HH:mm:ss" + ); + return date; + } + case "float": + return Math.random() * SampleServer.MAX_FLOAT; + case "integer": { + let max = SampleServer.MAX_INTEGER; + if (fieldName.includes('color')) { + max = Math.random() < 0.5 ? SampleServer.MAX_COLOR_INT : 0; + } + return Math.floor(Math.random() * max); + } + case "monetary": + return Math.floor(Math.random() * SampleServer.MAX_MONETARY); + case "many2one": + if (field.relation === 'res.currency') { + return session.company_currency_id; + } + return this._getRandomSubRecordId(); + case "one2many": + case "many2many": { + const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()]; + return [...new Set(ids)]; + } + case "selection": { + if (field.selection.length > 0) { + const index = Math.floor(Math.random() * field.selection.length); + return field.selection[index][0]; + } + return false; + } + default: + return false; + } + } + + /** + * Generates a random id in the range of ids generated for sub models. + * @private + * @returns {number} id in [1, SUB_RECORDSET_SIZE] + */ + _getRandomSubRecordId() { + return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1; + } + + /** + * Mocks calls to the read method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Array[]} params.args (args[0] is the list of ids, args[1] is + * the list of fields) + * @returns {Object[]} + */ + _mockRead(params) { + const model = this.data[params.model]; + const ids = params.args[0]; + const fieldNames = params.args[1]; + const records = []; + for (const r of model.records) { + if (!ids.includes(r.id)) { + continue; + } + const record = { id: r.id }; + for (const fieldName of fieldNames) { + const field = model.fields[fieldName]; + if (!field) { + record[fieldName] = false; // unknown field + } else if (field.type === 'many2one') { + const relModel = this.data[field.relation]; + const relRecord = relModel.records.find( + relR => r[fieldName] === relR.id + ); + record[fieldName] = relRecord ? + [relRecord.id, relRecord.display_name] : + false; + } else { + record[fieldName] = r[fieldName]; + } + } + records.push(record); + } + return records; + } + + /** + * Mocks calls to the read_group method. + * + * @param {Object} params + * @param {string} params.model + * @param {string[]} [params.fields] defaults to the list of all fields + * @param {string[]} params.groupBy + * @param {boolean} [params.lazy=true] + * @returns {Object[]} Object with keys groups and length + */ + _mockReadGroup(params) { + const lazy = 'lazy' in params ? params.lazy : true; + const model = params.model; + const fields = this.data[model].fields; + const records = this.data[model].records; + + const normalizedGroupBys = []; + let groupBy = []; + if (params.groupBy.length) { + groupBy = lazy ? [params.groupBy[0]] : params.groupBy; + } + for (const groupBySpec of groupBy) { + let [fieldName, interval] = groupBySpec.split(':'); + const { type, relation } = fields[fieldName]; + if (type) { + const gb = { fieldName, type, interval, relation }; + normalizedGroupBys.push(gb); + } + } + const groups = utils.groupBy(records, (record) => { + const vals = {}; + for (const gb of normalizedGroupBys) { + const { fieldName, type } = gb; + let value; + if (['date', 'datetime'].includes(type)) { + value = this._formatValue(record[fieldName], gb); + } else { + value = record[fieldName]; + } + vals[fieldName] = value; + } + return JSON.stringify(vals); + }); + const measures = []; + for (const measureSpec of (params.fields || Object.keys(fields))) { + const [fieldName, aggregateFunction] = measureSpec.split(':'); + const { type } = fields[fieldName]; + if (!params.groupBy.includes(fieldName) && type && + (type !== 'many2one' || aggregateFunction !== 'count_distinct')) { + measures.push({ fieldName, type }); + } + } + + const result = []; + for (const id in groups) { + const records = groups[id]; + const group = { __domain: [] }; + let countKey = `__count`; + if (normalizedGroupBys.length && lazy) { + countKey = `${normalizedGroupBys[0].fieldName}_count`; + } + group[countKey] = records.length; + const firstElem = records[0]; + for (const gb of normalizedGroupBys) { + const { fieldName } = gb; + group[fieldName] = this._formatValue(firstElem[fieldName], gb); + } + Object.assign(group, this._aggregateFields(measures, records)); + result.push(group); + } + return result; + } + + /** + * Mocks calls to the read_progress_bar method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Object} params.kwargs + * @return {Object} + */ + _mockReadProgressBar(params) { + const groupBy = params.kwargs.group_by; + const progress_bar = params.kwargs.progress_bar; + const groupByField = this.data[params.model].fields[groupBy]; + const data = {}; + for (const record of this.data[params.model].records) { + let groupByValue = record[groupBy]; + if (groupByField.type === "many2one") { + const relatedRecords = this.data[groupByField.relation].records; + const relatedRecord = relatedRecords.find(r => r.id === groupByValue); + groupByValue = relatedRecord.display_name; + } + if (!(groupByValue in data)) { + data[groupByValue] = {}; + for (const key in progress_bar.colors) { + data[groupByValue][key] = 0; + } + } + const fieldValue = record[progress_bar.field]; + if (fieldValue in data[groupByValue]) { + data[groupByValue][fieldValue]++; + } + } + return data; + } + + /** + * Mocks calls to the /web/dataset/search_read route to return sample + * records. + * @private + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @returns {{ records: Object[], length: number }} + */ + _mockSearchReadController(params) { + const model = this.data[params.model]; + const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT); + const records = this._mockRead({ + model: params.model, + args: [rawRecords.map(r => r.id), params.fields], + }); + return { records, length: records.length }; + } + + /** + * Mocks calls to the web_read_group method to return groups populated + * with sample records. Only handles the case where the real call to + * web_read_group returned groups, but none of these groups contain + * records. In this case, we keep the real groups, and populate them + * with sample records. + * @private + * @param {Object} params + * @param {Object} [result] the result of a real call to web_read_group + * @returns {{ groups: Object[], length: number }} + */ + _mockWebReadGroup(params) { + let groups; + if (this.existingGroups) { + this._tweakExistingGroups(params); + groups = this.existingGroups; + } else { + groups = this._mockReadGroup(params); + } + return { + groups, + length: groups.length, + }; + } + + /** + * Updates the sample data such that the existing groups (in database) + * also exists in the sample, and such that there are sample records in + * those groups. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.groupBy + */ + _populateExistingGroups(params) { + if (!this.existingGroupsPopulated) { + const groups = this.existingGroups; + this.groupsInfo = groups; + const groupBy = params.groupBy[0]; + const values = groups.map(g => g[groupBy]); + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === 'many2one'; + if (groupedByM2O) { // re-populate co model with relevant records + this.data[groupByField.relation].records = values.map(v => { + return { id: v[0], display_name: v[1] }; + }); + } + for (const r of this.data[params.model].records) { + const value = getSampleFromId(r.id, values); + r[groupBy] = groupedByM2O ? value[0] : value; + } + this.existingGroupsPopulated = true; + } + } + + /** + * Generates sample records for the models in this.data. Records will be + * generated once, and subsequent calls to this function will be skipped. + * @private + */ + _populateModels() { + if (!this.populated) { + for (const modelName in this.data) { + const model = this.data[modelName]; + const fieldNames = Object.keys(model.fields).filter(f => f !== 'id'); + const size = modelName === this.mainModel ? + SampleServer.MAIN_RECORDSET_SIZE : + SampleServer.SUB_RECORDSET_SIZE; + for (let id = 1; id <= size; id++) { + const record = { id }; + for (const fieldName of fieldNames) { + record[fieldName] = this._generateFieldValue(modelName, fieldName, id); + } + model.records.push(record); + } + } + this.populated = true; + } + } + + /** + * A real (web_)read_group call has been done, and it has returned groups, + * but they are all empty. This function updates the sample data such + * that those group values exist and those groups contain sample records. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @param {string[]} params.groupBy + * @returns {Object[]} groups with count and aggregate values updated + * + * TODO: rename + */ + _tweakExistingGroups(params) { + const groups = this.existingGroups; + this._populateExistingGroups(params); + + // update count and aggregates for each group + const groupBy = params.groupBy[0].split(':')[0]; + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === 'many2one'; + const records = this.data[params.model].records; + for (const g of groups) { + const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy]; + const recordsInGroup = records.filter(r => r[groupBy] === groupValue); + g[`${groupBy}_count`] = recordsInGroup.length; + for (const field of params.fields) { + const fieldType = this.data[params.model].fields[field].type; + if (['integer, float', 'monetary'].includes(fieldType)) { + g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0); + } + } + g.__data = { + records: this._mockRead({ + model: params.model, + args: [recordsInGroup.map(r => r.id), params.fields], + }), + length: recordsInGroup.length, + }; + } + } + } + + SampleServer.FORMATS = { + day: 'YYYY-MM-DD', + week: 'ww YYYY', + month: 'MMMM YYYY', + quarter: '[Q]Q YYYY', + year: 'Y', + }; + + SampleServer.MAIN_RECORDSET_SIZE = 16; + SampleServer.SUB_RECORDSET_SIZE = 5; + SampleServer.SEARCH_READ_LIMIT = 10; + + SampleServer.MAX_FLOAT = 100; + SampleServer.MAX_INTEGER = 50; + SampleServer.MAX_COLOR_INT = 7; + SampleServer.MAX_MONETARY = 100000; + SampleServer.DATE_DELTA = 24 * 7; // in hours -> 7 days + + SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"]; + SampleServer.SAMPLE_PEOPLE = [ + "John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot", + ]; + SampleServer.SAMPLE_TEXTS = [ + "Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa", + ]; + SampleServer.PEOPLE_MODELS = [ + 'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact' + ]; + + SampleServer.UnimplementedRouteError = UnimplementedRouteError; + + // mockRegistry allows to register mock version of methods or routes for + // specific models, for instance + // SampleServer.mockRegistry.registry('res.partner' { + // some_method: () => 23, + // some_route: () => "abcd", + // }); + SampleServer.mockRegistry = new Registry(); + + return SampleServer; +}); diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 61381d57160..7eea7e98ed2 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -1,5 +1,6 @@ .o_list_view { position: relative; + height: 100%; .o_list_table { position: relative; diff --git a/addons/web/static/src/scss/views.scss b/addons/web/static/src/scss/views.scss index d917b3fd1d6..584ec66faed 100644 --- a/addons/web/static/src/scss/views.scss +++ b/addons/web/static/src/scss/views.scss @@ -40,7 +40,7 @@ // No content helper .o_view_nocontent { - @include o-position-absolute(20%, 0, 0, 0); + @include o-position-absolute(30%, 0, 0, 0); pointer-events: none; z-index: 1; @@ -72,3 +72,38 @@ } } } + +// Sample data +@mixin web_view_sample_data { + opacity: 0.33; + pointer-events: none; + user-select: none; +} +.o_view_sample_data { + .o_list_view { + .o_data_row, .o_group_header, .o_list_table > tfoot { + @include web_view_sample_data; + } + .o_list_table { + cursor: default !important; + > thead .o_list_record_selector { + pointer-events: none; + } + } + } + .o_kanban_view { + .o_kanban_counter, + .o_kanban_record, + .o_kanban_toggle_fold, + .o_column_folded, + .o_column_archive_records, + .o_column_unarchive_records { + @include web_view_sample_data; + } + } + .o_nocontent_help { + border-radius: 100%; + background-color: #f9f9f9; + box-shadow: 0 0 120px 100px #f9f9f9; + } +} diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index 801120bf64b..341ace4fba8 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -282,7 +282,7 @@ odoo.define('web.test_utils_create', function (require) { async function createModel(params) { const widget = new Widget(); - const model = new params.Model(widget); + const model = new params.Model(widget, params); await testUtilsMock.addMockEnvironment(widget, params); diff --git a/addons/web/static/tests/views/abstract_model_tests.js b/addons/web/static/tests/views/abstract_model_tests.js new file mode 100644 index 00000000000..544a908b063 --- /dev/null +++ b/addons/web/static/tests/views/abstract_model_tests.js @@ -0,0 +1,130 @@ +odoo.define('web.abstract_model_tests', function (require) { + "use strict"; + + const AbstractModel = require('web.AbstractModel'); + const Domain = require('web.Domain'); + + QUnit.module('Views', {}, function () { + QUnit.module('AbstractModel'); + + QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) { + assert.expect(4); + + const Model = AbstractModel.extend({ + _isEmpty() { + return true; + }, + async __load() { + if (this.isSampleModel) { + await this._rpc({ model: 'partner', method: 'unknown' }); + } + }, + }); + + const model = new Model(null, { + modelName: 'partner', + fields: {}, + useSampleModel: true, + SampleModel: Model, + }); + + assert.ok(model.useSampleModel); + assert.notOk(model._isInSampleMode); + + await model.load({}); + + assert.notOk(model.useSampleModel); + assert.notOk(model._isInSampleMode); + + model.destroy(); + }); + + QUnit.test("don't cath general error on sample server in sample mode", async function (assert) { + assert.expect(5); + + const error = new Error(); + + const Model = AbstractModel.extend({ + _isEmpty() { + return true; + }, + async __reload() { + if (this.isSampleModel) { + await this._rpc({ model: 'partner', method: 'read_group' }); + } + }, + async _rpc() { + throw error; + }, + }); + + const model = new Model(null, { + modelName: 'partner', + fields: {}, + useSampleModel: true, + SampleModel: Model, + }); + + assert.ok(model.useSampleModel); + assert.notOk(model._isInSampleMode); + + await model.load({}); + + assert.ok(model.useSampleModel); + assert.ok(model._isInSampleMode); + + async function reloadModel() { + try { + await model.reload(); + } catch (e) { + assert.strictEqual(e, error); + } + } + + await reloadModel(); + + model.destroy(); + }); + + QUnit.test('fetch sample data: concurrency', async function (assert) { + assert.expect(3); + + const Model = AbstractModel.extend({ + _isEmpty() { + return true; + }, + __get() { + return { isSample: !!this.isSampleModel }; + }, + }); + + const model = new Model(null, { + modelName: 'partner', + fields: {}, + useSampleModel: true, + SampleModel: Model, + }); + + await model.load({ domain: Domain.FALSE_DOMAIN, }); + + const beforeReload = model.get(null, { withSampleData: true }); + + const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN }); + const duringReload = model.get(null, { withSampleData: true }); + + await reloaded; + + const afterReload = model.get(null, { withSampleData: true }); + + assert.strictEqual(beforeReload.isSample, true, + "Sample data flag must be true before reload" + ); + assert.strictEqual(duringReload.isSample, true, + "Sample data flag must be true during reload" + ); + assert.strictEqual(afterReload.isSample, false, + "Sample data flag must be true after reload" + ); + }); + }); +}); diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 3d28a32cf2e..f57478e1252 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -1309,7 +1309,7 @@ odoo.define('web.basic_model_tests', function (require) { res_id: 1, }; - model.load(params).then(function (resultID) { + await model.load(params).then(function (resultID) { var record = model.get(resultID); assert.ok(record.data.date instanceof moment, "fetched date field should have been formatted"); @@ -1317,7 +1317,7 @@ odoo.define('web.basic_model_tests', function (require) { params.res_id = 2; - model.load(params).then(function (resultID) { + await model.load(params).then(function (resultID) { var record = model.get(resultID); assert.strictEqual(record.data.date, false, "unset date field should be false"); @@ -1340,7 +1340,7 @@ odoo.define('web.basic_model_tests', function (require) { type: 'list', }; - model.load(params).then(function (resultID) { + await model.load(params).then(function (resultID) { var record = model.get(resultID); var firstRecord = record.data[0]; var secondRecord = record.data[1]; @@ -1367,7 +1367,7 @@ odoo.define('web.basic_model_tests', function (require) { type: 'record', }; - model.load(params).then(function (resultID) { + await model.load(params).then(function (resultID) { var record = model.get(resultID); assert.strictEqual(record.data.date, false, "date default value should be false"); }); @@ -1391,7 +1391,7 @@ odoo.define('web.basic_model_tests', function (require) { type: 'record', }; - model.load(params).then(function (resultID) { + await model.load(params).then(function (resultID) { var record = model.get(resultID); assert.ok(_.isEqual(record.data.category.res_ids, [12, 14]), "category field should have correct default value"); @@ -1958,7 +1958,7 @@ odoo.define('web.basic_model_tests', function (require) { }, }); - model.load(this.params); + await model.load(this.params); assert.verifySteps(['read'], "there should be only one read"); model.destroy(); @@ -1996,7 +1996,7 @@ odoo.define('web.basic_model_tests', function (require) { data: this.data, }); - model.load(params).then(function (resultID) { + await model.load(params).then(function (resultID) { var record = model.get(resultID); assert.strictEqual(record.data.product_ids.data[0].data.date, false, "date value should be in data, and should be false"); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 795d3b54324..ec9bc62d6fc 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -2,6 +2,7 @@ odoo.define('web.kanban_tests', function (require) { "use strict"; var AbstractField = require('web.AbstractField'); +const Domain = require('web.Domain'); var fieldRegistry = require('web.field_registry'); var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); var kanbanExamplesRegistry = require('web.kanban_examples_registry'); @@ -4045,6 +4046,529 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('empty grouped kanban with sample data and no columns', async function (assert) { + assert.expect(3); + + this.data.partner.records = []; + + const kanban = await createView({ + arch: ` + + + +
+ +
+
+
`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsNone(kanban, '.o_view_nocontent'); + assert.containsOnce(kanban, '.o_quick_create_unfolded'); + assert.containsOnce(kanban, '.o_kanban_example_background_container'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and click quick create', async function (assert) { + assert.expect(11); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + + + + +
+
+
+
`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "there should be two columns"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_view_nocontent'); + assert.containsN(kanban, '.o_kanban_record', 16, + "there should be 8 sample records by column"); + + await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first')); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record'); + assert.containsNone(kanban, '.o_view_nocontent'); + assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create'); + + await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle'); + await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add')); + + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_record'); + assert.containsNone(kanban, '.o_view_nocontent'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and cancel quick create', async function (assert) { + assert.expect(12); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + + + + +
+
+
+
`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, + "there should be two columns"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_view_nocontent'); + assert.containsN(kanban, '.o_kanban_record', 16, + "there should be 8 sample records by column"); + + await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first')); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record'); + assert.containsNone(kanban, '.o_view_nocontent'); + assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create'); + + await testUtils.dom.click(kanban.$('.o_kanban_view')); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_quick_create'); + assert.containsNone(kanban, '.o_kanban_record'); + assert.containsOnce(kanban, '.o_view_nocontent'); + + kanban.destroy(); + }); + + QUnit.test('empty kanban with sample data', async function (assert) { + assert.expect(6); + + this.data.partner.records = []; + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + + + + +
+
+
+
`, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 10, + "there should be 10 sample records"); + assert.containsOnce(kanban, '.o_view_nocontent'); + + await kanban.reload({ domain: [['id', '<', 0]]}); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + assert.containsOnce(kanban, '.o_view_nocontent'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data and many2many_tags', async function (assert) { + assert.expect(6); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + + + + +
+ + +
+
+
+
`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + assert.step(method || route); + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + }); + + assert.containsN(kanban, '.o_kanban_group', 2, "there should be 2 'real' columns"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.ok(kanban.$('.o_kanban_record').length >= 1, "there should be sample records"); + assert.ok(kanban.$('.o_field_many2manytags .o_tag').length >= 1, "there should be tags"); + + assert.verifySteps(["web_read_group"], "should not read the tags"); + kanban.destroy(); + }); + + QUnit.test('sample data does not change after reload with sample data', async function (assert) { + assert.expect(4); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + + + + +
+
+
+
`, + groupBy: ['product_id'], + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return empty groups, as this is + // the case for several models (e.g. project.task grouped + // by stage_id) + result.groups.forEach(group => { + group[`${kwargs.groupby[0]}_count`] = 0; + }); + } + return result; + }, + }); + + const columns = kanban.el.querySelectorAll('.o_kanban_group'); + + assert.ok(columns.length >= 1, "there should be at least 1 sample column"); + assert.hasClass(kanban.$el, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_record', 16); + + const kanbanText = kanban.el.innerText; + await kanban.reload(); + + assert.strictEqual(kanbanText, kanban.el.innerText, + "the content should be the same after reloading the view"); + + kanban.destroy(); + }); + + QUnit.test('non empty kanban with sample data', async function (assert) { + assert.expect(5); + + const kanban = await createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: ` + + + + +
+
+
+
`, + viewOptions: { + action: { + help: "No content helper", + }, + }, + }); + + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4); + assert.containsNone(kanban, '.o_view_nocontent'); + + await kanban.reload({ domain: [['id', '<', 0]]}); + assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data'); + assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: add a column', async function (assert) { + assert.expect(6); + + const kanban = await createView({ + arch: ` + + + +
+ +
+
+
`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + result.groups = this.data.product.records.map(r => { + return { + product_id: [r.id, r.display_name], + product_id_count: 0, + __domain: ['product_id', '=', r.id], + }; + }); + result.length = result.groups.length; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column')); + await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo"); + await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add')); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 3); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + kanban.destroy(); + }); + + QUnit.skip('empty grouped kanban with sample data: fold/unfold a column', async function (assert) { + // folding/unfolding of grouped kanban with sample data is currently disabled + assert.expect(8); + + const kanban = await createView({ + arch: ` + + + +
+ +
+
+
`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { kwargs, method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return a single, empty group + result.groups = result.groups.slice(0, 1); + result.groups[0][`${kwargs.groupby[0]}_count`] = 0; + result.length = 1; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_kanban_group'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // Fold the column + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a')); + await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_kanban_toggle_fold')); + + assert.containsOnce(kanban, '.o_kanban_group'); + assert.hasClass(kanban.$('.o_kanban_group'), 'o_column_folded'); + + // Unfold the column + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group.o_column_folded')); + + assert.containsOnce(kanban, '.o_kanban_group'); + assert.doesNotHaveClass(kanban.$('.o_kanban_group'), 'o_column_folded'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: delete a column', async function (assert) { + assert.expect(5); + + this.data.partner.records = []; + + let groups = [{ + product_id: [1, 'New'], + product_id_count: 0, + __domain: [], + }]; + const kanban = await createView({ + arch: ` + + + +
+ +
+
+
`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { method }) { + let result = await this._super(...arguments); + if (method === 'web_read_group') { + // override read_group to return a single, empty group + return { + groups, + length: groups.length, + }; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsOnce(kanban, '.o_kanban_group'); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // Delete the first column + groups = []; + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a')); + await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_column_delete')); + await testUtils.dom.click(document.querySelector('.modal .btn-primary')); + + assert.containsNone(kanban, '.o_kanban_group'); + assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded'); + + kanban.destroy(); + }); + + QUnit.test('empty grouped kanban with sample data: add a column and delete it right away', async function (assert) { + assert.expect(9); + + const kanban = await createView({ + arch: ` + + + +
+ +
+
+
`, + data: this.data, + groupBy: ['product_id'], + model: 'partner', + View: KanbanView, + async mockRPC(route, { method }) { + const result = await this._super(...arguments); + if (method === 'web_read_group') { + result.groups = this.data.product.records.map(r => { + return { + product_id: [r.id, r.display_name], + product_id_count: 0, + __domain: ['product_id', '=', r.id], + }; + }); + result.length = result.groups.length; + } + return result; + }, + }); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // add a new column + await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column')); + await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo"); + await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add')); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 3); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + // delete the column we just created + const newColumn = kanban.el.querySelectorAll('.o_kanban_group')[2]; + await testUtils.dom.click(newColumn.querySelector('.o_kanban_config > a')); + await testUtils.dom.click(newColumn.querySelector('.dropdown-item.o_column_delete')); + await testUtils.dom.click(document.querySelector('.modal .btn-primary')); + + assert.hasClass(kanban, 'o_view_sample_data'); + assert.containsN(kanban, '.o_kanban_group', 2); + assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records'); + + kanban.destroy(); + }); + QUnit.test('bounce create button when no data and click on empty area', async function (assert) { assert.expect(2); @@ -4535,7 +5059,6 @@ QUnit.module('Views', { await testUtils.dom.click(kanban.$('.o_column_quick_create')); kanban.$('.o_column_quick_create input').val('new column'); await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add')); - assert.isVisible(kanban.$buttons.find('.o-kanban-button-new'), "Create button should now be visible"); kanban.destroy(); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 67c1a7962a0..3d49b2079cc 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -5,6 +5,7 @@ var AbstractFieldOwl = require('web.AbstractFieldOwl'); var AbstractStorageService = require('web.AbstractStorageService'); var BasicModel = require('web.BasicModel'); var core = require('web.core'); +const Domain = require('web.Domain') var basicFields = require('web.basic_fields'); var fieldRegistry = require('web.field_registry'); var fieldRegistryOwl = require('web.field_registry_owl'); @@ -3764,6 +3765,284 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test("empty list with sample data", async function (assert) { + assert.expect(19); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + + + + + + + + + `, + domain: [['id', '<', 0]], // such that no record matches the domain + viewOptions: { + action: { + help: '

click to add a partner

' + } + }, + }); + + assert.hasClass(list.$el, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 10); + assert.containsOnce(list, '.o_nocontent_help .hello'); + + // Check list sample data + const firstRow = list.el.querySelector('.o_data_row'); + const cells = firstRow.querySelectorAll(':scope > .o_data_cell'); + assert.strictEqual(cells[0].innerText.trim(), "", + "Char field should yield an empty element" + ); + assert.containsOnce(cells[1], '.custom-checkbox', + "Boolean field has been instantiated" + ); + assert.notOk(isNaN(cells[2].innerText.trim()), "Intger value is a number"); + assert.ok(cells[3].innerText.trim(), "Many2one field is a string"); + + const firstM2MTag = cells[4].querySelector( + ':scope span.o_badge_text' + ).innerText.trim(); + assert.ok(firstM2MTag.length > 0, "Many2many contains at least one string tag"); + + assert.ok(/\d{2}\/\d{2}\/\d{4}/.test(cells[5].innerText.trim()), + "Date field should have the right format" + ); + assert.ok(/\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}/.test(cells[6].innerText.trim()), + "Datetime field should have the right format" + ); + + const textContent = list.$el.text(); + await list.reload(); + assert.strictEqual(textContent, list.$el.text(), + 'The content should be the same after reloading the view without change' + ); + + // reload with another domain -> should no longer display the sample records + await list.reload({ domain: Domain.FALSE_DOMAIN }); + + assert.doesNotHaveClass(list.$el, 'o_view_sample_data'); + assert.containsNone(list, '.o_list_table'); + assert.containsOnce(list, '.o_nocontent_help .hello'); + + // reload with another domain matching records + await list.reload({ domain: Domain.TRUE_DOMAIN }); + + assert.doesNotHaveClass(list.$el, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 4); + assert.containsNone(list, '.o_nocontent_help .hello'); + + list.destroy(); + }); + + QUnit.test("non empty list with sample data", async function (assert) { + assert.expect(6); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + + + + + `, + domain: Domain.TRUE_DOMAIN, + }); + + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 4); + assert.doesNotHaveClass(list.$el, 'o_view_sample_data'); + + // reload with another domain matching no record (should not display the sample records) + await list.reload({ domain: Domain.FALSE_DOMAIN }); + + assert.containsOnce(list, '.o_list_table'); + assert.containsNone(list, '.o_data_row'); + assert.doesNotHaveClass(list.$el, 'o_view_sample_data'); + + list.destroy(); + }); + + QUnit.test('click on header in empty list with sample data', async function (assert) { + assert.expect(4); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + + + + + `, + domain: Domain.FALSE_DOMAIN, + }); + + assert.hasClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 10); + + const content = list.$el.text(); + await testUtils.dom.click(list.$('tr:first .o_column_sortable:first')); + assert.strictEqual(list.$el.text(), content, "the content should still be the same"); + + list.destroy(); + }); + + QUnit.test("non empty editable list with sample data: delete all records", async function (assert) { + assert.expect(7); + + const list = await createView({ + arch: ` + + + + + `, + data: this.data, + domain: Domain.TRUE_DOMAIN, + model: 'foo', + View: ListView, + viewOptions: { + action: { + help: '

click to add a partner

' + }, + hasActionMenus: true, + }, + }); + + // Initial state: all records displayed + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 4); + assert.containsNone(list, '.o_nocontent_help'); + + // Delete all records + await testUtils.dom.click(list.el.querySelector('thead .o_list_record_selector input')); + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Delete"); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + // Final state: no more sample data, but nocontent helper displayed + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsNone(list, '.o_list_table'); + assert.containsOnce(list, '.o_nocontent_help'); + + list.destroy(); + }); + + QUnit.test("empty editable list with sample data: start create record and cancel", async function (assert) { + assert.expect(10); + + const list = await createView({ + arch: ` + + + + + `, + data: this.data, + domain: Domain.FALSE_DOMAIN, + model: 'foo', + View: ListView, + viewOptions: { + action: { + help: '

click to add a partner

' + }, + }, + }); + + // Initial state: sample data and nocontent helper displayed + assert.hasClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 10); + assert.containsOnce(list, '.o_nocontent_help'); + + // Start creating a record + await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add')); + + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_data_row'); + + // Discard temporary record + await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_discard')); + + // Final state: table should be displayed with no data at all + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsNone(list, '.o_data_row'); + assert.containsNone(list, '.o_nocontent_help'); + + list.destroy(); + }); + + QUnit.test("empty editable list with sample data: create and delete record", async function (assert) { + assert.expect(13); + + const list = await createView({ + arch: ` + + + + + `, + data: this.data, + domain: Domain.FALSE_DOMAIN, + model: 'foo', + View: ListView, + viewOptions: { + action: { + help: '

click to add a partner

' + }, + hasActionMenus: true, + }, + }); + + // Initial state: sample data and nocontent helper displayed + assert.hasClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsN(list, '.o_data_row', 10); + assert.containsOnce(list, '.o_nocontent_help'); + + // Start creating a record + await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add')); + + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_data_row'); + + // Save temporary record + await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_save')); + + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsOnce(list, '.o_list_table'); + assert.containsOnce(list, '.o_data_row'); + assert.containsNone(list, '.o_nocontent_help'); + + // Delete newly created record + await testUtils.dom.click(list.el.querySelector('.o_data_row input')); + await cpHelpers.toggleActionMenu(list); + await cpHelpers.toggleMenuItem(list, "Delete"); + await testUtils.dom.click($('.modal-footer .btn-primary')); + + // Final state: there should be no table, but the no content helper + assert.doesNotHaveClass(list, 'o_view_sample_data'); + assert.containsNone(list, '.o_list_table'); + assert.containsOnce(list, '.o_nocontent_help'); + + list.destroy(); + }); + QUnit.test('groupby node with a button', async function (assert) { assert.expect(14); diff --git a/addons/web/static/tests/views/sample_server_tests.js b/addons/web/static/tests/views/sample_server_tests.js new file mode 100644 index 00000000000..5582585a0a1 --- /dev/null +++ b/addons/web/static/tests/views/sample_server_tests.js @@ -0,0 +1,461 @@ +odoo.define('web.sample_server_tests', function (require) { + "use strict"; + + const SampleServer = require('web.SampleServer'); + const session = require('web.session'); + const { mock } = require('web.test_utils'); + + const { + MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits + SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values + MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY // Number values + } = SampleServer; + + QUnit.module("Sample Server", { + beforeEach() { + this.fields = { + 'res.users': { + display_name: { string: "Name", type: 'char' }, + name: { string: "Reference", type: 'char' }, + email: { string: "Email", type: 'char' }, + phone_number: { string: "Phone number", type: 'char' }, + brol_machin_url_truc: { string: "URL", type: 'char' }, + urlemailphone: { string: "Whatever", type: 'char' }, + active: { string: "Active", type: 'boolean' }, + is_alive: { string: "Is alive", type: 'boolean' }, + description: { string: "Description", type: 'text' }, + birthday: { string: "Birthday", type: 'date' }, + arrival_date: { string: "Date of arrival", type: 'datetime' }, + height: { string: "Height", type: 'float' }, + color: { string: "Color", type: 'integer' }, + age: { string: "Age", type: 'integer' }, + salary: { string: "Salary", type: 'monetary' }, + currency: { string: "Currency", type: 'many2one', relation: 'res.currency' }, + manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' }, + managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' }, + tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' }, + type: { string: "Type", type: 'selection', selection: [ + ['client', "Client"], ['partner', "Partner"], ['employee', "Employee"] + ] }, + }, + 'res.country': { + display_name: { string: "Name", type: 'char' }, + }, + 'hobbit': { + display_name: { string: "Name", type: 'char' }, + profession: { string: "Profession", type: 'selection', selection: [ + ['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"] + ] }, + age: { string: "Age", type: 'integer' }, + }, + }; + }, + }, function () { + + QUnit.module("Basic behaviour"); + + QUnit.test("Sample data: people type + all field names", async function (assert) { + assert.expect(24); + + mock.patch(session, { + company_currency_id: 4, + }); + + const allFieldNames = Object.keys(this.fields['res.users']); + const server = new SampleServer('res.users', this.fields['res.users']); + const { records } = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'res.users', + fields: allFieldNames, + }); + const rec = records[0]; + + function assertFormat(fieldName, regex) { + if (regex instanceof RegExp) { + assert.ok( + regex.test(rec[fieldName].toString()), + `Field "${fieldName}" has the correct format` + ); + } else { + assert.strictEqual( + typeof rec[fieldName], regex, + `Field "${fieldName}" is of type ${regex}` + ); + } + } + function assertBetween(fieldName, min, max, decimal = 1) { + assert.ok( + min <= rec[fieldName] && rec[fieldName] < max && + rec[fieldName].toString().split(".").length === decimal, + `Field "${fieldName}" is between ${min} and ${max} and is ${ + decimal === 1 ? "an integer" : "a float number" + }` + ); + } + + // Basic fields + assert.ok(SAMPLE_PEOPLE.includes(rec.display_name)); + assert.ok(SAMPLE_PEOPLE.includes(rec.name)); + assert.strictEqual(rec.email, + `${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo` + ); + assertFormat('phone_number', /\+1 555 754 000\d/); + assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/); + assert.strictEqual(rec.urlemailphone, false); + assert.strictEqual(rec.active, true); + assertFormat('is_alive', 'boolean'); + assert.ok(SAMPLE_TEXTS.includes(rec.description)); + assertFormat('birthday', /\d{4}-\d{2}-\d{2}/); + assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + assertBetween('height', 0, MAX_FLOAT, 2); + assertBetween('color', 0, MAX_COLOR_INT); + assertBetween('age', 0, MAX_INTEGER); + assertBetween('salary', 0, MAX_MONETARY); + + const selectionValues = this.fields['res.users'].type.selection.map( + (sel) => sel[0] + ); + assert.ok(selectionValues.includes(rec.type)); + + // Relational fields + assert.strictEqual(rec.currency[0], 4); + // Currently we expect the currency name to be a latin string, which + // is not important; in most case we only need the ID. The following + // assertion can be removed if needed. + assert.ok(SAMPLE_TEXTS.includes(rec.currency[1])); + + assert.strictEqual(typeof rec.manager_id[0], 'number'); + assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1])); + + assert.ok([1, 2].includes(rec.managed_ids.length)); + assert.ok(rec.managed_ids.every( + (id) => typeof id === 'number') + ); + + assert.ok([1, 2].includes(rec.tag_ids.length)); + assert.ok(rec.tag_ids.every( + (id) => typeof id === 'number') + ); + + mock.unpatch(session); + }); + + QUnit.test("Sample data: country type", async function (assert) { + assert.expect(1); + + const server = new SampleServer('res.country', this.fields['res.country']); + const { records } = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'res.country', + fields: ['display_name'], + }); + + assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name)); + }); + + QUnit.test("Sample data: any type", async function (assert) { + assert.expect(1); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const { records } = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'hobbit', + fields: ['display_name'], + }); + + assert.ok(SAMPLE_TEXTS.includes(records[0].display_name)); + }); + + QUnit.module("RPC calls"); + + QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) { + assert.expect(3); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'hobbit', + fields: ['display_name'], + }); + + assert.deepEqual( + Object.keys(result.records[0]), + ['id', 'display_name'] + ); + assert.strictEqual(result.length, SEARCH_READ_LIMIT); + assert.ok(/\w+/.test(result.records[0].display_name), + "Display name has been mocked" + ); + }); + + QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) { + assert.expect(3); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: '/web/dataset/search_read', + model: 'hobbit', + fields: ['name'], + }); + + assert.deepEqual( + Object.keys(result.records[0]), + ['id', 'name'] + ); + assert.strictEqual(result.length, SEARCH_READ_LIMIT); + assert.strictEqual(result.records[0].name, false, + `Field "name" doesn't exist => returns false` + ); + }); + + QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) { + assert.expect(1); + + const server = new SampleServer('hobbit', this.fields.hobbit); + server.setExistingGroups([]); + + const result = await server.mockRpc({ + method: 'web_read_group', + model: 'hobbit', + groupBy: ['profession'], + }); + + assert.deepEqual(result, { groups: [], length: 0 }); + }); + + QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) { + assert.expect(6); + + const server = new SampleServer('hobbit', this.fields.hobbit); + const existingGroups = [ + { profession: 'gardener', profession_count: 0 }, + { profession: 'adventurer', profession_count: 0 }, + ]; + server.setExistingGroups(existingGroups); + + const result = await server.mockRpc({ + method: 'web_read_group', + model: 'hobbit', + groupBy: ['profession'], + fields: [], + }); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result.groups.length, 2); + + const professions = result.groups.map((g) => g.profession); + assert.ok(professions.includes("gardener")); + assert.ok(professions.includes("adventurer")); + + assert.strictEqual( + result.groups.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE + ); + assert.ok( + result.groups.every((g) => g.profession_count === g.__data.length) + ); + }); + + QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) { + assert.expect(7); + + const server = new SampleServer('hobbit', this.fields.hobbit); + const existingGroups = [ + { profession: 'gardener', profession_count: 0 }, + { profession: 'brewer', profession_count: 0 }, + { profession: 'adventurer', profession_count: 0 }, + ]; + server.setExistingGroups(existingGroups); + + const result = await server.mockRpc({ + method: 'web_read_group', + model: 'hobbit', + groupBy: ['profession'], + fields: [], + }); + + assert.strictEqual(result.length, 3); + assert.strictEqual(result.groups.length, 3); + + const professions = result.groups.map((g) => g.profession); + assert.ok(professions.includes("gardener")); + assert.ok(professions.includes("brewer")); + assert.ok(professions.includes("adventurer")); + + assert.strictEqual( + result.groups.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE + ); + assert.ok( + result.groups.every((g) => g.profession_count === g.__data.length) + ); + }); + + QUnit.test("Send 'read_group' RPC: no group", async function (assert) { + assert.expect(1); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: [], + }); + + assert.deepEqual(result, [{ + __count: MAIN_RECORDSET_SIZE, + __domain: [], + }]); + }); + + QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) { + assert.expect(5); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: ['profession'], + }); + + assert.strictEqual(result.length, 3); + + const professions = result.map((g) => g.profession); + assert.ok(professions.includes("gardener")); + assert.ok(professions.includes("brewer")); + assert.ok(professions.includes("adventurer")); + + assert.strictEqual( + result.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE, + ); + }); + + QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) { + assert.expect(6); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: ['age'], + groupBy: ['profession'], + }); + + assert.strictEqual(result.length, 3); + + const professions = result.map((g) => g.profession); + assert.ok(professions.includes("gardener")); + assert.ok(professions.includes("brewer")); + assert.ok(professions.includes("adventurer")); + + assert.strictEqual( + result.reduce((acc, g) => acc + g.profession_count, 0), + MAIN_RECORDSET_SIZE, + ); + + assert.strictEqual( + result.reduce((acc, g) => acc + g.age, 0), + server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0) + ); + }); + + QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) { + assert.expect(2); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: ['profession', 'age'], + }); + + assert.ok('profession' in result[0]); + assert.notOk('age' in result[0]); + }); + + QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) { + assert.expect(2); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read_group', + model: 'hobbit', + fields: [], + groupBy: ['profession', 'age'], + lazy: false, + }); + + assert.ok('profession' in result[0]); + assert.ok('age' in result[0]); + }); + + QUnit.test("Send 'read' RPC: no id", async function (assert) { + assert.expect(1); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read', + model: 'hobbit', + args: [ + [], ['display_name'] + ], + }); + + assert.deepEqual(result, []); + }); + + QUnit.test("Send 'read' RPC: one id", async function (assert) { + assert.expect(3); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const result = await server.mockRpc({ + method: 'read', + model: 'hobbit', + args: [ + [1], ['display_name'] + ], + }); + + assert.strictEqual(result.length, 1); + assert.ok( + /\w+/.test(result[0].display_name), + "Display name has been mocked" + ); + assert.strictEqual(result[0].id, 1); + }); + + QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) { + assert.expect(1); + + const server = new SampleServer('hobbit', this.fields.hobbit); + + const amount = MAIN_RECORDSET_SIZE + 3; + const ids = new Array(amount).fill().map((_, i) => i + 1); + const result = await server.mockRpc({ + method: 'read', + model: 'hobbit', + args: [ + ids, ['display_name'] + ], + }); + + assert.strictEqual(result.length, MAIN_RECORDSET_SIZE); + }); + + // To be implemented if needed + // QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... }); + }); +}); diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index 3cb081e7e37..caa6f06a2c5 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -337,6 +337,7 @@ + @@ -701,6 +702,7 @@ + @@ -710,6 +712,7 @@ + diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 79bd92b5a73..97d96fb7062 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -121,6 +121,18 @@ an impact on all view types. ``info``, ``warning``, ``danger`` and ``secondary`` displays. The list view supports ``bf``, ``it``, ``success``, ``info``, ``warning``, ``danger``, ``muted`` and ``primary`` displays. +* ``sample`` (``kanban`` & ``list`` & ``gantt``) + + Populate the view with a set of sample records if none are found for the current model. + This attribute is false by default. + + These fake records will have heuristics for certain field names/models. For example, + a field 'display_name' on the model 'res.users' will be populated with sample people names + while an 'email' field will be in the form 'firstname.lastname@sample.demo'. + + The user will not be able to interact with these data and they will be discarded as soon as + an action is performed (record created, column added, etc.) + * ``banner_route`` a route address to be fetched and prepended to the view. From 6998452065044650665c5020e6b730aede690ec0 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Fri, 19 Jun 2020 07:06:32 +0000 Subject: [PATCH 4/7] [IMP] web: disable keyboard navigation with sample data Previous commit introduces the sample data feature in list and kanban views. When sample data is displayed, most parts of the renderers can't be clicked on, to prevent the user from interacting with sample records (e.g. to open a sample record in form view from the list). This commit disables keyboard navigation as well, as those sample records could still be accessed with the keyboard. To do so, several refactorings have been done in views: - _giveFocus function added on controllers (so that we can hook on the moment where we want to focus the renderer, and prevent it in sample mode), it kind of replaces giveFocus of ActionMixin which wasn't use from the outside - updateRendererState -> _updateRendererState as it is private - _setState function added on controllers, to have a hook when we set a new state (already existed, but only in kanban) - _renderView function moved from Basic to AbstractRenderer, so that we have a proper hook to deactivate clickable elements in sample mode Task 2232801 X-original-commit: 4482a03fe2f083f4f44fc6eb76d90ae22afbff91 --- .../web/static/src/js/chrome/action_mixin.js | 5 - .../src/js/views/abstract_controller.js | 53 ++++----- .../static/src/js/views/abstract_renderer.js | 88 +++++++++++++-- .../src/js/views/basic/basic_controller.js | 8 +- .../src/js/views/basic/basic_renderer.js | 46 +++----- .../js/views/calendar/calendar_renderer.js | 10 +- .../static/src/js/views/form/form_renderer.js | 3 +- .../src/js/views/graph/graph_renderer.js | 11 +- .../src/js/views/kanban/kanban_controller.js | 2 +- .../src/js/views/kanban/kanban_renderer.js | 34 +++--- .../src/js/views/list/list_controller.js | 12 ++- .../js/views/list/list_editable_renderer.js | 4 +- .../static/src/js/views/list/list_renderer.js | 18 +++- addons/web/static/src/scss/views.scss | 38 +++---- addons/web/static/tests/views/graph_tests.js | 2 +- addons/web/static/tests/views/kanban_tests.js | 47 ++++++++ addons/web/static/tests/views/list_tests.js | 102 ++++++++++++++++++ 17 files changed, 336 insertions(+), 147 deletions(-) diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js index 99f45405c72..82feba04102 100644 --- a/addons/web/static/src/js/chrome/action_mixin.js +++ b/addons/web/static/src/js/chrome/action_mixin.js @@ -154,11 +154,6 @@ odoo.define('web.ActionMixin', function (require) { return this._title; }, - /** - * Gives the focus to the action - */ - giveFocus: function () { }, - /** * Renders the buttons to append, in most cases, to the control panel (in * the bottom left corner). When the action is rendered in a dialog, those diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index 3df1d48410b..07c4138c54d 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -96,7 +96,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { if (this.withControlPanel) { this._updateControlPanelProps(this.initialState); this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps); - this._controlPanelWrapper.env.bus.on('focus-view', this, () => this.renderer.giveFocus()); + this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus()); promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' })); } await Promise.all(promises); @@ -192,12 +192,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { } return state; }, - /** - * Gives the focus to the renderer - */ - giveFocus: function () { - this.renderer.giveFocus(); - }, /** * The use of this method is discouraged. It is still snakecased, because * it currently is used in many templates, but we will move to a simpler @@ -246,7 +240,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { } await Promise.all([this.update(params, {}), searchPanelUpdateProm]); if (postponeRendering) { - return this.renderer._render(); + return this._updateRendererState(false); } }, /** @@ -261,7 +255,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { * @param {Object} params will be given to the model and to the renderer * @param {Object} [options={}] * @param {boolean} [options.reload=true] if true, the model will reload data - * * @returns {Promise} */ async update(params, options = {}) { @@ -272,7 +265,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { const localState = this.renderer.getLocalState(); const state = this.model.get(this.handle, { withSampleData: true }); const promises = [ - this.updateRendererState(state, params).then(() => { + this._updateRendererState(state, params).then(() => { this.renderer.setLocalState(localState); }), this._update(this.model.get(this.handle), params) @@ -280,19 +273,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { await this.dp.add(Promise.all(promises)); this.updateButtons(); }, - /** - * Update the state of the renderer (handle both Widget and Component - * renderers). - * - * @param {Object} state the model state - * @param {Object} params will be given to the model and to the renderer - */ - updateRendererState: function (state, params) { - if (this.renderer instanceof owl.Component) { - return this.renderer.update(state); - } - return this.renderer.updateState(state, params); - }, //-------------------------------------------------------------------------- // Private @@ -326,6 +306,16 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { _getActionMenuItems: function (state) { return null; }, + /** + * Gives the focus to the renderer if not in sample mode. + * + * @private + */ + _giveFocus() { + if (!this.model.isInSampleMode()) { + this.renderer.giveFocus(); + } + }, /** * This method is the way a view can notifies the outside world that * something has changed. The main use for this is to update the url, for @@ -472,6 +462,21 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { return this.updateControlPanel({ pager: pagingInfo }); } }, + /** + * Updates the state of the renderer (handle both Widget and Component + * renderers). + * + * @private + * @param {Object} state the model state + * @param {Object} [params={}] will be given to the model and to the renderer + * @return {Promise} + */ + _updateRendererState(state, params = {}) { + if (this.renderer instanceof owl.Component) { + return this.renderer.update(state); + } + return this.renderer.updateState(state, params); + }, //-------------------------------------------------------------------------- // Handlers @@ -551,7 +556,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { break; case 'down': ev.stopPropagation(); - this.giveFocus(); + this._giveFocus(); break; } }, diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index afaf275ab5b..e1ae3fe5370 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -9,10 +9,28 @@ odoo.define('web.AbstractRenderer', function (require) { var mvc = require('web.mvc'); +// Renderers may display sample data when there is no real data to display. In +// this case the data is displayed with opacity and can't be clicked. Moreover, +// we also want to prevent the user from accessing DOM elements with TAB +// navigation. This is the list of elements we won't allow to focus. +const FOCUSABLE_ELEMENTS = [ + // focusable by default + 'a', 'button', 'input', 'select', 'textarea', + // manually set + '[tabindex="0"]' +].map((sel) => `:scope ${sel}`).join(', '); + /** * @class AbstractRenderer */ return mvc.Renderer.extend({ + // Defines the elements suppressed when in demo data. This must be a list + // of DOM selectors matching view elements that will: + // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events) + // 2. have themselves and any of their focusable children removed from the + // tab navigation + sampleDataTargets: [], + /** * @override * @param {string} [params.noContentHelp] @@ -29,12 +47,12 @@ return mvc.Renderer.extend({ * * @returns {Promise} */ - start: function () { + async start() { this.$el.addClass(this.arch.attrs.class); if (this.withSearchPanel) { this.$el.addClass('o_renderer_with_searchpanel'); } - return Promise.all([this._render(), this._super()]); + await Promise.all([this._render(), this._super()]); }, /** * Called each time the renderer is attached into the DOM. @@ -92,9 +110,11 @@ return mvc.Renderer.extend({ * if true, the method only updates the state without rerendering * @returns {Promise} */ - updateState: function (state, params) { - this.state = state; - return params.noRender ? Promise.resolve() : this._render(); + async updateState(state, params) { + this._setState(state); + if (!params.noRender) { + await this._render(); + } }, //-------------------------------------------------------------------------- @@ -102,14 +122,66 @@ return mvc.Renderer.extend({ //-------------------------------------------------------------------------- /** - * Render the view + * Renders the widget. This method can be overriden to perform actions + * before or after the view has been rendered. + * + * @private + * @returns {Promise} + */ + async _render() { + await this._renderView(); + this._suppressFocusableElements(); + }, + /** + * Actual rendering. This method is meant to be overridden by concrete + * renderers. * * @abstract * @private * @returns {Promise} */ - _render: function () { - return Promise.resolve(); + async _renderView() { }, + /** + * Assigns a new state to the renderer if not false. + * + * @private + * @param {any} [state=false] + */ + _setState(state = false) { + if (state !== false) { + this.state = state; + } + }, + /** + * Suppresses 'tabindex' property on any focusable element located inside + * root elements defined in the `this.sampleDataTargets` object and assigns + * the 'o_sample_data_disabled' class to these root elements. + * + * @private + * @see sampleDataTargets + */ + _suppressFocusableElements() { + if (!this.state.isSample) { + return; + } + const rootEls = []; + for (const selector of this.sampleDataTargets) { + rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`)); + } + const focusableEls = new Set(rootEls); + for (const rootEl of rootEls) { + rootEl.classList.add('o_sample_data_disabled'); + for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) { + focusableEls.add(focusableEl); + } + } + for (const focusableEl of focusableEls) { + focusableEl.setAttribute('tabindex', -1); + if (focusableEl.classList.contains('dropdown-item')) { + // Tells Bootstrap to ignore the dropdown item in keynav + focusableEl.classList.add('disabled'); + } + } }, }); 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 0849cf5508d..ec7255b9e60 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -133,12 +133,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { getSelectedIds: function () { return []; }, - /** - * Gives the focus to the renderer - */ - giveFocus:function() { - this.renderer.giveFocus(); - }, /** * Returns true iff the given recordID (or the main recordID) is dirty. * @@ -761,7 +755,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { await this.model.resequence(this.modelName, resIDs, this.handle, options); this._updateControlPanel(); state = this.model.get(this.handle); - return this.renderer.updateState(state, { noRender: true }); + return this._updateRendererState(state, { noRender: true }); }, }); }, 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 8db6058f242..f90e27d23d8 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -142,7 +142,7 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { */ confirmChange: function (state, id, fields, ev) { var self = this; - this.state = state; + this._setState(state); var record = this._getRecord(id); if (!record) { return this._render().then(_.constant([])); @@ -646,25 +646,25 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { return modifiersData.evaluatedModifiers[record.id]; }, /** - * Render the view - * * @override - * @returns {Promise} */ - _render: function () { - var oldAllFieldWidgets = this.allFieldWidgets; + async _render() { + const oldAllFieldWidgets = this.allFieldWidgets; this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great this.allModifiersData = []; - var oldWidgets = this.widgets; + const oldWidgets = this.widgets; this.widgets = []; - return this._renderView().then(function () { - _.each(oldAllFieldWidgets, function (recordWidgets) { - _.each(recordWidgets, function (widget) { - widget.destroy(); - }); - }); - _.invoke(oldWidgets, 'destroy'); - }); + + await this._super(...arguments); + + for (const id in oldAllFieldWidgets) { + for (const widget of oldAllFieldWidgets[id]) { + widget.destroy(); + } + } + for (const widget of oldWidgets) { + widget.destroy(); + } }, /** * Instantiates the appropriate AbstractField specialization for the given @@ -775,22 +775,6 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { .addClass('o_view_nocontent') .append($noContent); }, - /** - * Actual rendering. Supposed to be overridden by concrete renderers. - * The basic responsabilities of _renderView are: - * - use the xml arch of the view to render a jQuery representation - * - instantiate a widget from the registry for each field in the arch - * - * Note that the 'state' field should contains all necessary information - * for the rendering. The field widgets should be as synchronous as - * possible. - * - * @abstract - * @returns {Promise} - */ - _renderView: function () { - return Promise.resolve(); - }, /** * Instantiate custom widgets * diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js index be96cf25068..a76c7ce3825 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -567,11 +567,9 @@ return AbstractRenderer.extend({ /** * Render the calendar view, this is the main entry point. * - * @override method from AbstractRenderer - * @private - * @returns {Promise} + * @override */ - _render: function () { + async _renderView() { this.$('.o_calendar_view')[0].prepend(this.calendarElement); if (this._isInDOM) { this._renderCalendar(); @@ -590,9 +588,7 @@ return AbstractRenderer.extend({ $a.not('.ui-state-active').addClass('o_color'); }); - var filterProm = this._renderFilters(); - - return Promise.all([filterProm, this._super.apply(this, arguments)]); + await this._renderFilters(); }, /** * Render the specific code for the FullCalendar when it's in the DOM diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js index 1927d688c2c..2bcb833c14e 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -286,6 +286,7 @@ var FormRenderer = BasicRenderer.extend(WidgetAdapterMixin, { * @returns {Promise} */ updateState: function (state, params) { + this._setState(state); this.mode = (params && 'mode' in params) ? params.mode : this.mode; // if fieldNames are given, we update the corresponding field widget. @@ -293,7 +294,7 @@ var FormRenderer = BasicRenderer.extend(WidgetAdapterMixin, { // confirmChange method if (params.fieldNames) { // only update the given fields - return this.confirmChange(state, state.id, params.fieldNames); + return this.confirmChange(this.state, this.state.id, params.fieldNames); } return this._super.apply(this, arguments); }, diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index 4def49a6d7e..52608e7b9cd 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -75,12 +75,6 @@ return AbstractRenderer.extend({ this.$legendTooltip = null; this.$tooltip = null; }, - /** - * @override - */ - updateState: function () { - return this._super.apply(this, arguments); - }, /** * Chart.js does not need the canvas to be in dom in order * to be able to work well. We could avoid the calls to on_attach_callback @@ -754,10 +748,8 @@ return AbstractRenderer.extend({ * immediately, then we render the chart when the widget is in the DOM. * * @override - * @private - * @returns {Promise} The _super promise is actually resolved immediately */ - _render: function () { + async _renderView() { if (this.chart) { this.chart.destroy(); } @@ -796,7 +788,6 @@ return AbstractRenderer.extend({ this._renderTitle(); } - return this._super.apply(this, arguments); }, /** * create bar chart. 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 ea45c58f253..f7028d93d08 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -364,7 +364,7 @@ var KanbanController = BasicController.extend({ _onButtonsKeyDown: function (ev) { switch(ev.keyCode) { case $.ui.keyCode.DOWN: - this.$('.o_kanban_record:first').focus(); + this._giveFocus(); } }, /** 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 8152d899150..cfb511ef79b 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -98,6 +98,14 @@ var KanbanRenderer = BasicRenderer.extend({ events:_.extend({}, BasicRenderer.prototype.events || {}, { 'keydown .o_kanban_record' : '_onRecordKeyDown' }), + sampleDataTargets: [ + '.o_kanban_counter', + '.o_kanban_record', + '.o_kanban_toggle_fold', + '.o_column_folded', + '.o_column_archive_records', + '.o_column_unarchive_records', + ], /** * @override @@ -200,7 +208,7 @@ var KanbanRenderer = BasicRenderer.extend({ var column = this.widgets[index]; this.widgets[index] = newColumn; if (options && options.state) { - this.state = options.state; + this._setState(options.state); } return newColumn.appendTo(document.createDocumentFragment()).then(function () { var def; @@ -247,13 +255,6 @@ var KanbanRenderer = BasicRenderer.extend({ } return Promise.resolve(); }, - /** - * @override - */ - updateState: function (state) { - this._setState(state); - return this._super.apply(this, arguments); - }, //-------------------------------------------------------------------------- // Private @@ -271,7 +272,7 @@ var KanbanRenderer = BasicRenderer.extend({ }, /** * Tries to give focus to the previous card, and returns true if successful - * + * * @private * @param {DOMElement} currentColumn * @returns {boolean} @@ -498,16 +499,15 @@ var KanbanRenderer = BasicRenderer.extend({ /** * Sets the current state and updates some internal attributes accordingly. * - * @private - * @param {Object} state + * @override */ - _setState: function (state) { - this.state = state; + _setState: function () { + this._super(...arguments); - var groupByField = state.groupedBy[0]; + var groupByField = this.state.groupedBy[0]; var cleanGroupByField = this._cleanGroupByField(groupByField); - var groupByFieldAttrs = state.fields[cleanGroupByField]; - var groupByFieldInfo = state.fieldsInfo.kanban[cleanGroupByField]; + var groupByFieldAttrs = this.state.fields[cleanGroupByField]; + var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField]; // Deactivate the drag'n'drop if the groupedBy field: // - is a date or datetime since we group by month or // - is readonly (on the field attrs or in the view) @@ -536,7 +536,7 @@ var KanbanRenderer = BasicRenderer.extend({ grouped_by_m2o: this.groupedByM2O, grouped_by_date: grouped_by_date, relation: relation, - quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state), + quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state), }); this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable; }, 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 51f95e5a9ff..b786673ec7c 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -246,10 +246,14 @@ var ListController = BasicController.extend({ break; case $.ui.keyCode.DOWN: e.preventDefault(); - self.renderer.giveFocus(); + self._giveFocus(); break; case $.ui.keyCode.TAB: - if (!e.shiftKey && e.target.classList.contains("btn-primary")) { + if ( + !e.shiftKey && + e.target.classList.contains("btn-primary") && + !self.model.isInSampleMode() + ) { e.preventDefault(); $createButton.tooltip('show'); } @@ -268,7 +272,7 @@ var ListController = BasicController.extend({ */ _confirmSave: function (id) { var state = this.model.get(this.handle); - return this.renderer.updateState(state, {noRender: true}) + return this._updateRendererState(state, { noRender: true }) .then(this._setMode.bind(this, 'readonly', id)); }, /** @@ -445,7 +449,7 @@ var ListController = BasicController.extend({ // and the list was multi-editable, we do not want to select // the next row. this.selectedRecords = []; - await this.renderer.updateState(state, { + await this._updateRendererState(state, { keepWidths: true, selectedRecords: [], }); diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 278a5f2e896..818be2f350a 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -223,7 +223,7 @@ ListRenderer.include({ var self = this; var oldData = this.state.data; - this.state = state; + this._setState(state); return this.confirmChange(state, id, fields, ev).then(function () { // If no record with 'id' can be found in the state, the // confirmChange method will have rerendered the whole view already, @@ -369,7 +369,7 @@ ListRenderer.include({ * @param {string} recordID */ removeLine: function (state, recordID) { - this.state = state; + this._setState(state); var $row = this._getRow(recordID); if ($row.length === 0) { return; 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 428a781319f..b35c42810a8 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -50,6 +50,12 @@ var ListRenderer = BasicRenderer.extend({ 'keydown td': '_onKeyDown', 'keydown th': '_onKeyDown', }, + sampleDataTargets: [ + '.o_data_row', + '.o_group_header', + '.o_list_table > tfoot', + '.o_list_table > thead .o_list_record_selector', + ], /** * @constructor * @param {Widget} parent @@ -90,8 +96,8 @@ var ListRenderer = BasicRenderer.extend({ /** * Order to focus to be given to the content of the current view + * * @override - * @public */ giveFocus: function () { this.$('th:eq(0) input, th:eq(1)').first().focus(); @@ -100,7 +106,8 @@ var ListRenderer = BasicRenderer.extend({ * @override */ updateState: function (state, params) { - this.isGrouped = state.groupedBy.length > 0; + this._setState(state); + this.isGrouped = this.state.groupedBy.length > 0; this._processColumns(params.columnInvisibleFields || {}); if (params.selectedRecords) { this.selection = params.selectedRecords; @@ -1231,7 +1238,7 @@ var ListRenderer = BasicRenderer.extend({ optionalColumnsEnabled: this.optionalColumnsEnabled, }); this._processColumns(this.columnInvisibleFields || {}); - this._renderView().then(function () { + this._render().then(function () { self._onToggleOptionalColumnDropdown(ev); }); }, @@ -1263,6 +1270,9 @@ var ListRenderer = BasicRenderer.extend({ var $tr; var $futureCell; var colIndex; + if (this.state.isSample) { + return; // we disable keyboard navigation inside the table in "sample" mode + } switch (ev.keyCode) { case $.ui.keyCode.LEFT: ev.preventDefault(); @@ -1345,7 +1355,7 @@ var ListRenderer = BasicRenderer.extend({ offset: currentMinimum - 1, on_success: reloadedGroup => { Object.assign(group, reloadedGroup); - this._renderView(); + this._render(); }, }); }, diff --git a/addons/web/static/src/scss/views.scss b/addons/web/static/src/scss/views.scss index 584ec66faed..55defa589e7 100644 --- a/addons/web/static/src/scss/views.scss +++ b/addons/web/static/src/scss/views.scss @@ -73,37 +73,25 @@ } } -// 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_list_table { + cursor: default !important; + + & > thead .o_list_record_selector { + pointer-events: none; } } + .o_nocontent_help { border-radius: 100%; background-color: #f9f9f9; box-shadow: 0 0 120px 100px #f9f9f9; } + + .o_sample_data_disabled { + opacity: 0.33; + pointer-events: none; + user-select: none; + } } diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index 7c45c51a175..9035fce355b 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -783,7 +783,7 @@ QUnit.module('Views', { '' + '', }); - graph.renderer.giveFocus(); + graph._giveFocus(); assert.ok(true,"should not generate any error"); graph.destroy(); }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index ec9bc62d6fc..0c4dbdab889 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -4196,6 +4196,53 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('empty grouped kanban with sample data: keyboard navigation', async function (assert) { + assert.expect(5); + + 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') { + result.groups.forEach(g => g.product_id_count = 0); + } + return result; + }, + }); + + // Check keynav is disabled + assert.hasClass( + kanban.el.querySelector('.o_kanban_record'), + 'o_sample_data_disabled' + ); + assert.hasClass( + kanban.el.querySelector('.o_kanban_toggle_fold'), + 'o_sample_data_disabled' + ); + assert.containsNone(kanban.renderer, '[tabindex]:not([tabindex="-1"])'); + + assert.hasClass(document.activeElement, 'o_searchview_input'); + + await testUtils.fields.triggerKeydown(document.activeElement, 'down'); + + assert.hasClass(document.activeElement, 'o_searchview_input'); + + kanban.destroy(); + }); + QUnit.test('empty kanban with sample data', async function (assert) { assert.expect(6); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 3d49b2079cc..703218a071a 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -3843,6 +3843,108 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test("empty list with sample data: toggle optional field", async function (assert) { + assert.expect(9); + + const RamStorageService = AbstractStorageService.extend({ + storage: new RamStorage(), + }); + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: ` + + + + `, + domain: Domain.FALSE_DOMAIN, + services: { + local_storage: RamStorageService, + }, + }); + + assert.hasClass(list.$el, 'o_view_sample_data'); + assert.ok(list.$('.o_data_row').length > 0); + assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled'); + assert.containsN(list, 'th', 2, "should have 2 th, 1 for selector and 1 for foo"); + assert.containsOnce(list.$('table'), '.o_optional_columns_dropdown_toggle'); + + await testUtils.dom.click(list.$('table .o_optional_columns_dropdown_toggle')); + await testUtils.dom.click(list.$('div.o_optional_columns div.dropdown-item:first input')); + + assert.hasClass(list.$el, 'o_view_sample_data'); + assert.ok(list.$('.o_data_row').length > 0); + assert.hasClass(list.el.querySelector('.o_data_row'), 'o_sample_data_disabled'); + assert.containsN(list, 'th', 3); + + list.destroy(); + }); + + QUnit.test("empty list with sample data: keyboard navigation", async function (assert) { + assert.expect(11); + + const list = await createView({ + arch: ` + + + + + `, + data: this.data, + domain: Domain.FALSE_DOMAIN, + model: 'foo', + View: ListView, + }); + + // Check keynav is disabled + assert.hasClass( + list.el.querySelector('.o_data_row'), + 'o_sample_data_disabled' + ); + assert.hasClass( + list.el.querySelector('.o_list_table > tfoot'), + 'o_sample_data_disabled' + ); + assert.hasClass( + list.el.querySelector('.o_list_table > thead .o_list_record_selector'), + 'o_sample_data_disabled' + ); + assert.containsNone(list.renderer, 'input:not([tabindex="-1"])'); + + // From search bar + assert.hasClass(document.activeElement, 'o_searchview_input'); + + await testUtils.fields.triggerKeydown(document.activeElement, 'down'); + + assert.hasClass(document.activeElement, 'o_searchview_input'); + + // From 'Create' button + document.querySelector('.btn.o_list_button_add').focus(); + + assert.hasClass(document.activeElement, 'o_list_button_add'); + + await testUtils.fields.triggerKeydown(document.activeElement, 'down'); + + assert.hasClass(document.activeElement, 'o_list_button_add'); + + await testUtils.fields.triggerKeydown(document.activeElement, 'tab'); + + assert.containsNone(document.body, '.oe_tooltip_string'); + + // From column header + list.el.querySelector(':scope th[data-name="foo"]').focus(); + + assert.ok(document.activeElement.dataset.name === 'foo'); + + await testUtils.fields.triggerKeydown(document.activeElement, 'down'); + + assert.ok(document.activeElement.dataset.name === 'foo'); + + list.destroy(); + }); + QUnit.test("non empty list with sample data", async function (assert) { assert.expect(6); From c1b4ad7c6d7044de41af8cf106374c86e1b97eef Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 19 Jun 2020 07:06:34 +0000 Subject: [PATCH 5/7] [IMP] purchase: enable sample data in dashboard For the sample data feature to work, rpcs done by the view need to be mocked. This commit properly mocks the 'retrieve_dashboard' function. It also remove custom no content helper style that was conflicting with the new style in web. Task 2232801 X-original-commit: 300bc6d9ac2c1bea823bfc80675ffcf8e5dd3e9f --- .../purchase/static/src/js/purchase_dashboard.js | 15 +++++++++++++-- addons/purchase/static/src/scss/purchase.scss | 6 ------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/addons/purchase/static/src/js/purchase_dashboard.js b/addons/purchase/static/src/js/purchase_dashboard.js index 70075389f9b..7eb0a3aacd3 100644 --- a/addons/purchase/static/src/js/purchase_dashboard.js +++ b/addons/purchase/static/src/js/purchase_dashboard.js @@ -18,10 +18,21 @@ var KanbanController = require('web.KanbanController'); var KanbanModel = require('web.KanbanModel'); var KanbanRenderer = require('web.KanbanRenderer'); var KanbanView = require('web.KanbanView'); +var SampleServer = require('web.SampleServer'); var view_registry = require('web.view_registry'); var QWeb = core.qweb; +// Add mock of method 'retrieve_dashboard' in SampleServer, so that we can have +// the sample data in empty purchase kanban and list view +let dashboardValues; +SampleServer.mockRegistry.add('purchase.order', { + retrieve_dashboard: () => { + return Object.assign({}, dashboardValues); + }, +}); + + //-------------------------------------------------------------------------- // List View //-------------------------------------------------------------------------- @@ -107,7 +118,7 @@ var PurchaseListDashboardModel = ListModel.extend({ }); return Promise.all([super_def, dashboard_def]).then(function(results) { var id = results[0]; - var dashboardValues = results[1]; + dashboardValues = results[1]; self.dashboardValues[id] = dashboardValues; return id; }); @@ -222,7 +233,7 @@ var PurchaseKanbanDashboardModel = KanbanModel.extend({ }); return Promise.all([super_def, dashboard_def]).then(function(results) { var id = results[0]; - var dashboardValues = results[1]; + dashboardValues = results[1]; self.dashboardValues[id] = dashboardValues; return id; }); diff --git a/addons/purchase/static/src/scss/purchase.scss b/addons/purchase/static/src/scss/purchase.scss index 003bb0a488e..1c6644c4667 100644 --- a/addons/purchase/static/src/scss/purchase.scss +++ b/addons/purchase/static/src/scss/purchase.scss @@ -52,9 +52,3 @@ } } } - -.o_purchase_dashboard ~ .o_view_nocontent { - // To prevent overlapping from inherited values - position: relative; - width: 100%; -} From 8abbc5a3b573c87120f0147f4913292e7cb12aa8 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Fri, 19 Jun 2020 07:06:35 +0000 Subject: [PATCH 6/7] [REF] *: adapt code to web changes Some changes in AbstractModel have been done to ease the implementation of the sample data feature. This commit reverberates those changes in other addons. Part of task 2232801 X-original-commit: dd8069a3c80570e7fa03a80dbb100ab0a182692a --- .../src/js/im_livechat_channel_form_controller.js | 2 +- addons/lunch/static/src/js/lunch_model.js | 4 ++-- .../static/src/js/views/activity/activity_model.js | 6 +++--- addons/purchase/static/src/js/purchase_dashboard.js | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js b/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js index 2361910aebf..9819e8cb96d 100644 --- a/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js +++ b/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js @@ -22,7 +22,7 @@ const ImLivechatChannelFormController = FormController.extend({ this.$(`[name="${name}"] .o_field_color`).css('background-color', colorValues[name]); } const result = await this.model.notifyChanges(this.handle, colorValues); - this.renderer.updateState(this.model.get(this.handle), { fieldNames: result }); + this._updateRendererState(this.model.get(this.handle), { fieldNames: result }); }, //-------------------------------------------------------------------------- diff --git a/addons/lunch/static/src/js/lunch_model.js b/addons/lunch/static/src/js/lunch_model.js index cd99b6ff834..720b43565ec 100644 --- a/addons/lunch/static/src/js/lunch_model.js +++ b/addons/lunch/static/src/js/lunch_model.js @@ -31,7 +31,7 @@ var LunchModel = BasicModel.extend({ return self._buildLocationDomainLeaf() ? [self._buildLocationDomainLeaf()]: []; }); }, - load: function () { + __load: function () { var self = this; var args = arguments; var _super = this._super; @@ -43,7 +43,7 @@ var LunchModel = BasicModel.extend({ return _super.apply(self, args); }); }, - reload: function (id, options) { + __reload: function (id, options) { var domain = options && options.domain || this.localData[id].domain; this._addOrUpdate(domain, this._buildLocationDomainLeaf()); diff --git a/addons/mail/static/src/js/views/activity/activity_model.js b/addons/mail/static/src/js/views/activity/activity_model.js index 7099bf4a44a..cfb9e36a121 100644 --- a/addons/mail/static/src/js/views/activity/activity_model.js +++ b/addons/mail/static/src/js/views/activity/activity_model.js @@ -18,7 +18,7 @@ const ActivityModel = BasicModel.extend({ * * @override */ - get: function () { + __get: function () { var result = this._super.apply(this, arguments); if (result && result.model === this.modelName && result.type === 'list') { _.extend(result, this.additionalData, {getKanbanActivityData: this.getKanbanActivityData}); @@ -63,7 +63,7 @@ const ActivityModel = BasicModel.extend({ * @override * @param {Array[]} params.domain */ - load: function (params) { + __load: function (params) { this.originalDomain = _.extend([], params.domain); params.domain.push(['activity_ids', '!=', false]); this.domain = params.domain; @@ -78,7 +78,7 @@ const ActivityModel = BasicModel.extend({ * @override * @param {Array[]} [params.domain] */ - reload: function (handle, params) { + __reload: function (handle, params) { if (params && 'domain' in params) { this.originalDomain = _.extend([], params.domain); params.domain.push(['activity_ids', '!=', false]); diff --git a/addons/purchase/static/src/js/purchase_dashboard.js b/addons/purchase/static/src/js/purchase_dashboard.js index 7eb0a3aacd3..d4db21a80ab 100644 --- a/addons/purchase/static/src/js/purchase_dashboard.js +++ b/addons/purchase/static/src/js/purchase_dashboard.js @@ -83,7 +83,7 @@ var PurchaseListDashboardModel = ListModel.extend({ /** * @override */ - get: function (localID) { + __get: function (localID) { var result = this._super.apply(this, arguments); if (_.isObject(result)) { result.dashboardValues = this.dashboardValues[localID]; @@ -94,14 +94,14 @@ var PurchaseListDashboardModel = ListModel.extend({ * @override * @returns {Promise} */ - load: function () { + __load: function () { return this._loadDashboard(this._super.apply(this, arguments)); }, /** * @override * @returns {Promise} */ - reload: function () { + __reload: function () { return this._loadDashboard(this._super.apply(this, arguments)); }, @@ -198,7 +198,7 @@ var PurchaseKanbanDashboardModel = KanbanModel.extend({ /** * @override */ - get: function (localID) { + __get: function (localID) { var result = this._super.apply(this, arguments); if (_.isObject(result)) { result.dashboardValues = this.dashboardValues[localID]; @@ -209,14 +209,14 @@ var PurchaseKanbanDashboardModel = KanbanModel.extend({ * @override * @returns {Promise} */ - load: function () { + __load: function () { return this._loadDashboard(this._super.apply(this, arguments)); }, /** * @override * @returns {Promise} */ - reload: function () { + __reload: function () { return this._loadDashboard(this._super.apply(this, arguments)); }, From 9389f7ce2ea16121a74535557fa4cae0577bb011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Hennecart?= Date: Fri, 19 Jun 2020 07:06:37 +0000 Subject: [PATCH 7/7] [IMP] *: enable sample data in several views This commit sets attribute sample="1" in a bunch of list and kanban views, to enable the new sample data feature when views are empty. Task 2232801 X-original-commit: 5471309b35619f307242c2704c7681ba63d2be42 --- addons/account/views/account_move_views.xml | 9 +++++---- addons/account/views/account_payment_view.xml | 4 ++-- addons/account/views/product_view.xml | 2 +- addons/coupon/views/coupon_program_views.xml | 4 ++-- addons/crm/views/crm_lead_views.xml | 8 ++++---- addons/event/views/event_views.xml | 9 +++++---- addons/fleet/views/fleet_vehicle_cost_views.xml | 8 ++++++-- addons/fleet/views/fleet_vehicle_views.xml | 7 +++++-- addons/hr/views/hr_department_views.xml | 4 ++-- addons/hr/views/hr_employee_public_views.xml | 4 ++-- addons/hr/views/hr_employee_views.xml | 4 ++-- addons/hr/views/hr_job_views.xml | 4 ++-- addons/hr_attendance/views/hr_attendance_view.xml | 4 ++-- addons/hr_contract/views/hr_contract_views.xml | 4 ++-- addons/hr_expense/views/hr_expense_views.xml | 8 ++++---- .../hr_holidays/views/hr_leave_allocation_views.xml | 4 ++-- addons/hr_holidays/views/hr_leave_views.xml | 4 ++-- addons/hr_recruitment/views/hr_job_views.xml | 2 +- .../hr_recruitment/views/hr_recruitment_views.xml | 4 ++-- addons/hr_work_entry/views/hr_work_entry_views.xml | 2 +- addons/link_tracker/views/link_tracker_views.xml | 2 +- addons/mail/views/mail_activity_views.xml | 2 +- addons/maintenance/views/maintenance_views.xml | 8 ++++---- addons/mass_mailing/views/mailing_contact_views.xml | 4 ++-- addons/mass_mailing/views/mailing_list_views.xml | 4 ++-- addons/mass_mailing/views/mailing_mailing_views.xml | 4 ++-- .../views/mailing_mailing_views.xml | 2 +- addons/mrp/views/mrp_bom_views.xml | 4 ++-- addons/mrp/views/mrp_production_views.xml | 4 ++-- addons/mrp/views/mrp_unbuild_views.xml | 4 ++-- addons/point_of_sale/views/pos_order_view.xml | 4 ++-- addons/point_of_sale/views/pos_session_view.xml | 4 ++-- addons/product/views/product_pricelist_views.xml | 4 ++-- addons/product/views/product_template_views.xml | 4 ++-- addons/product/views/product_views.xml | 4 ++-- addons/project/views/project_views.xml | 12 ++++++------ addons/purchase/views/purchase_views.xml | 13 +++++++++---- .../views/purchase_requisition_views.xml | 4 ++-- addons/rating/views/rating_rating_views.xml | 2 +- addons/repair/views/repair_views.xml | 4 ++-- addons/sale/views/sale_views.xml | 6 +++--- addons/sales_team/views/crm_team_views.xml | 6 +++--- addons/stock/views/stock_inventory_views.xml | 4 ++-- addons/stock/views/stock_orderpoint_views.xml | 2 +- addons/stock/views/stock_picking_views.xml | 4 ++-- addons/stock/views/stock_quant_views.xml | 3 ++- addons/stock/views/stock_scrap_views.xml | 4 ++-- addons/survey/views/survey_survey_views.xml | 4 ++-- addons/utm/views/utm_campaign_views.xml | 4 ++-- addons/website/views/website_visitor_views.xml | 6 +++--- addons/website_blog/views/website_blog_views.xml | 4 ++-- addons/website_slides/views/slide_channel_views.xml | 6 +++--- addons/website_slides/views/slide_slide_views.xml | 5 +++-- odoo/addons/base/rng/tree_view.rng | 1 + odoo/addons/base/views/res_partner_views.xml | 4 ++-- 55 files changed, 136 insertions(+), 119 deletions(-) diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index ae9fead3ac6..d0325e46b84 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -136,7 +136,7 @@ account.move.line - + @@ -380,7 +380,7 @@ account.move.tree account.move - + @@ -400,7 +400,8 @@ + js_class="account_tree" + sample="1"> @@ -432,7 +433,7 @@ account.move.kanban account.move - + diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml index cf335bb0fdc..04bde22700f 100644 --- a/addons/account/views/account_payment_view.xml +++ b/addons/account/views/account_payment_view.xml @@ -8,7 +8,7 @@ account.payment.tree account.payment - + @@ -38,7 +38,7 @@ account.payment.kanban account.payment - + diff --git a/addons/account/views/product_view.xml b/addons/account/views/product_view.xml index b89cfbe5721..db4a4aa7946 100644 --- a/addons/account/views/product_view.xml +++ b/addons/account/views/product_view.xml @@ -6,7 +6,7 @@ product.template.tree product.template - + diff --git a/addons/coupon/views/coupon_program_views.xml b/addons/coupon/views/coupon_program_views.xml index f9fa25943a3..d808e8329c8 100644 --- a/addons/coupon/views/coupon_program_views.xml +++ b/addons/coupon/views/coupon_program_views.xml @@ -107,7 +107,7 @@ coupon.program.tree coupon.program - + @@ -133,7 +133,7 @@ coupon.program.kanban coupon.program - + diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 647556ad98b..3ccf679c4b1 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -309,7 +309,7 @@ crm.lead 10 - + @@ -342,7 +342,7 @@ crm.lead - + @@ -455,7 +455,7 @@ + archivable="false" sample="1"> @@ -599,7 +599,7 @@ crm.lead 1 - + diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index ae5f66b95e5..c5851b26765 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -285,7 +285,8 @@ + multi_edit="1" + sample="1"> @@ -324,7 +325,7 @@ event.event.kanban event.event - + @@ -498,7 +499,7 @@ event.registration.tree event.registration - + @@ -575,7 +576,7 @@ event.registration 10 - + diff --git a/addons/fleet/views/fleet_vehicle_cost_views.xml b/addons/fleet/views/fleet_vehicle_cost_views.xml index 929439b6abe..8bb9fface37 100644 --- a/addons/fleet/views/fleet_vehicle_cost_views.xml +++ b/addons/fleet/views/fleet_vehicle_cost_views.xml @@ -66,7 +66,11 @@ fleet.vehicle.log.contract.tree fleet.vehicle.log.contract - + @@ -87,7 +91,7 @@ fleet.vehicle.log.contract.kanban fleet.vehicle.log.contract - + diff --git a/addons/fleet/views/fleet_vehicle_views.xml b/addons/fleet/views/fleet_vehicle_views.xml index c0765013e87..51b8904fd0c 100644 --- a/addons/fleet/views/fleet_vehicle_views.xml +++ b/addons/fleet/views/fleet_vehicle_views.xml @@ -131,7 +131,10 @@ fleet.vehicle.tree fleet.vehicle - + @@ -192,7 +195,7 @@ fleet.vehicle.kanban fleet.vehicle - + diff --git a/addons/hr/views/hr_department_views.xml b/addons/hr/views/hr_department_views.xml index 480dd06f263..f9c7b891e4d 100644 --- a/addons/hr/views/hr_department_views.xml +++ b/addons/hr/views/hr_department_views.xml @@ -28,7 +28,7 @@ hr.department.tree hr.department - + @@ -55,7 +55,7 @@ hr.department.kanban hr.department - + diff --git a/addons/hr/views/hr_employee_public_views.xml b/addons/hr/views/hr_employee_public_views.xml index 4d8d9517a63..108ff2c685d 100644 --- a/addons/hr/views/hr_employee_public_views.xml +++ b/addons/hr/views/hr_employee_public_views.xml @@ -92,7 +92,7 @@ hr.employee.tree hr.employee.public - + @@ -110,7 +110,7 @@ hr.employee.public 10 - + diff --git a/addons/hr/views/hr_employee_views.xml b/addons/hr/views/hr_employee_views.xml index 3f2d93c3d5a..5082a5dc424 100644 --- a/addons/hr/views/hr_employee_views.xml +++ b/addons/hr/views/hr_employee_views.xml @@ -201,7 +201,7 @@ hr.employee.tree hr.employee - + @@ -220,7 +220,7 @@ hr.employee 10 - + diff --git a/addons/hr/views/hr_job_views.xml b/addons/hr/views/hr_job_views.xml index 46c3a31c974..3e0a6170e5d 100644 --- a/addons/hr/views/hr_job_views.xml +++ b/addons/hr/views/hr_job_views.xml @@ -43,7 +43,7 @@ hr.job.tree hr.job - + @@ -61,7 +61,7 @@ hr.job.kanban hr.job - +
diff --git a/addons/hr_attendance/views/hr_attendance_view.xml b/addons/hr_attendance/views/hr_attendance_view.xml index c6f7b75e99c..adf2ca3e4cd 100644 --- a/addons/hr_attendance/views/hr_attendance_view.xml +++ b/addons/hr_attendance/views/hr_attendance_view.xml @@ -7,7 +7,7 @@ hr.attendance.tree hr.attendance - + @@ -20,7 +20,7 @@ hr.attendance.kanban hr.attendance - + diff --git a/addons/hr_contract/views/hr_contract_views.xml b/addons/hr_contract/views/hr_contract_views.xml index 63a53b6553e..6fc0d4ae5c3 100644 --- a/addons/hr_contract/views/hr_contract_views.xml +++ b/addons/hr_contract/views/hr_contract_views.xml @@ -226,7 +226,7 @@ hr.contract.tree hr.contract - + @@ -246,7 +246,7 @@ hr.contract.kanban hr.contract - + diff --git a/addons/hr_expense/views/hr_expense_views.xml b/addons/hr_expense/views/hr_expense_views.xml index a0a491a63d5..2ef7df2cf51 100644 --- a/addons/hr_expense/views/hr_expense_views.xml +++ b/addons/hr_expense/views/hr_expense_views.xml @@ -38,7 +38,7 @@ hr.expense.tree hr.expense - + @@ -160,7 +160,7 @@ hr.expense.kanban hr.expense - + @@ -441,7 +441,7 @@ hr.expense.sheet.tree hr.expense.sheet - + @@ -571,7 +571,7 @@ hr.expense.sheet.kanban hr.expense.sheet - + diff --git a/addons/hr_holidays/views/hr_leave_allocation_views.xml b/addons/hr_holidays/views/hr_leave_allocation_views.xml index a046ae9e41f..cf34ca2a195 100644 --- a/addons/hr_holidays/views/hr_leave_allocation_views.xml +++ b/addons/hr_holidays/views/hr_leave_allocation_views.xml @@ -188,7 +188,7 @@ hr.leave.allocation 16 - + @@ -242,7 +242,7 @@ hr.leave.allocation.view.kanban hr.leave.allocation - + diff --git a/addons/hr_holidays/views/hr_leave_views.xml b/addons/hr_holidays/views/hr_leave_views.xml index 24fb22f6acb..924587e705d 100644 --- a/addons/hr_holidays/views/hr_leave_views.xml +++ b/addons/hr_holidays/views/hr_leave_views.xml @@ -95,7 +95,7 @@ hr.leave.view.kanban hr.leave - + @@ -484,7 +484,7 @@ hr.holidays.view.tree hr.leave - + diff --git a/addons/hr_recruitment/views/hr_job_views.xml b/addons/hr_recruitment/views/hr_job_views.xml index 9f874cf8eab..3b6c6dbcc4c 100644 --- a/addons/hr_recruitment/views/hr_job_views.xml +++ b/addons/hr_recruitment/views/hr_job_views.xml @@ -11,7 +11,7 @@ hr.job.kanban hr.job - + diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml index 3fd0baab43d..3f2313ba7a3 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -24,7 +24,7 @@ Applicants hr.applicant - + @@ -266,7 +266,7 @@ Hr Applicants kanban hr.applicant - + diff --git a/addons/hr_work_entry/views/hr_work_entry_views.xml b/addons/hr_work_entry/views/hr_work_entry_views.xml index 74ee454589a..79b824b605a 100644 --- a/addons/hr_work_entry/views/hr_work_entry_views.xml +++ b/addons/hr_work_entry/views/hr_work_entry_views.xml @@ -72,7 +72,7 @@ hr.work.entry.tree hr.work.entry - + diff --git a/addons/link_tracker/views/link_tracker_views.xml b/addons/link_tracker/views/link_tracker_views.xml index f4d1273d5de..1dfc27422f6 100644 --- a/addons/link_tracker/views/link_tracker_views.xml +++ b/addons/link_tracker/views/link_tracker_views.xml @@ -58,7 +58,7 @@ link.tracker.view.tree link.tracker - + diff --git a/addons/mail/views/mail_activity_views.xml b/addons/mail/views/mail_activity_views.xml index 337455bae57..500750b6c72 100644 --- a/addons/mail/views/mail_activity_views.xml +++ b/addons/mail/views/mail_activity_views.xml @@ -66,7 +66,7 @@ mail.activity.type.view.tree mail.activity.type - + diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 90fcd57a2ed..014909d78b5 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -114,7 +114,7 @@ equipment.request.kanban maintenance.request - + @@ -180,7 +180,7 @@ equipment.request.tree maintenance.request - + @@ -411,7 +411,7 @@ equipment.kanban maintenance.equipment - + @@ -484,7 +484,7 @@ equipment.tree maintenance.equipment - + diff --git a/addons/mass_mailing/views/mailing_contact_views.xml b/addons/mass_mailing/views/mailing_contact_views.xml index 7ddd18e1b2b..a117b5b035f 100644 --- a/addons/mass_mailing/views/mailing_contact_views.xml +++ b/addons/mass_mailing/views/mailing_contact_views.xml @@ -89,7 +89,7 @@ mailing.contact 10 - + @@ -105,7 +105,7 @@ mailing.contact.view.kanban mailing.contact - + diff --git a/addons/mass_mailing/views/mailing_list_views.xml b/addons/mass_mailing/views/mailing_list_views.xml index 188ed8b900f..f581669c36a 100644 --- a/addons/mass_mailing/views/mailing_list_views.xml +++ b/addons/mass_mailing/views/mailing_list_views.xml @@ -22,7 +22,7 @@ mailing.list 10 - + @@ -97,7 +97,7 @@ mailing.list.view.kanban mailing.list - + diff --git a/addons/mass_mailing/views/mailing_mailing_views.xml b/addons/mass_mailing/views/mailing_mailing_views.xml index be77f42a5c2..74e2ab3375e 100644 --- a/addons/mass_mailing/views/mailing_mailing_views.xml +++ b/addons/mass_mailing/views/mailing_mailing_views.xml @@ -30,7 +30,7 @@ mailing.mailing 10 - + @@ -291,7 +291,7 @@ mailing.mailing.kanban mailing.mailing - + diff --git a/addons/mass_mailing_sms/views/mailing_mailing_views.xml b/addons/mass_mailing_sms/views/mailing_mailing_views.xml index e70482a379e..9b6244853ab 100644 --- a/addons/mass_mailing_sms/views/mailing_mailing_views.xml +++ b/addons/mass_mailing_sms/views/mailing_mailing_views.xml @@ -185,7 +185,7 @@ mailing.mailing 20 - + diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index 89410353ba6..ba5e37a6197 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -145,7 +145,7 @@ mrp.bom.tree mrp.bom - + @@ -163,7 +163,7 @@ mrp.bom.kanban mrp.bom - + diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 99c8d5e9ce5..270a0458650 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -6,7 +6,7 @@ mrp.production.tree mrp.production - + mrp.production.kanban mrp.production - + diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml index 9284899f4c2..7922478eb25 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -48,7 +48,7 @@ mrp.unbuild.kanban mrp.unbuild - + @@ -174,7 +174,7 @@ mrp.unbuild.tree mrp.unbuild - + diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml index 14c9f1b48f4..fd4ad92c74c 100644 --- a/addons/point_of_sale/views/pos_order_view.xml +++ b/addons/point_of_sale/views/pos_order_view.xml @@ -132,7 +132,7 @@ pos.order.kanban pos.order - + @@ -214,7 +214,7 @@ pos.order.tree pos.order - + diff --git a/addons/point_of_sale/views/pos_session_view.xml b/addons/point_of_sale/views/pos_session_view.xml index 56cf32de468..7a58a880794 100644 --- a/addons/point_of_sale/views/pos_session_view.xml +++ b/addons/point_of_sale/views/pos_session_view.xml @@ -135,7 +135,7 @@ pos.session.tree.view pos.session - + @@ -150,7 +150,7 @@ pos.session.kanban pos.session - + diff --git a/addons/product/views/product_pricelist_views.xml b/addons/product/views/product_pricelist_views.xml index 715122331a3..b19577ef475 100644 --- a/addons/product/views/product_pricelist_views.xml +++ b/addons/product/views/product_pricelist_views.xml @@ -186,7 +186,7 @@ product.pricelist.tree product.pricelist - + @@ -200,7 +200,7 @@ product.pricelist.kanban product.pricelist - +
diff --git a/addons/product/views/product_template_views.xml b/addons/product/views/product_template_views.xml index b5e3df764df..06a598c6661 100644 --- a/addons/product/views/product_template_views.xml +++ b/addons/product/views/product_template_views.xml @@ -4,7 +4,7 @@ product.template.product.tree product.template - + @@ -70,7 +70,7 @@ Product.template.product.kanban product.template - + diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index efce0ef8e55..064eb89a681 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -297,7 +297,7 @@ product.product - + @@ -354,7 +354,7 @@ Product Kanban product.product - + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 3bc731f503f..4e5dfa8ed5a 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -158,7 +158,7 @@ project.task.type.tree project.task.type - + @@ -175,7 +175,7 @@ project.task.type.kanban project.task.type - + @@ -390,7 +390,7 @@ project.project.tree project.project - + @@ -487,7 +487,7 @@ project.project.kanban project.project - + @@ -833,7 +833,7 @@ project.task - + @@ -1040,7 +1040,7 @@ Tags project.tags - + diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 4e0c8dc61cf..613ddda8d41 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -465,7 +465,7 @@ purchase.order.kanban purchase.order - + @@ -505,7 +505,7 @@ + decoration-muted="state=='cancel'" decoration-info="state in ('wait','confirmed')" sample="1"> @@ -535,7 +535,7 @@ + class="o_purchase_order" js_class="purchase_list_dashboard" sample="1"> @@ -563,7 +563,12 @@ purchase.order.view.tree purchase.order - + diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml index 0cff42364f1..88faddf3f4a 100644 --- a/addons/purchase_requisition/views/purchase_requisition_views.xml +++ b/addons/purchase_requisition/views/purchase_requisition_views.xml @@ -193,7 +193,7 @@ purchase.requisition.tree purchase.requisition - + @@ -212,7 +212,7 @@ purchase.requisition.kanban purchase.requisition - + diff --git a/addons/rating/views/rating_rating_views.xml b/addons/rating/views/rating_rating_views.xml index 6d5d0076ba5..66cedd91aa6 100644 --- a/addons/rating/views/rating_rating_views.xml +++ b/addons/rating/views/rating_rating_views.xml @@ -5,7 +5,7 @@ rating.rating.tree rating.rating - + diff --git a/addons/repair/views/repair_views.xml b/addons/repair/views/repair_views.xml index faf07fe3ba1..12db352ad50 100644 --- a/addons/repair/views/repair_views.xml +++ b/addons/repair/views/repair_views.xml @@ -6,7 +6,7 @@ repair.tree repair.order - + @@ -208,7 +208,7 @@ repair.kanban repair.order - + diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index d7e6cb9ebe9..ae960d2fe9c 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -156,7 +156,7 @@ sale.order.kanban sale.order - + @@ -195,7 +195,7 @@ sale.order 2 - + @@ -222,7 +222,7 @@ sale.order 4 - + diff --git a/addons/sales_team/views/crm_team_views.xml b/addons/sales_team/views/crm_team_views.xml index 112c701434b..fcb6e561329 100644 --- a/addons/sales_team/views/crm_team_views.xml +++ b/addons/sales_team/views/crm_team_views.xml @@ -77,7 +77,7 @@ crm.team child_ids - + @@ -91,7 +91,7 @@ crm.team.kanban crm.team - +
@@ -115,7 +115,7 @@ crm.team.dashboard crm.team - + diff --git a/addons/stock/views/stock_inventory_views.xml b/addons/stock/views/stock_inventory_views.xml index 7039fc1c297..55a6dd83d4b 100644 --- a/addons/stock/views/stock_inventory_views.xml +++ b/addons/stock/views/stock_inventory_views.xml @@ -116,7 +116,7 @@ stock.inventory.tree stock.inventory - + @@ -131,7 +131,7 @@ stock.inventory.kanban stock.inventory - + diff --git a/addons/stock/views/stock_orderpoint_views.xml b/addons/stock/views/stock_orderpoint_views.xml index 71444f8154f..5eca47fe524 100644 --- a/addons/stock/views/stock_orderpoint_views.xml +++ b/addons/stock/views/stock_orderpoint_views.xml @@ -38,7 +38,7 @@ stock.warehouse.orderpoint.tree.editable stock.warehouse.orderpoint - + diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index cf17f3ea7d2..65f0ffc92ef 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -148,7 +148,7 @@ stock.picking.kanban stock.picking - + @@ -189,7 +189,7 @@ stock.picking.tree stock.picking - + diff --git a/addons/stock/views/stock_quant_views.xml b/addons/stock/views/stock_quant_views.xml index 2a646bf72bf..b115308c9be 100644 --- a/addons/stock/views/stock_quant_views.xml +++ b/addons/stock/views/stock_quant_views.xml @@ -78,7 +78,8 @@ + create="1" edit="1" import="0" js_class="singleton_list" + sample="1"> stock.scrap.kanban stock.scrap - + @@ -118,7 +118,7 @@ stock.scrap.tree stock.scrap - + diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index 07032804683..bd472e2a0fc 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -160,7 +160,7 @@ Tree view for survey survey.survey - + @@ -179,7 +179,7 @@ Kanban view for survey survey.survey - + diff --git a/addons/utm/views/utm_campaign_views.xml b/addons/utm/views/utm_campaign_views.xml index d97c7396a70..d08882c0ad4 100644 --- a/addons/utm/views/utm_campaign_views.xml +++ b/addons/utm/views/utm_campaign_views.xml @@ -45,7 +45,7 @@ utm.campaign.view.tree utm.campaign - + @@ -73,7 +73,7 @@ utm.campaign.view.kanban utm.campaign - + diff --git a/addons/website/views/website_visitor_views.xml b/addons/website/views/website_visitor_views.xml index d5289842230..8690dda0da5 100644 --- a/addons/website/views/website_visitor_views.xml +++ b/addons/website/views/website_visitor_views.xml @@ -79,7 +79,7 @@ website.visitor.view.kanban website.visitor - + @@ -225,7 +225,7 @@ website.visitor.view.tree website.visitor - + @@ -294,7 +294,7 @@ website.track.view.tree website.track - + diff --git a/addons/website_blog/views/website_blog_views.xml b/addons/website_blog/views/website_blog_views.xml index 26678476f08..9516e2fa76e 100644 --- a/addons/website_blog/views/website_blog_views.xml +++ b/addons/website_blog/views/website_blog_views.xml @@ -41,7 +41,7 @@ blog.post.list blog.post - + @@ -121,7 +121,7 @@ blog.post.kanban blog.post - + diff --git a/addons/website_slides/views/slide_channel_views.xml b/addons/website_slides/views/slide_channel_views.xml index c68b95ecc74..c15edb495a9 100644 --- a/addons/website_slides/views/slide_channel_views.xml +++ b/addons/website_slides/views/slide_channel_views.xml @@ -160,7 +160,7 @@ slide.channel.view.tree slide.channel - + @@ -178,7 +178,7 @@ slide.channel 20 - + @@ -214,7 +214,7 @@ slide.channel.view.kanban slide.channel - + diff --git a/addons/website_slides/views/slide_slide_views.xml b/addons/website_slides/views/slide_slide_views.xml index 11444575350..c72c1bcc7d1 100644 --- a/addons/website_slides/views/slide_slide_views.xml +++ b/addons/website_slides/views/slide_slide_views.xml @@ -169,7 +169,8 @@ + class="o_slide_kanban" + sample="1"> @@ -238,7 +239,7 @@ slide.slide.tree slide.slide - + diff --git a/odoo/addons/base/rng/tree_view.rng b/odoo/addons/base/rng/tree_view.rng index 64032125869..10a8c4527d0 100644 --- a/odoo/addons/base/rng/tree_view.rng +++ b/odoo/addons/base/rng/tree_view.rng @@ -45,6 +45,7 @@ + diff --git a/odoo/addons/base/views/res_partner_views.xml b/odoo/addons/base/views/res_partner_views.xml index e322ee3b3e8..da975e23134 100644 --- a/odoo/addons/base/views/res_partner_views.xml +++ b/odoo/addons/base/views/res_partner_views.xml @@ -43,7 +43,7 @@ res.partner - + @@ -425,7 +425,7 @@ res.partner.kanban res.partner - +