diff --git a/addons/board/static/src/js/dashboard.js b/addons/board/static/src/js/dashboard.js index 98d169c11e2..3c40524e12c 100644 --- a/addons/board/static/src/js/dashboard.js +++ b/addons/board/static/src/js/dashboard.js @@ -20,7 +20,6 @@ FormView.include({ */ init: function (viewInfo) { this._super.apply(this, arguments); - this.rendererParams.noContentHelp = this.controllerParams.noContentHelp; this.controllerParams.viewID = viewInfo.view_id; }, }); diff --git a/addons/crm/i18n/crm.pot b/addons/crm/i18n/crm.pot index 64fafb2c725..52c532aead2 100644 --- a/addons/crm/i18n/crm.pot +++ b/addons/crm/i18n/crm.pot @@ -3593,8 +3593,7 @@ msgstr "" #. module: crm #: model:ir.actions.act_window,help:crm.crm_opportunity_report_action_lead -msgid "This report analyses the source of your leads\n" -"(including those converted into opportunities)." +msgid "This report analyses the source of your leads." msgstr "" #. module: crm diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml index 67254155822..7fe9c41ae0a 100644 --- a/addons/crm/report/crm_opportunity_report_views.xml +++ b/addons/crm/report/crm_opportunity_report_views.xml @@ -77,8 +77,8 @@ [('probability', '=', 100)] {'col_group_by': ['create_date:month'], 'group_by': ['country_id']} - - Expected Revenus by Team + + Expected Revenue by Team crm.opportunity.report {'group_by': ['create_date:month', 'team_id'], 'measures': ['expected_revenue', '__count']} @@ -191,13 +191,12 @@ with the the sales channel. crm.opportunity.report form pivot,graph - {'search_default_current': True, 'active_test': True} + {'search_default_current': True, 'active_test': True, 'search_default_lead': True} - This report analyses the source of your leads -(including those converted into opportunities). + This report analyses the source of your leads. ' + + '' + + '
' + + '
' + + '', + domain: [['id', '=', 239]], // no record will match this domain + mockRPC: function (route, args) { + if (args.method === 'retrieve_sales_dashboard') { + return $.when(dashboardData); + } + return this._super(route, args); + }, + viewOptions: { + action: { + help: '

A help message

', + }, + }, + }); + + assert.strictEqual(kanban.$('div.o_sales_dashboard').length, 1, + "should render the dashboard"); + assert.strictEqual(kanban.$('.oe_view_nocontent:contains(A help message)').length, 1, + "should correctly render the nocontent helper"); + + kanban.destroy(); +}); + }); diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 64c1e723f92..3f9564caf6c 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -53,9 +53,9 @@ class MrpBom(models.Model): default='asap', required=True) picking_type_id = fields.Many2one( 'stock.picking.type', 'Operation Type', domain=[('code', '=', 'mrp_operation')], - help="When a procurement has a ‘produce’ route with a operation type set, it will try to create " + help=u"When a procurement has a ‘produce’ route with a operation type set, it will try to create " "a Manufacturing Order for that product using a BoM of the same operation type. That allows " - "to define procurement rules which trigger different manufacturing orders with different BoMs. ") + "to define procurement rules which trigger different manufacturing orders with different BoMs.") company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('mrp.bom'), diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 6f8de3c897a..92c3a24708c 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -151,7 +151,7 @@ - + diff --git a/addons/pad/static/src/js/pad.js b/addons/pad/static/src/js/pad.js index 9637072274e..4068ff24ead 100644 --- a/addons/pad/static/src/js/pad.js +++ b/addons/pad/static/src/js/pad.js @@ -39,6 +39,25 @@ var FieldPad = AbstractField.extend({ this.$(".oe_configured").addClass('hidden'); return; } + if (this.mode === 'edit' && _.str.startsWith(this.value, 'http')) { + this.url = this.value; + // please close your eyes and look elsewhere... + // Since the pad value (the url) will not change during the edition + // process, we have a problem: the description field will not be + // properly updated. We need to explicitely write the value each + // time someone edit the record in order to force the server to read + // the updated value of the pad and put it in the description field. + // + // However, the basic model optimizes away the changes if they are + // not really different from the current value. So, we need to + // either add special configuration options to the basic model, or + // to trick him into accepting the same value as being different... + // Guess what we decided... + var url = {}; + url.toJSON = _.constant(this.url); + this._setValue(url); + } + return this._super.apply(this, arguments); }, @@ -75,11 +94,11 @@ var FieldPad = AbstractField.extend({ * @private */ _renderEdit: function () { - if (_.str.startsWith(this.value, 'http')) { + if (this.url) { // here, we have a valid url, so we can simply display an iframe // with the correct src attribute var userName = encodeURIComponent(this.getSession().userName); - var url = this.value + '?showChat=false&userName=' + userName; + var url = this.url + '?showChat=false&userName=' + userName; var content = ''; this.$('.oe_pad_content').html(content); } else if (this.value) { @@ -111,7 +130,8 @@ var FieldPad = AbstractField.extend({ // We need to write the url of the pad to trigger // the write function which updates the actual value // of the field to the value of the pad content - self._setValue(result.url); + self.url = result.url; + self._setValue(result.url, {doNotSetDirty: true}); }); } }, diff --git a/addons/pad/static/tests/pad_tests.js b/addons/pad/static/tests/pad_tests.js index 69342f5de6e..5d18e55c1e0 100644 --- a/addons/pad/static/tests/pad_tests.js +++ b/addons/pad/static/tests/pad_tests.js @@ -112,7 +112,7 @@ QUnit.module('pad widget', { }); QUnit.test('pad widget works, with existing data', function (assert) { - assert.expect(2); + assert.expect(3); var contentDef = $.Deferred(); @@ -129,10 +129,17 @@ QUnit.module('pad widget', { '', res_id: 2, mockRPC: function (route, args) { + if (_.str.startsWith(route, 'http')) { + return $.when(true); + } var result = this._super.apply(this, arguments); if (args.method === 'pad_get_content') { return contentDef.then(_.constant(result)); } + if (args.method === 'write') { + assert.ok('description' in args.args[1], + "should always send the description value"); + } return result; }, session: { @@ -144,8 +151,47 @@ QUnit.module('pad widget', { contentDef.resolve(); assert.strictEqual(form.$('.oe_pad_content').text(), "we should rewrite this server in haskell", "should display proper value"); + + form.$buttons.find('.o_form_button_edit').click(); + form.$buttons.find('.o_form_button_save').click(); form.destroy(); delete FieldPad.prototype.isPadConfigured; }); + QUnit.test('pad widget is not considered dirty at creation', function (assert) { + assert.expect(2); + + var form = createView({ + View: FormView, + model: 'task', + data: this.data, + arch:'
' + + '' + + '' + + '' + + '' + + '' + + '
', + mockRPC: function (route, args) { + if (!args.method) { + return $.when(true); + } + return this._super.apply(this, arguments); + }, + session: { + userName: "batman", + }, + }); + var def = form.canBeDiscarded(); + + assert.strictEqual($('.modal').length, 0, + "should have no confirmation modal opened"); + + assert.strictEqual(def.state(), 'resolved', + "can be discarded was succesfully resolved"); + form.destroy(); + delete FieldPad.prototype.isPadConfigured; + }); + + }); diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index 37998ab6c1d..d8191dfc34c 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -110,7 +110,7 @@ class AuthorizeAPI(): payment_profile = etree.SubElement(profile, "paymentProfiles") etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual' billTo = etree.SubElement(payment_profile, "billTo") - etree.SubElement(billTo, "address").text = (partner.street + (partner.street2 if partner.street2 else '')) or None + etree.SubElement(billTo, "address").text = (partner.street or '' + (partner.street2 if partner.street2 else '')) or None etree.SubElement(billTo, "city").text = partner.city etree.SubElement(billTo, "state").text = partner.state_id.name or None etree.SubElement(billTo, "zip").text = partner.zip diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 6cfd2a477b1..f3387885915 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -946,7 +946,7 @@ class ProcurementOrder(models.Model): if self.purchase_line_id: if not self.move_ids: return False - return all(move.state == 'done' for move in self.move_ids) + return all(move.state in ('done', 'cancel') for move in self.move_ids) and any(move.state == 'done' for move in self.move_ids) return super(ProcurementOrder, self)._check() def _get_purchase_schedule_date(self): diff --git a/addons/stock/wizard/stock_picking_return_views.xml b/addons/stock/wizard/stock_picking_return_views.xml index a5bcf71dda0..b7f15e73dbb 100644 --- a/addons/stock/wizard/stock_picking_return_views.xml +++ b/addons/stock/wizard/stock_picking_return_views.xml @@ -18,7 +18,7 @@ - + diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js index fa2f37a123f..426d42d664b 100644 --- a/addons/web/static/src/js/fields/abstract_field.js +++ b/addons/web/static/src/js/fields/abstract_field.js @@ -379,6 +379,11 @@ var AbstractField = Widget.extend({ * @private * @param {any} value * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if true, the basic model + * will not consider that this field is dirty, even though it was changed. + * Please do not use this flag unless you really need it. Our only use + * case is currently the pad widget, which does a _setValue in the + * renderEdit method. * @param {boolean} [options.forceChange=false] if true, the change event will be * triggered even if the new value is the same as the old one * @returns {Deferred} @@ -407,6 +412,7 @@ var AbstractField = Widget.extend({ dataPointID: this.dataPointID, changes: changes, viewType: this.viewType, + doNotSetDirty: options && options.doNotSetDirty, onSuccess: def.resolve.bind(def), onFailure: def.reject.bind(def), }); diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 2f41f81f932..2de5a73802d 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -151,6 +151,9 @@ var DebouncedField = AbstractField.extend({ }); var InputField = DebouncedField.extend({ + custom_events: _.extend({}, DebouncedField.prototype.custom_events, { + field_changed: '_onFieldChanged', + }), events: _.extend({}, DebouncedField.prototype.events, { 'input': '_onInput', 'change': '_onChange', @@ -168,6 +171,12 @@ var InputField = DebouncedField.extend({ if (this.mode === 'edit') { this.tagName = 'input'; } + // We need to know if the widget is dirty (i.e. if the user has changed + // the value, and those changes haven't been acknowledged yet by the + // environment), to prevent erasing that new value on a reset (e.g. + // coming by an onchange on another field) + this.isDirty = false; + this.lastChangeEvent = undefined; }, //-------------------------------------------------------------------------- @@ -183,14 +192,21 @@ var InputField = DebouncedField.extend({ return this.$input || $(); }, /** - * Do not re-render this field if it was the origin of the onchange call. - * FIXME: make the onchange work on itself without disturbing the user typing + * Re-renders the widget if it isn't dirty. The widget is dirty if the user + * changed the value, and that change hasn't been acknowledged yet by the + * environment. For example, another field with an onchange has been updated + * and this field is updated before the onchange returns. Two '_setValue' + * are done (this is sequential), the first one returns and this widget is + * reset. However, it has pending changes, so we don't re-render. * * @override */ reset: function (record, event) { this._reset(record, event); - if (event && event.target === this) { + if (!event || event === this.lastChangeEvent) { + this.isDirty = false; + } + if (this.isDirty || (event && event.target === this)) { return $.when(); } else { return this._render(); @@ -265,6 +281,16 @@ var InputField = DebouncedField.extend({ _onChange: function () { this._doAction(); }, + /** + * Listens to events 'field_changed' to keep track of the last event that + * has been trigerred. This allows to detect that all changes have been + * acknowledged by the environment. + * + * @param {OdooEvent} event 'field_changed' event + */ + _onFieldChanged: function (event) { + this.lastChangeEvent = event; + }, /** * Called when the user is typing text -> By default this only calls a * debounced method to notify the outside world of the changes. @@ -273,6 +299,7 @@ var InputField = DebouncedField.extend({ * @private */ _onInput: function () { + this.isDirty = true; this._doDebouncedAction(); }, /** diff --git a/addons/web/static/src/js/services/crash_manager.js b/addons/web/static/src/js/services/crash_manager.js index f09eec23daf..2684c46a12b 100644 --- a/addons/web/static/src/js/services/crash_manager.js +++ b/addons/web/static/src/js/services/crash_manager.js @@ -205,7 +205,7 @@ var RedirectWarningHandler = Dialog.extend(ExceptionHandler, { } }); -core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler); +core.crash_registry.add('odoo.exceptions.RedirectWarning', RedirectWarningHandler); return CrashManager; }); diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index b84f6ec1951..3bdf8ab08ca 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -32,7 +32,6 @@ var AbstractController = Widget.extend({ * @param {string} params.modelName * @param {any} [params.handle] a handle that will be given to the model (some id) * @param {any} params.initialState the initialState - * @param {string} [params.noContentHelp] */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); @@ -42,7 +41,6 @@ var AbstractController = Widget.extend({ this.handle = params.handle; this.activeActions = params.activeActions; this.initialState = params.initialState; - this.noContentHelp = params.noContentHelp; }, /** * Simply renders and updates the url. @@ -223,31 +221,6 @@ var AbstractController = Widget.extend({ _pushState: function (state) { this.trigger_up('push_state', state || {}); }, - /** - * Hide or show the nocontent helper. For this, it also remove the renderer - * from the dom, and reattach it when necessary. - * - * This method is a helper for controllers that want to display a help - * message when no content is available. It is suggested to override - * _update to call this method. - * - * @private - * @param {boolean} hasNoContent - */ - _toggleNoContentHelper: function (hasNoContent) { - if (hasNoContent && this.noContentHelp) { - this.renderer.$el.detach(); - var $msg = $('
') - .addClass('oe_view_nocontent') - .html(this.noContentHelp); - this.$el.html($msg); - } else { - if (!document.contains(this.renderer.el)) { - this.$('div.oe_view_nocontent').remove(); - this.$el.append(this.renderer.$el); - } - } - }, /** * This method is called after each update or when the start method is * completed. diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js index 7d10f05eab8..d7eb5002f2d 100644 --- a/addons/web/static/src/js/views/abstract_renderer.js +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -15,11 +15,13 @@ return Widget.extend({ * @param {Widget} parent * @param {any} state * @param {Object} params + * @param {string} [params.noContentHelp] */ init: function (parent, state, params) { this._super(parent); this.state = state; this.arch = params.arch; + this.noContentHelp = params.noContentHelp; }, /** * The rendering can be asynchronous (but it is not encouraged). The start diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 4c43a692601..8de118b29b7 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -78,6 +78,7 @@ var AbstractView = Class.extend({ init: function (viewInfo, params) { this.rendererParams = { arch: viewInfo.arch, + noContentHelp: params.action && params.action.help, }; this.controllerParams = { @@ -88,7 +89,6 @@ var AbstractView = Class.extend({ delete: viewInfo.arch.attrs.delete ? JSON.parse(viewInfo.arch.attrs.delete) : true, duplicate: viewInfo.arch.attrs.duplicate ? JSON.parse(viewInfo.arch.attrs.duplicate) : true, }, - noContentHelp: params.action && params.action.help, }; this.loadParams = { 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 cc72816a85d..54c8ccfbf51 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -361,17 +361,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { model: this.modelName, }; }, - /** - * Used by list and kanban views to determine whether or not to display - * the no content helper (if there is no data in the state to display) - * - * @private - * @param {Object} state - * @returns {boolean} - */ - _hasContent: function (state) { - return state.count !== 0; - }, /** * Helper function to display a warning that some fields have an invalid * value. This is used when a save operation cannot be completed. 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 8f679207f00..04fe384afdc 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -628,12 +628,12 @@ var BasicModel = AbstractModel.extend({ * * @param {string} record_id * @param {Object} changes a map field => new value - * @param {string} [viewType] current viewType. If not set, we will assume - * main viewType from the record + * @param {Object} [options] will be transferred to the applyChange method + * @see _applyChange * @returns {string[]} list of changed fields */ - notifyChanges: function (record_id, changes, viewType) { - return this.mutex.exec(this._applyChange.bind(this, record_id, changes, viewType)); + notifyChanges: function (record_id, changes, options) { + return this.mutex.exec(this._applyChange.bind(this, record_id, changes, options)); }, /** * Reload all data for a given resource @@ -956,23 +956,30 @@ var BasicModel = AbstractModel.extend({ * * @param {string} recordID * @param {Object} changes - * @param {string} [viewType] current viewType. If not set, we will assume + * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if this flag is set to + * true, then we will not tag the record as dirty. This should be avoided + * for most situations. + * @param {string} [options.viewType] current viewType. If not set, we will assume * main viewType from the record * @returns {Deferred} */ - _applyChange: function (recordID, changes, viewType) { + _applyChange: function (recordID, changes, options) { var self = this; var record = this.localData[recordID]; var field; var defs = []; + options = options || {}; record._changes = record._changes || {}; - record._isDirty = true; + if (!options.doNotSetDirty) { + record._isDirty = true; + } // apply changes to local data for (var fieldName in changes) { field = record.fields[fieldName]; if (field.type === 'one2many' || field.type === 'many2many') { - defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], viewType)); + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options.viewType)); } else if (field.type === 'many2one' || field.type === 'reference') { defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName])); } else { @@ -993,7 +1000,7 @@ var BasicModel = AbstractModel.extend({ } var onchangeDef; if (onChangeFields.length) { - onchangeDef = self._performOnChange(record, onChangeFields, viewType).then(function (result) { + onchangeDef = self._performOnChange(record, onChangeFields, options.viewType).then(function (result) { delete record._warning; return _.keys(changes).concat(Object.keys(result && result.value || {})); }); @@ -2196,7 +2203,7 @@ var BasicModel = AbstractModel.extend({ // remove readonly fields from the list of changes if (!withReadonly && fieldName in changes || fieldName in commands) { var editionViewType = record._editionViewType[fieldName] || viewType; - if (this._isFieldReadonly(record, fieldName, editionViewType)) { + if (this._isFieldProtected(record, fieldName, editionViewType)) { delete changes[fieldName]; continue; } @@ -2555,9 +2562,10 @@ var BasicModel = AbstractModel.extend({ return context; }, /** - * Returns true if the field is readonly (checking first in the modifiers, - * and if there is no readonly modifier, checking the readonly attribute of - * the field). + * Returns true if the field is protected against changes, looking for a + * readonly modifier unless there is a force_save modifier (checking first + * in the modifiers, and if there is no readonly modifier, checking the + * readonly attribute of the field). * * @private * @param {Object} record an element from the localData @@ -2566,12 +2574,12 @@ var BasicModel = AbstractModel.extend({ * main viewType from the record * @returns {boolean} */ - _isFieldReadonly: function (record, fieldName, viewType) { + _isFieldProtected: function (record, fieldName, viewType) { var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName]; if (fieldInfo) { var rawModifiers = JSON.parse(fieldInfo.modifiers || "{}"); var modifiers = this._evalModifiers(record, rawModifiers); - return modifiers.readonly; + return modifiers.readonly && !fieldInfo.force_save; } else { return false; } diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index 8bbf9ae45a4..e040a5fa7f0 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -317,6 +317,16 @@ var BasicRenderer = AbstractRenderer.extend({ _getModifiersData: function (node) { return _.findWhere(this.allModifiersData, {node: node}); }, + /** + * Used by list and kanban renderers to determine whether or not to display + * the no content helper (if there is no data in the state to display) + * + * @private + * @returns {boolean} + */ + _hasContent: function () { + return this.state.count !== 0; + }, /** * Registers or updates the modifiers data associated to the given node. * This method is quiet complex as it handles all the needs of the basic @@ -486,6 +496,20 @@ var BasicRenderer = AbstractRenderer.extend({ return widget; }, + /** + * Renders the nocontent helper. + * + * This method is a helper for renderers that want to display a help + * message when no content is available. + * + * @private + */ + _renderNoContentHelper: function () { + var $msg = $('
') + .addClass('oe_view_nocontent') + .html(this.noContentHelp); + this.$el.html($msg); + }, /** * Actual rendering. Supposed to be overridden by concrete renderers. * The basic responsabilities of _renderView are: 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 73868a93fdb..ec3ff8940a7 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -302,44 +302,38 @@ return AbstractModel.extend({ // Private //-------------------------------------------------------------------------- + /** + * Converts this.data.filters into a domain + * + * @private + * @returns {Array} + */ _getFilterDomain: function () { - var domain = []; - _.each(this.data.filters, function (filter) { - if (filter.all) return; - var nb = {}; - var not = {}; - var contains = {}; - _.each(filter.filters, function (f) { - if (!contains[filter.fieldName]) { - contains[filter.fieldName] = []; - not[filter.fieldName] = []; - nb[filter.fieldName] = 0; - } - if (f.value === 'all' && f.active) { - contains[filter.fieldName].all = true; - } else { - nb[filter.fieldName]++; - } + // List authorized values for every field + // fields with an active 'all' filter are skipped + var authorizedValues = {}; - if (f.active && contains[filter.fieldName].indexOf(f.value) === -1) { - contains[filter.fieldName].push(f.value); - } - if (!f.active && not[filter.fieldName].indexOf(f.value) === -1) { - not[filter.fieldName].push(f.value); + _.each(this.data.filters, function (filter) { + // Skip 'all' filters because they do not affect the domain + if (filter.all) return; + + // Loop over subfilters to complete authorizedValues + _.each(filter.filters, function (f) { + if (!authorizedValues[filter.fieldName]) + authorizedValues[filter.fieldName] = []; + + if (f.active) { + authorizedValues[filter.fieldName].push(f.value); } }); - - for (var field in contains) { - if (contains[field].all || contains[field].length === nb[field]) { - continue; - } - if (contains[field].length) { - domain.push([field, 'in', contains[field]]); - } else if (not[field].length && not[field].length !== nb[field]) { - domain.push([field, 'not in', not[field]]); - } - } }); + + // Compute the domain + var domain = []; + for (var field in authorizedValues) { + domain.push([field, 'in', authorizedValues[field]]); + } + return domain; }, /** diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js index 422af121d96..b4bf1ddbf94 100644 --- a/addons/web/static/src/js/views/field_manager_mixin.js +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -43,7 +43,8 @@ var FieldManagerMixin = { */ _applyChanges: function (dataPointID, changes, event) { var self = this; - return this.model.notifyChanges(dataPointID, changes, event.data.viewType) + var options = _.pick(event.data, 'viewType', 'doNotSetDirty'); + return this.model.notifyChanges(dataPointID, changes, options) .then(function (result) { if (event.data.force_save) { return self.model.save(dataPointID).then(function () { 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 8a7f5da6d5d..50e9f224697 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -114,13 +114,13 @@ return AbstractModel.extend({ * @returns {Deferred} */ _loadGraph: function () { - var fields = _.map(this.chart.groupedBy, function (groupBy) { + var groupedBy = this.chart.groupedBy.length ? this.chart.groupedBy : this.initialGroupBys; + var fields = _.map(groupedBy, function (groupBy) { return groupBy.split(':')[0]; }); if (this.chart.measure !== '__count__') { fields = fields.concat(this.chart.measure); } - var groupedBy = this.chart.groupedBy.length ? this.chart.groupedBy : this.initialGroupBys; return this._rpc({ model: this.modelName, method: 'read_group', 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 2592e3d96ec..2cf6a94285e 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -80,19 +80,6 @@ var KanbanController = BasicController.extend({ _confirmSave: function (id) { return this.renderer.updateRecord(this.model.get(id)); }, - /** - * The nocontent helper should be displayed in kanban: - * - ungrouped: if there is no records - * - grouped: if there is no groups and no column quick create - * - * @override - * @private - */ - _hasContent: function (state) { - return this._super.apply(this, arguments) || - this.createColumnEnabled || - (state.groupedBy.length && state.data.length); - }, /** * The column quick create should be displayed in kanban iff grouped by an * m2o field and group_create action enabled. @@ -138,17 +125,6 @@ var KanbanController = BasicController.extend({ .toggleClass('btn-default', createMuted); } }, - /** - * @override - * @param {Object} state - * @returns {Deferred} - */ - _update: function (state) { - var hasNoContent = !this._hasContent(state); - this.$el.toggleClass('o_kanban_nocontent', hasNoContent); - this._toggleNoContentHelper(hasNoContent); - return this._super.apply(this, arguments); - }, //-------------------------------------------------------------------------- // Handlers 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 ea2f0270017..0bbc7aa2698 100644 --- a/addons/web/static/src/js/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -100,6 +100,8 @@ var KanbanRenderer = BasicRenderer.extend({ viewType: 'kanban', }); this.columnOptions = _.extend({}, params.column_options, { qweb: this.qweb }); + + this._setState(state); }, //-------------------------------------------------------------------------- @@ -167,11 +169,31 @@ var KanbanRenderer = BasicRenderer.extend({ record.update(recordState); } }, + /** + * @override + */ + updateState: function (state) { + this._setState(state); + return this._super.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- + /** + * The nocontent helper should be displayed in kanban: + * - ungrouped: if there is no records + * - grouped: if there is no groups and no column quick create + * + * @override + * @private + */ + _hasContent: function () { + return this._super.apply(this, arguments) || + this.createColumnEnabled || + (this.state.groupedBy.length && this.state.data.length); + }, /** * Renders empty invisible divs in a document fragment. * @@ -193,28 +215,6 @@ var KanbanRenderer = BasicRenderer.extend({ */ _renderGrouped: function (fragment) { var self = this; - var groupByFieldAttrs = this.state.fields[this.state.groupedBy[0]]; - var groupByFieldInfo = this.state.fieldsInfo.kanban[this.state.groupedBy[0]]; - // Deactivate the drag'n'drop if the groupedBy field: - // - is a date or datetime since we group by month or - // - is readonly - var draggable = true; - if (groupByFieldAttrs) { - if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") { - draggable = false; - } else if (groupByFieldAttrs.readonly !== undefined) { - draggable = !(groupByFieldAttrs.readonly); - } - } - var groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one'); - var grouped_by_field = groupedByM2O && groupByFieldAttrs.relation; - var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip; - this.columnOptions = _.extend(this.columnOptions, { - draggable: draggable, - group_by_tooltip: groupByTooltip, - grouped_by_m2o: groupedByM2O, - relation: grouped_by_field, - }); // Render columns _.each(this.state.data, function (group) { @@ -228,7 +228,7 @@ var KanbanRenderer = BasicRenderer.extend({ } }); - if (groupedByM2O) { + if (this.groupedByM2O) { // Enable column sorting this.$el.sortable({ axis: 'x', @@ -249,7 +249,7 @@ var KanbanRenderer = BasicRenderer.extend({ }); // Enable column quickcreate - if (this.columnOptions.group_creatable) { + if (this.createColumnEnabled) { this.quickCreate = new ColumnQuickCreate(this); this.quickCreate.appendTo(fragment); } @@ -280,20 +280,62 @@ var KanbanRenderer = BasicRenderer.extend({ _renderView: function () { var oldWidgets = this.widgets; this.widgets = []; - var isGrouped = !!this.state.groupedBy.length; - this.$el.toggleClass('o_kanban_grouped', isGrouped); - this.$el.toggleClass('o_kanban_ungrouped', !isGrouped); this.$el.empty(); - var fragment = document.createDocumentFragment(); - if (isGrouped) { - this._renderGrouped(fragment); + var displayNoContentHelper = !this._hasContent() && !!this.noContentHelp; + this.$el.toggleClass('o_kanban_nocontent', displayNoContentHelper); + if (displayNoContentHelper) { + // display the no content helper if there is no data to display + this._renderNoContentHelper(); } else { - this._renderUngrouped(fragment); + var isGrouped = !!this.state.groupedBy.length; + this.$el.toggleClass('o_kanban_grouped', isGrouped); + this.$el.toggleClass('o_kanban_ungrouped', !isGrouped); + var fragment = document.createDocumentFragment(); + // render the kanban view + if (isGrouped) { + this._renderGrouped(fragment); + } else { + this._renderUngrouped(fragment); + } + this.$el.append(fragment); } - this.$el.append(fragment); + return this._super.apply(this, arguments).then(_.invoke.bind(_, oldWidgets, 'destroy')); }, + /** + * Sets the current state and updates some internal attributes accordingly. + * + * @private + * @param {Object} state + */ + _setState: function (state) { + this.state = state; + + var groupByFieldAttrs = state.fields[state.groupedBy[0]]; + var groupByFieldInfo = state.fieldsInfo.kanban[state.groupedBy[0]]; + // Deactivate the drag'n'drop if the groupedBy field: + // - is a date or datetime since we group by month or + // - is readonly + var draggable = true; + if (groupByFieldAttrs) { + if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") { + draggable = false; + } else if (groupByFieldAttrs.readonly !== undefined) { + draggable = !(groupByFieldAttrs.readonly); + } + } + this.groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one'); + var grouped_by_field = this.groupedByM2O && groupByFieldAttrs.relation; + var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip; + this.columnOptions = _.extend(this.columnOptions, { + draggable: draggable, + group_by_tooltip: groupByTooltip, + grouped_by_m2o: this.groupedByM2O, + relation: grouped_by_field, + }); + this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable; + }, }); return KanbanRenderer; 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 2ec9cf3181e..3b74f140e4d 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -188,7 +188,6 @@ var ListController = BasicController.extend({ position: self.editable, }); }).then(function (recordID) { - self._toggleNoContentHelper(false); var state = self.model.get(self.handle); self.renderer.updateState(state, {}); self.renderer.editRecord(recordID); @@ -283,12 +282,10 @@ var ListController = BasicController.extend({ }, /** * @override - * @param {Object} state * @returns {Deferred} */ - _update: function (state) { + _update: function () { this.selectedRecords = []; - this._toggleNoContentHelper(!this._hasContent(state)); this._toggleSidebar(); return this._super.apply(this, arguments); }, 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 08b9e7f1602..3cd93bb6e8b 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 @@ -13,6 +13,7 @@ odoo.define('web.EditableListRenderer', function (require) { */ var core = require('web.core'); var ListRenderer = require('web.ListRenderer'); +var utils = require('web.utils'); var _t = core._t; @@ -153,12 +154,32 @@ ListRenderer.include({ */ setRowMode: function (recordID, mode) { var self = this; - var rowIndex = _.findIndex(this.state.data, {id: recordID}); + + // find the record and its row index (handles ungrouped and grouped cases + // as even if the grouped list doesn't support edition, it may contain + // a widget allowing the edition in readonly (e.g. priority), so it + // should be able to update a record as well) + var record; + var rowIndex; + if (this.state.groupedBy.length) { + rowIndex = -1; + var count = 0; + utils.traverse_records(this.state, function (r) { + if (r.id === recordID) { + record = r; + rowIndex = count; + } + count++; + }); + } else { + rowIndex = _.findIndex(this.state.data, {id: recordID}); + record = this.state.data[rowIndex]; + } + if (rowIndex < 0) { return $.when(); } var editMode = (mode === 'edit'); - var record = this.state.data[rowIndex]; this.currentRow = editMode ? rowIndex : null; var $row = this.$('.o_data_row:nth(' + rowIndex + ')'); 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 802f2f03a09..295266db276 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -30,7 +30,6 @@ var FIELD_CLASSES = { }; var ListRenderer = BasicRenderer.extend({ - className: 'table-responsive', events: { 'click tbody tr': '_onRowClicked', 'click tbody .o_list_record_selector': '_onSelectRecord', @@ -577,12 +576,24 @@ var ListRenderer = BasicRenderer.extend({ _renderView: function () { var self = this; + this.$el + .removeClass('table-responsive') + .empty(); + // destroy the previously instantiated pagers, if any _.invoke(this.pagers, 'destroy'); this.pagers = []; + // display the no content helper if there is no data to display + if (!this._hasContent() && this.noContentHelp) { + this._renderNoContentHelper(); + return this._super(); + } + var $table = $('').addClass('o_list_view table table-condensed table-striped'); - this.$el.empty().append($table); + this.$el + .addClass('table-responsive') + .append($table); var is_grouped = !!this.state.groupedBy.length; this._computeAggregates(); $table.toggleClass('o_list_view_grouped', is_grouped); 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 78640a97124..731183e7efe 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -170,21 +170,8 @@ var PivotController = AbstractController.extend({ /** * @private */ - _update: function (state) { + _update: function () { this._updateButtons(); - var hasNoContent = !state.has_data || !state.measures.length; - - if (hasNoContent) { - if (!this.$('.oe_view_nocontent').length) { - this.renderer.$el.detach(); - this.$el.append(QWeb.render('PivotView.nodata')); - } - } else { - if (!document.contains(this.renderer.el)) { - this.$('div.oe_view_nocontent').remove(); - this.$el.append(this.renderer.$el); - } - } return this._super.apply(this, arguments); }, /** diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js index a37e65b104b..d279d59d4b1 100644 --- a/addons/web/static/src/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -2,8 +2,11 @@ odoo.define('web.PivotRenderer', function (require) { "use strict"; var AbstractRenderer = require('web.AbstractRenderer'); +var core = require('web.core'); var field_utils = require('web.field_utils'); +var QWeb = core.qweb; + var PivotRenderer = AbstractRenderer.extend({ tagName: 'table', className: 'table-hover table-condensed table-bordered', @@ -15,15 +18,32 @@ var PivotRenderer = AbstractRenderer.extend({ // Private //-------------------------------------------------------------------------- + /** + * Used to determine whether or not to display the no content helper. + * + * @private + * @returns {boolean} + */ + _hasContent: function () { + return this.state.has_data && this.state.measures.length; + }, /** * @override * @private * @returns {Deferred} */ _render: function () { - if (!this.state.has_data) { + if (!this._hasContent()) { + // display the nocontent helper + this.replaceElement(QWeb.render('PivotView.nodata')); return this._super.apply(this, arguments); } + + if (!this.$el.is('table')) { + // coming from the no content helper, so the root element has to be + // re-rendered before rendering and appending its content + this.renderElement(); + } var $fragment = $(document.createDocumentFragment()); var $table = $('
').appendTo($fragment); var $thead = $('').appendTo($table); diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 173f49080ea..508e86d9cd3 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -849,6 +849,122 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('input field: change value before pending onchange returns', function (assert) { + assert.expect(3); + + this.data.partner.onchanges = { + product_id: function () {}, + }; + + var def; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '', + res_id: 1, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === "onchange") { + return $.when(def).then(function () { + return result; + }); + } else { + return result; + } + }, + viewOptions: { + mode: 'edit', + }, + }); + + form.$('.o_field_x2many_list_row_add a').click(); + assert.strictEqual(form.$('input[name="foo"]').val(), 'My little Foo Value', + 'should contain the default value'); + + def = $.Deferred(); + form.$('.o_field_many2one input').click(); + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + $dropdown.find('li:first()').click(); + + // set foo before onchange + form.$('input[name="foo"]').val("tralala").trigger('input'); + assert.strictEqual(form.$('input[name="foo"]').val(), 'tralala', + 'input should contain tralala'); + + // complete the onchange + def.resolve(); + assert.strictEqual(form.$('input[name="foo"]').val(), 'tralala', + 'input should contain the same value as before onchange'); + + form.destroy(); + }); + + QUnit.test('input field: change value before pending onchange renaming', function (assert) { + assert.expect(3); + + this.data.partner.onchanges = { + product_id: function (obj) { + obj.foo = 'on change value'; + }, + }; + + var def = $.Deferred(); + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '' + + '' + + '' + + '' + + '' + + '', + res_id: 1, + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === "onchange") { + return $.when(def).then(function () { + return result; + }); + } else { + return result; + } + }, + viewOptions: { + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('input[name="foo"]').val(), 'yop', + 'should contain the correct value'); + + form.$('.o_field_many2one input').click(); + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + $dropdown.find('li:first()').click(); + + // set foo before onchange + form.$('input[name="foo"]').val("tralala").trigger('input'); + assert.strictEqual(form.$('input[name="foo"]').val(), 'tralala', + 'input should contain tralala'); + + // complete the onchange + def.resolve(); + assert.strictEqual(form.$('input[name="foo"]').val(), 'tralala', + 'input should contain the same value as before onchange'); + + form.destroy(); + }); + QUnit.module('UrlWidget'); QUnit.test('url widget in form view', function (assert) { diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js index 9e8e64edde6..53577d90e40 100644 --- a/addons/web/static/tests/views/basic_model_tests.js +++ b/addons/web/static/tests/views/basic_model_tests.js @@ -1475,6 +1475,71 @@ QUnit.module('Views', { model.destroy(); }); + QUnit.test('dont write on readonly fields unless save attribute is set', function (assert) { + assert.expect(6); + + this.params.fieldNames = ['foo', 'bar']; + this.data.partner.onchanges.foo = function (obj) { + obj.bar = obj.foo.length; + }; + this.params.fieldsInfo = { + default: { + foo: {}, + bar: { + modifiers:"{\"readonly\": true}", + force_save: true, + }, + } + }; + + var model = createModel({ + Model: BasicModel, + data: this.data, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], {bar: 14, foo: "verylongstring"}, + "should only save foo field"); + } + if (args.method === 'create') { + assert.deepEqual(args.args[0], {bar: 21, foo: "anotherverylongstring"}, + "should only save foo field"); + } + return this._super(route, args); + }, + }); + + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.bar, 2, + "should be initialized with correct value"); + + model.notifyChanges(resultID, {foo: "verylongstring"}); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 14, + "should be changed with correct value"); + + model.save(resultID); + }); + + // start again, but with a new record + delete this.params.res_id; + model.load(this.params).then(function (resultID) { + var record = model.get(resultID); + assert.strictEqual(record.data.bar, 0, + "should be initialized with correct value (0 as integer)"); + + model.notifyChanges(resultID, {foo: "anotherverylongstring"}); + + record = model.get(resultID); + assert.strictEqual(record.data.bar, 21, + "should be changed with correct value"); + + model.save(resultID); + }); + model.destroy(); + }); + QUnit.test('default_get with one2many values', function (assert) { assert.expect(1); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index dfc42c6f746..8910316eb21 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -32,7 +32,7 @@ QUnit.module('Views', { fields: { id: {string: "ID", type: "integer"}, user_id: {string: "user", type: "many2one", relation: 'user'}, - parnter_id: {string: "user", type: "many2one", relation: 'partner', related: 'user_id.parnter_id'}, + partner_id: {string: "user", type: "many2one", relation: 'partner', related: 'user_id.partner_id'}, name: {string: "name", type: "char"}, start_date: {string: "start date", type: "date"}, stop_date: {string: "stop date", type: "date"}, @@ -58,7 +58,7 @@ QUnit.module('Views', { fields: { id: {string: "ID", type: "integer"}, display_name: {string: "Displayed name", type: "char"}, - parnter_id: {string: "partner", type: "many2one", relation: 'partner'}, + partner_id: {string: "partner", type: "many2one", relation: 'partner'}, image: {string: "image", type: "integer"}, }, records: [ @@ -158,7 +158,7 @@ QUnit.module('Views', { 'all_day="allday" '+ 'mode="week" '+ 'attendee="partner_ids" '+ - 'color="parnter_id">'+ + 'color="partner_id">'+ ''+ ''+ ''+ @@ -194,7 +194,7 @@ QUnit.module('Views', { var $typeFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(user))'); assert.ok($typeFilter.length, "should display 'user' filter"); - assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 1, "should display 1 filter items for 'user'"); + assert.strictEqual($typeFilter.find('.o_calendar_filter_item').length, 2, "should display 2 filter items for 'user'"); var $attendeesFilter = $sidebar.find('.o_calendar_filter:has(h3:contains(attendees))'); assert.ok($attendeesFilter.length, "should display 'attendees' filter"); @@ -940,6 +940,47 @@ QUnit.module('Views', { assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body"); }); + QUnit.test('"all" filter', function (assert) { + assert.expect(3); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + ''+ + ''+ + ''+ + ''+ + '', + viewOptions: { + initialDate: initialDate, + }, + }); + + assert.strictEqual(calendar.$('.fc-event').length, 9, + "should display 9 events on the week"); + + // Select the events only associated with partner 2 + calendar.$('.o_calendar_filter_item[data-id=2] input').click(); + assert.strictEqual(calendar.$('.fc-event').length, 4, + "should display 4 events on the week"); + + // Click on the 'all' filter to reload all events + calendar.$('.o_calendar_filter_item[data-value=all] input').click(); + assert.strictEqual(calendar.$('.fc-event').length, 9, + "should display 9 events on the week"); + + calendar.destroy(); + }); + }); }); diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index 33b2fd8ffbd..c7d69b194ff 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -372,6 +372,30 @@ QUnit.module('Views', { }); }); + QUnit.test('reload graph with correct fields', function (assert) { + assert.expect(2); + + var graph = createView({ + View: GraphView, + model: 'foo', + data: this.data, + arch: '' + + '' + + '' + + '', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.fields, ['product_id', 'foo'], + "should read the correct fields"); + } + return this._super.apply(this, arguments); + }, + }); + + graph.reload({groupBy: []}); + + graph.destroy(); + }); }); }); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index dd0a52868f6..7fc9343d82c 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -965,7 +965,7 @@ QUnit.module('Views', { }, }); - assert.ok(kanban.$el.hasClass('o_kanban_nocontent'), + assert.ok(kanban.$('.o_kanban_view').hasClass('o_kanban_nocontent'), "$el should have correct no content class"); assert.strictEqual(kanban.$('.oe_view_nocontent').length, 1, diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index e3d8acfe84a..f0370ac7cf8 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2483,6 +2483,34 @@ QUnit.module('Views', { list.destroy(); }); + + QUnit.test('grouped list edition with toggle_button widget', function (assert) { + assert.expect(3); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '', + groupBy: ['m2o'], + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1], {bar: false}, + "should write the correct value"); + } + return this._super.apply(this, arguments); + }, + }); + + list.$('.o_group_header:first').click(); // open the first group + assert.strictEqual(list.$('.o_data_row:first .o_toggle_button_success').length, 1, + "boolean value of the first record should be true"); + list.$('.o_data_row:first .o_icon_button').click(); // toggle the value + assert.strictEqual(list.$('.o_data_row:first .text-muted:not(.o_toggle_button_success)').length, 1, + "boolean button should have been updated"); + + list.destroy(); + }); }); }); diff --git a/addons/web_kanban_gauge/static/src/js/kanban_gauge.js b/addons/web_kanban_gauge/static/src/js/kanban_gauge.js index 84f5ada2f85..93dc6d746f0 100644 --- a/addons/web_kanban_gauge/static/src/js/kanban_gauge.js +++ b/addons/web_kanban_gauge/static/src/js/kanban_gauge.js @@ -6,7 +6,7 @@ var field_registry = require('web.field_registry'); var utils = require('web.utils'); /** - * Kanban widgets: GaugeWidget + * Field widget: GaugeWidget * options * - max_value: maximum value of the gauge [default: 100] * - max_field: get the max_value from the field that must be present in the @@ -26,7 +26,15 @@ var utils = require('web.utils'); var GaugeWidget = AbstractField.extend({ className: "oe_gauge", - render: function() { + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _render: function () { // parameters var max_value = this.nodeOptions.max_value || 100; if (this.nodeOptions.max_field) { @@ -124,4 +132,6 @@ var GaugeWidget = AbstractField.extend({ field_registry.add("gauge", GaugeWidget); +return GaugeWidget; + }); diff --git a/addons/web_kanban_gauge/static/tests/gauge_tests.js b/addons/web_kanban_gauge/static/tests/gauge_tests.js new file mode 100644 index 00000000000..e494d5391e5 --- /dev/null +++ b/addons/web_kanban_gauge/static/tests/gauge_tests.js @@ -0,0 +1,49 @@ +odoo.define('web_kanban_gauge.gauge_tests', function (require) { +"use strict"; + +var KanbanView = require('web.KanbanView'); +var testUtils = require('web.test_utils'); + +var createView = testUtils.createView; + +QUnit.module('fields', {}, function () { + +QUnit.module('basic_fields', { + beforeEach: function () { + this.data = { + partner: { + fields: { + int_field: {string: "int_field", type: "integer", sortable: true}, + }, + records: [ + {id: 1, int_field: 10}, + {id: 2, int_field: 4}, + ] + }, + }; + } +}, function () { + + QUnit.module('gauge widget'); + + QUnit.test('basic rendering', function (assert) { + assert.expect(1); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '' + + '
' + + '
', + }); + + assert.strictEqual(kanban.$('.o_kanban_record:first .oe_gauge svg').length, 1, + "should render the gauge widget"); + + kanban.destroy(); + }); + +}); +}); +}); diff --git a/addons/web_kanban_gauge/views/web_kanban_gauge_templates.xml b/addons/web_kanban_gauge/views/web_kanban_gauge_templates.xml index 93ac6cf2653..fbba1de789b 100644 --- a/addons/web_kanban_gauge/views/web_kanban_gauge_templates.xml +++ b/addons/web_kanban_gauge/views/web_kanban_gauge_templates.xml @@ -5,4 +5,10 @@ + + diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js index 1999b6f6f0c..4dba132497a 100644 --- a/addons/web_tour/static/src/js/tour_manager.js +++ b/addons/web_tour/static/src/js/tour_manager.js @@ -119,10 +119,13 @@ var RunningTourActionHelper = core.Class.extend({ if (values.consume_event === "input") { values.$element.trigger("keydown").val(text).trigger("keyup").trigger("input"); } else if (values.$element.is("select")) { - values.$element.children("option") - .prop("selected", false).removeProp("selected") - .filter(function () { return $(this).val() === text; }) - .prop("selected", true); + var $options = values.$element.children("option"); + $options.prop("selected", false).removeProp("selected"); + var $selectedOption = $options.filter(function () { return $(this).val() === text; }); + if ($selectedOption.length === 0) { + $selectedOption = $options.filter(function () { return $(this).text() === text; }); + } + $selectedOption.prop("selected", true); this._click(values); } else { values.$element.text(text); diff --git a/addons/website_portal_sale/views/website_portal_sale_templates.xml b/addons/website_portal_sale/views/website_portal_sale_templates.xml index 2f2f37f13f9..01884a10859 100644 --- a/addons/website_portal_sale/views/website_portal_sale_templates.xml +++ b/addons/website_portal_sale/views/website_portal_sale_templates.xml @@ -178,7 +178,7 @@
- +
diff --git a/doc/cla/individual/vincentadriaensen.md b/doc/cla/individual/vincentadriaensen.md new file mode 100644 index 00000000000..3fcaa1d1352 --- /dev/null +++ b/doc/cla/individual/vincentadriaensen.md @@ -0,0 +1,11 @@ +Belgium, 19/07/2017 + +I hereby agree to the terms of the Odoo Individual Contributor License +Agreement v1.0. + +I declare that I am authorized and able to make this agreement and sign this +declaration. + +Signed, + +Vincent Adriaensen vincentadriaensen@users.noreply.github.com https://github.com/vincentadriaensen diff --git a/odoo/addons/base/rng/view.rng b/odoo/addons/base/rng/view.rng index 0fb5a0ca9fc..3225735ffad 100644 --- a/odoo/addons/base/rng/view.rng +++ b/odoo/addons/base/rng/view.rng @@ -556,6 +556,7 @@ + diff --git a/odoo/addons/test_converter/models.py b/odoo/addons/test_converter/models.py index b6251a5df3f..55938946b34 100644 --- a/odoo/addons/test_converter/models.py +++ b/odoo/addons/test_converter/models.py @@ -21,12 +21,12 @@ class test_model(models.Model): (4, "réponse "), ]) selection_str = fields.Selection([ - ('A', "Qu'il n'est pas arrivé à Toronto"), - ('B', "Qu'il était supposé arriver à Toronto"), - ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"), - ('D', "La réponse D"), - ], string="Lorsqu'un pancake prend l'avion à destination de Toronto et " - "qu'il fait une escale technique à St Claude, on dit:") + ('A', u"Qu'il n'est pas arrivé à Toronto"), + ('B', u"Qu'il était supposé arriver à Toronto"), + ('C', u"Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"), + ('D', u"La réponse D"), + ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et " + u"qu'il fait une escale technique à St Claude, on dit:") html = fields.Html() text = fields.Text() diff --git a/odoo/addons/test_main_flows/__init__.py b/odoo/addons/test_main_flows/__init__.py new file mode 100644 index 00000000000..ac45264fad4 --- /dev/null +++ b/odoo/addons/test_main_flows/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +#from . import models diff --git a/odoo/addons/test_main_flows/__manifest__.py b/odoo/addons/test_main_flows/__manifest__.py new file mode 100644 index 00000000000..031e886e5c8 --- /dev/null +++ b/odoo/addons/test_main_flows/__manifest__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'Test Main Flow', + 'version': '1.0', + 'category': 'Tools', + 'description': """ +This mobule will test the main workflow of Odoo. +It will install some main apps and will try to execute the most important actions. +""", + 'depends': ['web_tour', 'crm', 'sale_timesheet', 'purchase', 'mrp', 'account_accountant'], + 'data': [ + 'views/templates.xml', + ], + 'installable': True, +} diff --git a/odoo/addons/test_main_flows/static/src/js/tour.js b/odoo/addons/test_main_flows/static/src/js/tour.js new file mode 100644 index 00000000000..107c5d62c65 --- /dev/null +++ b/odoo/addons/test_main_flows/static/src/js/tour.js @@ -0,0 +1,691 @@ +odoo.define('test_main_flows.tour', function(require) { +"use strict"; + +var core = require('web.core'); +var tour = require('web_tour.tour'); + +var _t = core._t; + +tour.register('main_flow_tour', { + test: true, + url: "/web", +}, [tour.STEPS.MENU_MORE, { + trigger: '.o_app[data-menu-xmlid="sales_team.menu_base_partner"], .oe_menu_toggler[data-menu-xmlid="sales_team.menu_base_partner"]', + content: _t('Organize your sales activities with the Sales app.'), + position: 'bottom', +}, { +// Add Stockable product + edition: "enterprise", + trigger: ".o_menu_sections a:contains('Catalog')", + extra_trigger: '.o_main_navbar', + content: _t("Let\'s create products."), + position: "bottom", +}, { + trigger: ".o_menu_sections a:has(span:contains('Products')), .oe_secondary_submenu .oe_menu_text:contains('Products'):first", + content: _t("Let\'s create products."), + position: "bottom" +}, { + trigger: '.o-kanban-button-new', + extra_trigger: '.o_kanban_view', + content: _t('Let\'s create your first product.'), + position: 'right', +}, { + trigger: 'input[name=name]', + extra_trigger: '.o_form_sheet', + content: _t('Let\'s enter the name.'), + position: 'left', + run: 'text the_flow.product', +}, { + trigger: "select[name=type]", + content: _t('Let\'s enter the product type'), + position: 'left', + run: 'text "product"', +}, { + trigger: '.o_notebook a:contains("Inventory")', + content: _t('Go to inventory tab'), + position: 'top', +}, { + trigger: '.o_field_widget[name=route_ids] .o_checkbox + label:contains("Manufacture")', + content: _t('Check Manufacture'), + position: 'right', +}, { + trigger: '.o_field_widget[name=route_ids] .o_checkbox + label:contains("Buy")', + content: _t('Uncheck Buy'), + position: 'right', +}, { + trigger: '.o_field_widget[name=route_ids] .o_checkbox + label:contains("Make To Order")', + content: _t('Uncheck Make To Order'), + position: 'right', +}, { + trigger: '.o_form_button_save', + content: _t('Save this product and the modifications you\'ve made to it.'), + position: 'bottom', +}, { + trigger: ".oe_button_box .oe_stat_button:has(div[name=bom_count])", + extra_trigger: '.o_form_readonly', + content: _t('See Bill of material'), + position: 'bottom', +}, { + trigger: ".o_list_button_add", + content: _t("Let's create a new bill of material"), + position: "right", +}, { +// Add first component + trigger: ".o_field_x2many_list_row_add > a", + extra_trigger: ".o_form_editable", + content: _t("Click here to add some lines."), + position: "bottom", +}, { + trigger: ".o_selected_row .o_required_modifier[name=product_id] input", + content: _t("Select a product, or create a new one on the fly."), + position: "right", + run: "text the_flow.component1", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.component1')", + auto: true, +}, { +// Edit first component + trigger: ".o_selected_row .o_external_button", + content: _t("Click here to edit your component"), + position: "right", +}, { + trigger: '.o_notebook a:contains("Inventory")', + content: _t('Go to inventory tab'), + position: 'top', +}, { + trigger: '.o_field_widget[name=route_ids] .o_checkbox + label:contains("Make To Order")', + content: _t('Check Make To Order'), + position: 'right', +}, { + trigger: ".o_field_widget[name=seller_ids] .o_field_x2many_list_row_add > a", + content: _t('Let\'s enter the cost price'), + position: 'right', +}, { + trigger: ".o_field_widget[name=name] input", + extra_trigger: ".modal-dialog", + content: _t('Select a seller'), + position: 'top', + run: "text the_flow.vendor", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.vendor')", + in_modal: false, +}, { + trigger: ".o_field_widget[name=price]", + content: _t('Set the cost price'), + position: 'right', + run: "text 1", +}, { + trigger: ".modal-footer .btn-primary:first", + extra_trigger: ".o_field_widget[name=name] > .o_external_button", // Wait name_create + content: _t('Save & Close'), + position: 'bottom', +}, { + trigger: ".modal-footer .btn-primary", + content: _t('Save'), + position: 'bottom', +}, { +// Add second component + trigger: ".o_field_x2many_list_row_add > a", + in_modal: false, + content: _t("Click here to add some lines."), + position: "bottom", +}, { + trigger: ".o_selected_row .o_required_modifier[name=product_id] input", + extra_trigger: '.o_field_widget[name=bom_line_ids] .o_data_row:nth(1).o_selected_row', + content: _t("Select a product, or create a new one on the fly."), + position: "right", + run: "text the_flow.component2", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.component2')", + auto: true, +}, { +// Edit second component + trigger: ".o_selected_row .o_external_button", + content: _t("Click here to edit your component"), + position: "right", +}, { + trigger: '.o_notebook a:contains("Inventory")', + content: _t('Go to inventory tab'), + position: 'top', +}, { + trigger: ".o_field_widget[name=seller_ids] .o_field_x2many_list_row_add > a", + content: _t('Let\'s enter the cost price'), + position: 'right', +}, { + trigger: ".o_field_widget[name=name] input", + extra_trigger: ".modal-dialog", + content: _t('Select a seller'), + position: 'top', + run: "text the_flow.vendor", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.vendor')", + auto: true, + in_modal: false, +}, { + trigger: ".o_field_widget[name=price]", + content: _t('Set the cost price'), + position: 'right', + run: "text 1", +}, { + trigger: ".modal-footer .btn-primary:first", + content: _t('Save & Close'), + position: 'bottom', +}, { + trigger: ".modal-footer .btn-primary", + // Wait Save & Close and check value + extra_trigger: ".o_field_widget[name=seller_ids] .o_data_row td:nth-child(2):contains('the_flow.vendor')", + content: _t('Save'), + position: 'bottom', +}, { + trigger: '.o_form_button_save', + extra_trigger: ".o_field_widget[name=bom_line_ids] .o_list_view tr:nth-child(3):has(.o_field_x2many_list_row_add)", + content: _t('Save the bom.'), + position: 'bottom', +}, { + trigger: ".breadcrumb li:first", + extra_trigger: ".o_form_readonly", // FIXME: this is required due to an issue in tour_manager (see [*]) + content: _t("Use the breadcrumbs to go back to products."), + position: "bottom" +}, { +// Add service product + trigger: '.o-kanban-button-new', + extra_trigger: '.o_kanban_view', + content: _t('Let\'s create your second product.'), + position: 'right', +}, { + trigger: 'input[name=name]', + extra_trigger: '.o_form_sheet', + content: _t('Let\'s enter the name.'), + position: 'left', + run: 'text the_flow.service', +}, { + trigger: '.o_field_widget[name=type]', + content: _t('Set to service'), + position: 'left', + run: 'text "service"', +}, { + trigger: '.o_notebook a:contains("Invoicing")', + content: _t('Go to invoicing tab'), + position: 'bottom', +}, { + trigger: '.o_field_widget[name=invoice_policy] .o_radio_input[data-value="delivery"]', + content: _t('Change invoicing policy'), + position: 'left', +}, { + trigger: '.o_field_widget[name=track_service] input[data-value="task"]', + content: _t('Change track service'), + position: 'left', +}, { + trigger: '.o_field_widget[name=project_id] input', + content: _t('Choose project'), + position: 'left', + run: 'text the_flow.project', +}, { + trigger: ".ui-menu-item > a:contains('the_flow.project')", + auto: true, +}, { + trigger: '.o_form_button_save', + extra_trigger: '.o_field_widget[name=project_id] > .o_external_button', // Wait name_create + content: _t('Save this product and the modifications you\'ve made to it.'), + position: 'bottom', +}, { +// Create an opportunity + edition: "enterprise", + trigger: '.o_menu_toggle', + content: _t('Go back to the app switcher'), + position: 'bottom', +}, { + trigger: '.o_app[data-menu-xmlid="sales_team.menu_base_partner"], .oe_menu_toggler[data-menu-xmlid="sales_team.menu_base_partner"]', + content: _t('Organize your sales activities with the Sales app.'), + position: 'bottom', +}, { + trigger: ".o_sales_dashboard .o_dashboard_action[name='crm.action_your_pipeline']:last, .oe_secondary_submenu .oe_menu_text:contains('My Pipeline'):first", + content: _t("Let\'s have a look at your opportunities pipeline."), + position: "bottom" +}, { + trigger: ".o-kanban-button-new", + extra_trigger: '.o_opportunity_kanban', + content: _t("Click here to create your first opportunity and add it to your pipeline."), + position: "right" +}, { + trigger: ".modal-body input:first", + content: _t("Enter the opportunity title."), + position: "right", + run: "text the_flow.opportunity", +}, { + trigger: ".o_field_widget[name=partner_id] input", + content: _t("Write the name of your customer to create one on the fly, or select an existing one."), + position: "left", + run: "text the_flow.customer", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.customer')", + in_modal: false, + auto: true, +}, { + trigger: ".modal-footer .btn-primary", + extra_trigger: ".o_field_widget[name=partner_id] > .o_external_button", // Wait name_create + content: _t("Create"), + position: "bottom", +}, { + trigger: ".o_kanban_group:first-child .o_kanban_record:last-child", + content: _t("Drag & drop opportunities between columns as you progress in your sales cycle."), + position: "right", + run: "drag_and_drop .o_opportunity_kanban .o_kanban_group:eq(2) ", +}, { + trigger: ".o_kanban_record:has(span:contains('the_flow.opportunity'))", + extra_trigger: ".o_kanban_group:eq(2) > .o_kanban_record:has(span:contains('the_flow.opportunity'))", // FIXME: this is required due to an issue in tour_manager (see [*]) + content: _t("Click on an opportunity to zoom to it."), + position: "bottom", +}, { +// Create a quotation + trigger: ".o_statusbar_buttons > button:contains('New Quotation')", + content: _t('

Create a quotation

'), + position: "right" +}, { + trigger: ".o_field_widget[name=order_line] .o_field_x2many_list_row_add > a", + content: _t("Click here to add some lines to your quotations."), + position: "bottom", +}, { + trigger: ".o_field_widget[name=product_id] input", + content: _t("Select a product, or create a new one on the fly. The product will define the default sales price (that you can change), taxes and description automatically."), + position: "right", + run: "text the_flow.product", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.product')", + auto: true, + in_modal: false, + run: function (actions) { + actions.auto(); + // if the one2many isn't editable, we have to close the dialog + if ($(".modal-footer .btn-primary").length) { + actions.auto(".modal-footer .btn-primary"); + } + }, +}, { + trigger: ".o_field_widget[name=order_line] .o_field_x2many_list_row_add > a", + content: _t("Click here to add some lines to your quotations."), + position: "bottom", +}, { + trigger: ".o_field_widget[name=product_id] input", + // the one2many may be editable or not according to the modules installed, so + // we have to handle both cases + extra_trigger: '.o_field_widget[name=order_line] .o_data_row:nth(1).o_selected_row, .modal-dialog', + content: _t("Select a product"), + position: "right", + run: "text the_flow.service", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.service')", + auto: true, + in_modal: false, + run: function (actions) { + actions.auto(); + // if the one2many isn't editable, we have to close the dialog + if ($(".modal-footer .btn-primary").length) { + actions.auto(".modal-footer .btn-primary"); + } + }, +}, { + trigger: ".o_statusbar_buttons > button.o_sale_print", + content: _t("

Print this quotation.

"), + position: "bottom" +}, { + trigger: ".o_statusbar_buttons > button:contains('Send by Email')", + extra_trigger: ".o_statusbar_status .btn-primary:contains('Quotation Sent')", + content: _t("Try to send it to email"), + position: "bottom", +}, { + trigger: ".o_field_widget[name=email]", + content: _t("Enter an email address"), + position: "right", + run: "text test@the_flow.com", +}, { + trigger: ".modal-footer .btn-primary", + content: _t("Save your changes"), + position: "bottom", +}, { + trigger: ".modal-footer .btn-primary span:contains('Send')", + content: _t("Try to send it to email"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button:contains('Confirm Sale')", + content: _t("

Confirm this quotation

"), + position: "bottom" +}, { + trigger: ".o_form_button_save", + extra_trigger: ".o_statusbar_status .btn-primary:contains('Sales Order')", + content: _t("

Save this quotation

"), + position: "bottom" +// Reordering rule +}, { + edition: "enterprise", + trigger: '.o_menu_toggle', + content: _t('Go back to the app switcher'), + position: 'bottom', +}, { + trigger: '.o_app > div:contains("Inventory"), .oe_menu_toggler:contains("Inventory")', + content: _t('Go to Inventory'), + position: 'bottom', +}, { + edition: "enterprise", + trigger: ".o_menu_sections a:contains('Inventory Control')", + content: _t("Go to Inventory Control"), + position: "bottom" +}, { + trigger: ".o_menu_sections a:has(span:contains('Reordering Rules')), .oe_secondary_submenu .oe_menu_text:contains('Reordering Rules'):first", + content: _t("Reordering Rules"), + position: "bottom" +}, { + trigger: ".o_list_button_add", + content: _t("Let's create a new reordering rule"), + position: "right", +}, { + trigger: ".o_field_widget[name=product_id] input", + content: _t("Write the name of your product."), + position: "top", + run: "text the_flow.component2", +}, { + trigger: ".ui-menu-item > a:contains('the_flow.component2')", + auto: true, +}, { + trigger: ".o_field_widget[name=product_min_qty]", + content: _t("Set the minimum product quantity"), + position: "right", + run: 'text 1', +}, { + trigger: ".o_field_widget[name=product_max_qty]", + content: _t("Set the maximum product quantity"), + position: "right", + run: 'text 10', +}, { + trigger: ".o_form_button_save", + content: _t("

Save this reordering rule

"), + position: "bottom" +}, { +// Run the schedulers + edition: "enterprise", + trigger: ".o_menu_sections a:contains('Schedulers')", + content: _t("Go to Schedulers"), + position: "bottom" +}, { + trigger: ".o_menu_sections a:has(span:contains('Run Schedulers')), .oe_secondary_submenu .oe_menu_text:contains('Run Schedulers'):first", + content: _t("Run the schedulers"), + position: "bottom" +}, { + trigger: ".modal-footer .btn-primary", + extra_trigger: ".modal-dialog", + content: _t("Run Schedulers"), + position: "bottom", +}, { +//Go to purchase: + edition: "enterprise", + trigger: '.o_menu_toggle', + content: _t('Go back to the app switcher'), + position: 'bottom', +}, { + trigger: '.o_app > div:contains("Purchases"), .oe_menu_toggler:contains("Purchases")', + content: _t('Go to Purchase'), + position: 'bottom', +}, { + trigger: '.o_data_row:has(.o_data_cell:contains("the_flow.vendor"))', + content: _t('Select the generated request for quotation'), + position: 'bottom', +}, { + trigger: ".o_statusbar_buttons > button:contains('Confirm Order')", + content: _t("Confirm quotation"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button:contains('Receive Products')", + content: _t("Receive Product"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button:contains('Validate')", + content: _t("Validate"), + position: "bottom", +}, { + trigger: ".modal-footer .btn-primary", + extra_trigger: ".modal-dialog", + content: _t("Apply"), + position: "bottom", +}, { + trigger: ".o_back_button a, .breadcrumb li:not('.active'):last", + content: _t('go back to the purchase order'), + position: 'bottom', + }, { + trigger: ".oe_button_box .oe_stat_button:has(div[name=invoice_count])", + content: _t('go to Vendor Bills'), + position: 'bottom', +}, { + trigger: ".o_list_button_add", + content: _t("Let's create a new vendor bill"), + position: "right", +}, { + trigger: ".o_statusbar_buttons > button:contains('Validate')", + content: _t("Try to send it to email"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button:contains('Register Payment')", + content: _t("Register Payment"), + position: "bottom", +}, { + trigger: "select.o_field_widget[name=journal_id]", + extra_trigger: ".modal-dialog", + content: _t("Select Journal"), + position: "bottom", + run: 'text(Bank (USD))', +}, { + trigger: ".modal-footer .btn-primary", + extra_trigger: ".o_field_widget[name=payment_method_id]", // FIXME: Wait onchange + content: _t("Validate"), + position: "bottom", +}, { + edition: "enterprise", + trigger: '.o_menu_toggle', + content: _t('Go back to the app switcher'), + position: 'bottom', +}, { + trigger: '.o_app > div:contains("Manufacturing"), .oe_menu_toggler:contains("Manufacturing")', + content: _t('Go to Manufacturing'), + position: 'bottom', +}, { + trigger: '.o_data_row:has(.o_data_cell:contains("the_flow.product")):first', + content: _t('Select the generated manufacturing order'), + position: 'bottom', +}, { + trigger: ".o_statusbar_buttons > button:contains('Check availability')", + content: _t("Check availability"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button.oe_highlight:contains('Produce')", + content: _t("Produce"), + position: "bottom", +}, { + trigger: ".modal-footer .btn-primary:first", + content: _t('Record Production'), + position: 'bottom', +}, { + trigger: ".o_statusbar_buttons > button:contains('Mark as Done')", + content: _t("Mark as Done"), + position: "bottom", +}, { + edition: "enterprise", + trigger: '.o_menu_toggle', + content: _t('Go back to the app switcher'), + position: 'bottom', +}, { + trigger: '.o_app[data-menu-xmlid="sales_team.menu_base_partner"], .oe_menu_toggler[data-menu-xmlid="sales_team.menu_base_partner"]', + content: _t('Organize your sales activities with the Sales app.'), + position: 'bottom', +}, { + edition: "enterprise", + trigger: ".o_menu_sections a[data-menu-xmlid='sales_team.menu_sales']", + content: _t("Go to Sales menu"), + position: "bottom" +}, { + trigger: ".o_menu_sections a:has(span:contains('Sales Orders')), .oe_secondary_submenu .oe_menu_text:contains('Sales Orders'):first", + content: _t("Go to the sales orders"), + position: "bottom" +}, { + trigger: ".o_data_row:first", + extra_trigger: '.o_control_panel > .breadcrumb:contains("Sales Orders")', + content: _t("Go to the last sale order"), + position: "right" +}, { + trigger: '.oe_button_box .oe_stat_button:has(div[name=tasks_count])', + content: _t('See Tasks'), + position: 'right', +}, { + trigger: '.o_field_widget[name=project_id]', + content: _t('See Project'), + position: 'right', +}, { + trigger: '.oe_button_box .oe_stat_button:has(span:contains("Timesheets"))', + extra_trigger: '.o_form_readonly', + content: _t('See Timesheets'), + position: 'bottom', +}, { + trigger: '.o_list_button_add', + content: _t('Add a Timesheet'), + position: 'bottom', +}, { + trigger: '.o_selected_row input[name=name]', + content: _t('Set description'), + position: 'bottom', + run: 'text 10 hours', +}, { + trigger: '.o_selected_row .o_field_widget[name=task_id] input', + content: _t('Choose a task'), + position: 'bottom', + run: 'click', +}, { + trigger: ".ui-menu-item > a", + auto: true, +}, { + trigger: '.o_selected_row input[name=unit_amount]', + content: _t('Set time'), + position: 'bottom', + run: 'text 10', +}, { + trigger: '.o_list_button_save', + content: _t('Save'), + position: 'bottom', +}, { + trigger: '.breadcrumb li:nth-child(2) a', + extra_trigger: '.o_list_button_add', // Waiting save + content: _t('Back to the sale order'), + position: 'bottom', +}, { + trigger: ".o_statusbar_buttons > button:contains('Create Invoice')", + content: _t("Validate"), + position: "bottom", +}, { + trigger: ".modal-footer .btn-primary", + content: _t("Create and View Invoices"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button:contains('Validate')", + content: _t("Validate"), + position: "bottom", +}, { + trigger: ".o_statusbar_buttons > button:contains('Register Payment')", + content: _t("Register Payment"), + position: "bottom", +}, { + trigger: "select.o_field_widget[name=journal_id]", + extra_trigger: ".modal-dialog", + content: _t("Select Journal"), + position: "bottom", + run: 'text(Bank (USD))', +}, { + trigger: ".modal-footer .btn-primary", + content: _t("Validate"), + position: "bottom", +}, { + edition: "enterprise", + trigger: '.o_menu_toggle', + content: _t('Go back to the app switcher'), + position: 'bottom', +}, { + edition: "enterprise", + trigger: '.o_app[data-menu-xmlid="account.menu_finance"], .oe_menu_toggler[data-menu-xmlid="account.menu_finance"]', + content: _t('Go to Accounting'), + position: 'bottom', +}, { + edition: "enterprise", + trigger: 'div[name=bank_journal_default_cta] > a[data-name=create_bank_statement], div[name=bank_journal_cta] > a[data-name=create_bank_statement]', + content: _t('Create a new bank statement'), + position: 'bottom', +}, { + edition: "enterprise", + trigger: 'input[name=name]', + content: _t('Let\'s enter the reference.'), + position: 'left', + run: 'text the_flow.statement', +}, { + edition: "enterprise", + trigger: ".o_field_widget[name=balance_end_real] input", + content: _t('Let\'s calculate the ending balance.'), + position: 'right', + run: 'text 9010.85', // + 12.65 +}, { + edition: "enterprise", + trigger: ".o_field_widget[name=line_ids] .o_field_x2many_list_row_add > a", + content: _t("Click here to add some lines."), + position: "bottom", +}, { + edition: "enterprise", + trigger: ".o_selected_row .o_field_widget[name=amount] input", + content: _t("Write the amount received."), + position: "bottom", + run: "text 12.65", +}, { + edition: "enterprise", + trigger: ".o_selected_row .o_field_widget[name=partner_id] input", + content: _t("Write the name of your customer."), + position: "bottom", + run: "text the_flow.customer", +}, { + edition: "enterprise", + trigger: ".ui-menu-item > a:contains('the_flow.customer')", + auto: true, +}, { + edition: "enterprise", + trigger: ".o_selected_row .o_field_widget[name=name]", + extra_trigger: ".o_selected_row .o_field_widget[name=partner_id] .o_external_button", // FIXME: this is required due to an issue in tour_manager (see [*]) + content: _t('Let\'s enter a name.'), + position: "bottom", + run: "text the_flow.statement.line", +}, { + edition: "enterprise", + trigger: '.o_form_button_save', + content: _t('Save.'), + position: 'bottom', +}, { + edition: "enterprise", + trigger: ".o_statusbar_buttons > button:contains('Reconcile')", + content: _t('

Reconcile

'), + position: "bottom", +}, { + edition: "enterprise", + trigger: "button.o_reconcile", + content: _t('

Click on Reconcile

'), + position: "right", +}, { + edition: "enterprise", + trigger: ".button_close_statement", + content: _t('

Close this statement.

'), + position: "bottom", +}]); +}); + +/* + * [*] FIXME: issue in tour_manager: + * The JQuery element of a step is registered as soon as its trigger and + * extra_trigger elements are visible in the DOM, but it's action is delayed + * to handle the 'running_step_delay' option (0 by default, but even with 0, + * the action is executed after finishing the current execution block, which + * may produce a re-rendering of the part of the DOM containing the element). + * When this happens, the action is executed on an jquery element not present + * in the DOM anymore. + * To avoid this, we add an 'extra_trigger' to wait for the updated jquery + * element before activating the step (and thus registering the JQuery element). + */ diff --git a/odoo/addons/test_main_flows/tests/__init__.py b/odoo/addons/test_main_flows/tests/__init__.py new file mode 100644 index 00000000000..ad09d038804 --- /dev/null +++ b/odoo/addons/test_main_flows/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import test_flow diff --git a/odoo/addons/test_main_flows/tests/test_flow.py b/odoo/addons/test_main_flows/tests/test_flow.py new file mode 100644 index 00000000000..bf9305efec5 --- /dev/null +++ b/odoo/addons/test_main_flows/tests/test_flow.py @@ -0,0 +1,11 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import odoo.tests + + +@odoo.tests.common.at_install(False) +@odoo.tests.common.post_install(True) +class TestUi(odoo.tests.HttpCase): + + def test_01_main_flow_tour(self): + self.phantom_js("/web", "odoo.__DEBUG__.services['web_tour.tour'].run('main_flow_tour')", "odoo.__DEBUG__.services['web_tour.tour'].tours.main_flow_tour.ready", login="admin", timeout=180) diff --git a/odoo/addons/test_main_flows/views/templates.xml b/odoo/addons/test_main_flows/views/templates.xml new file mode 100644 index 00000000000..9c1bd81699f --- /dev/null +++ b/odoo/addons/test_main_flows/views/templates.xml @@ -0,0 +1,8 @@ + + + + diff --git a/odoo/netsvc.py b/odoo/netsvc.py index a33e3bcaf27..7772d7d47cd 100644 --- a/odoo/netsvc.py +++ b/odoo/netsvc.py @@ -89,6 +89,7 @@ def init_logger(): _logger_init = True logging.addLevelName(25, "INFO") + logging.captureWarnings(True) from .tools.translate import resetlocale resetlocale() diff --git a/odoo/service/server.py b/odoo/service/server.py index 6e4b05f3dcf..56acaaed901 100644 --- a/odoo/service/server.py +++ b/odoo/service/server.py @@ -75,12 +75,11 @@ class BaseWSGIServerNoBind(LoggingBaseWSGIServerMixIn, werkzeug.serving.BaseWSGI use this class, sets the socket and calls the process_request() manually """ def __init__(self, app): - werkzeug.serving.BaseWSGIServer.__init__(self, "1", "1", app) - def server_bind(self): - # we dont bind beause we use the listen socket of PreforkServer#socket - # instead we close the socket + werkzeug.serving.BaseWSGIServer.__init__(self, "127.0.0.1", 0, app) + # Directly close the socket. It will be replaced by WorkerHTTP when processing requests if self.socket: self.socket.close() + def server_activate(self): # dont listen as we use PreforkServer#socket pass @@ -583,6 +582,7 @@ class PreforkServer(CommonServer): if self.address: # listen to socket + _logger.info('HTTP service (werkzeug) running on %s:%s', *self.address) self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.setblocking(0)