From 1e3e3b4bbe8f2152884af62c5119b0b19143be92 Mon Sep 17 00:00:00 2001 From: Vincent Adriaensen Date: Wed, 19 Jul 2017 13:34:34 +0200 Subject: [PATCH 01/25] [CLA] signature for vincentadriaensen Done at #18332 --- doc/cla/individual/vincentadriaensen.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 doc/cla/individual/vincentadriaensen.md 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 From 3d6420d4b3f062f12d7c18d7d9a976e2299b3444 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Tue, 25 Jul 2017 13:27:07 +0200 Subject: [PATCH 02/25] [FIX] web,*: display dashboard panel if no content *board,crm Before this rev., the dashboard panel (e.g. of the sales team dashboard) wasn't displayed when there were no record to display, whereas it should. Only the nocontent helper was displayed. This was a bug introduced with the new view: the views' controller was in charge to display the renderer (if there were data to display) xor the nocontent helper. As the dashboard panel was rendered by the renderer, it couldn't be displayed alongside the nocontent helper. This rev. moves this logic to the renderer. So now, the controller always contains the renderer, which decides what to render (the data or the nocontent helper). This way, the dashboards can display their panel and the nocontent helper. --- addons/board/static/src/js/dashboard.js | 1 - .../tests/sales_team_dashboard_tests.js | 35 ++++++ .../src/js/views/abstract_controller.js | 27 ----- .../static/src/js/views/abstract_renderer.js | 2 + .../web/static/src/js/views/abstract_view.js | 2 +- .../src/js/views/basic/basic_controller.js | 11 -- .../src/js/views/basic/basic_renderer.js | 24 ++++ .../src/js/views/kanban/kanban_controller.js | 24 ---- .../src/js/views/kanban/kanban_renderer.js | 106 ++++++++++++------ .../src/js/views/list/list_controller.js | 5 +- .../static/src/js/views/list/list_renderer.js | 15 ++- .../src/js/views/pivot/pivot_controller.js | 15 +-- .../src/js/views/pivot/pivot_renderer.js | 22 +++- addons/web/static/tests/views/kanban_tests.js | 2 +- 14 files changed, 173 insertions(+), 118 deletions(-) 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/static/tests/sales_team_dashboard_tests.js b/addons/crm/static/tests/sales_team_dashboard_tests.js index d4da44e16a7..a4430d29a52 100644 --- a/addons/crm/static/tests/sales_team_dashboard_tests.js +++ b/addons/crm/static/tests/sales_team_dashboard_tests.js @@ -133,4 +133,39 @@ QUnit.test('dashboard: click on a button to execute an action', function (assert kanban.destroy(); }); +QUnit.test('dashboard should be displayed even if there is no content', function (assert) { + assert.expect(2); + + var dashboardData = this.dashboard_data; + var kanban = createView({ + View: view_registry.get('sales_team_dashboard'), + model: 'partner', + data: this.data, + arch: '' + + '' + + '
' + + '
' + + '
', + 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/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 e14e064ee33..74e1e1fce18 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -77,6 +77,7 @@ var AbstractView = Class.extend({ init: function (viewInfo, params) { this.rendererParams = { arch: viewInfo.arch, + noContentHelp: params.action && params.action.help, }; this.controllerParams = { @@ -87,7 +88,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_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/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_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/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 7aac83f26f8..712cce02067 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -953,7 +953,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, From 72ed3a153a1f4c718fee33ac8550298e8a29f6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Wed, 26 Jul 2017 11:27:11 +0200 Subject: [PATCH 03/25] [FIX] web,pad: record is not considered dirty when pad is loaded The pad widget does something unconventional: it creates the pad url only when rendering in edit mode. This was done to prevent creating pad by default for record in readonly mode. Because of that, if the user clicks on create, then discard, the record was considered dirty, so a confirmation modal popped up. In this commit, we just make sure that the initial setValue from the rendering does not 'dirty' the record. --- addons/pad/static/src/js/pad.js | 2 +- addons/pad/static/tests/pad_tests.js | 36 +++++++++++++++++++ .../static/src/js/fields/abstract_field.js | 6 ++++ .../static/src/js/views/basic/basic_model.js | 25 ++++++++----- .../src/js/views/field_manager_mixin.js | 3 +- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/addons/pad/static/src/js/pad.js b/addons/pad/static/src/js/pad.js index 9637072274e..b59f3f0bc04 100644 --- a/addons/pad/static/src/js/pad.js +++ b/addons/pad/static/src/js/pad.js @@ -111,7 +111,7 @@ 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._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..2aa8f7fe1db 100644 --- a/addons/pad/static/tests/pad_tests.js +++ b/addons/pad/static/tests/pad_tests.js @@ -148,4 +148,40 @@ QUnit.module('pad widget', { 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/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/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 8f679207f00..73cd2863f21 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 || {})); }); 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 () { From ecdcc65a9080fee876ca9cd82ab6385988557205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 27 Jul 2017 10:30:40 +0200 Subject: [PATCH 04/25] [FIX] pad: force writing url, even if it did not change Before this commit, the pad widget did not force the view to save the current value, since it did not change (pad values are just the url for the pad). However, this is a problem for the server, since it uses the fact that the web client forces a save to update the description field in the model (get the data from the pad server and add it to the database). This was not a big deal when looking at the pad (the pad widget fetches its data each time), but it is a problem that the database is not synchronized with the current data. --- addons/pad/static/src/js/pad.js | 24 ++++++++++++++++++++++-- addons/pad/static/tests/pad_tests.js | 12 +++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/addons/pad/static/src/js/pad.js b/addons/pad/static/src/js/pad.js index b59f3f0bc04..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,6 +130,7 @@ 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.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 2aa8f7fe1db..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,6 +151,9 @@ 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; }); From 4fdfdb2f8087a83140642be8b607409cde4e0264 Mon Sep 17 00:00:00 2001 From: Nicolas Martinelli Date: Thu, 27 Jul 2017 13:19:54 +0200 Subject: [PATCH 05/25] [FIX] mrp: domain in picking type Limit the picking type domain to 'Manufacturing Operation'. opw-760246 --- addons/mrp/views/mrp_production_views.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 46546c4e5e0..1886d119b6e 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -150,7 +150,7 @@ - + From 48c478513f791849c8e5b769b5bc2460ba284ae7 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Thu, 27 Jul 2017 13:39:08 +0200 Subject: [PATCH 06/25] [IMP] core: log address we listen on in multi-worker mode --- openerp/service/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openerp/service/server.py b/openerp/service/server.py index 60068a6c06e..3cfbd3a2834 100644 --- a/openerp/service/server.py +++ b/openerp/service/server.py @@ -610,6 +610,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) From e6b91eb55a129687ece64d635ea3412446d78128 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Thu, 27 Jul 2017 13:40:19 +0200 Subject: [PATCH 07/25] [FIX] core: werkzeug 0.12 compatibility Since werkzeug 0.12, the `BaseWSGIServer.__init__` method expect the socket to be bound to determine the effective port it listen to [1]. Stop pretend to bind the socket and close it directly after use. [1] see pallets/werkzeug@1fc28cbb30799bf6a09d998aa33f489c5e5a1a71 --- openerp/service/server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openerp/service/server.py b/openerp/service/server.py index 3cfbd3a2834..58e0345385d 100644 --- a/openerp/service/server.py +++ b/openerp/service/server.py @@ -66,12 +66,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 From a71f22370217d2da6ad13bd29c2fa412a40ebe4c Mon Sep 17 00:00:00 2001 From: Adrien Dieudonne Date: Tue, 25 Jul 2017 11:34:11 +0200 Subject: [PATCH 08/25] [FIX] web: InputField: don't re-render if dirty Let's assume that we have a form view with a one2many field displayed as a list containing two fields A and B (an input field, e.g. a char field). An onchange on the one2many is triggered as soon as A changes. This triggers a reset of the one2many which thus redraws the corresponding line by resetting its widgets. A problem occured when the user updated A, and directly (before the onchange returned) updated the input field B, as in this case the new value in B was erased when the onchanged returned, as the widget was reset with its former value (or with the value returned by the onchange, if any). Note that the model was aware of the change on B, so the model and the UI were actually desynchronized. This was quite hard to reproduce in practice, by hand, but it occured in the tour testing 'the flow', in the form view of account.bank_statement, with fields partner_id and amount of the line_ids one2many. With this rev., we don't re-render the input fields if there are pending changes that haven't been acknowledged by the model. --- .../web/static/src/js/fields/basic_fields.js | 33 ++++- .../static/tests/fields/basic_fields_tests.js | 116 ++++++++++++++++++ 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index ecf8661a384..0cb08b55d02 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/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index c732fe35ce6..8f1c04b6bb8 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -831,6 +831,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) { From 17a9e455669397c6be84ecc28a86f4196576badc Mon Sep 17 00:00:00 2001 From: Adrien Dieudonne Date: Wed, 26 Jul 2017 11:17:08 +0200 Subject: [PATCH 09/25] [FIX] web_tour: tour_manager: choose option for select element Before this commit, it was only possible to set an option with his value. For example: The only way to set the first option was to do the following: run: "text 1" With this commit, we can now select the first option with his text value: run "test One" We need this because in 'main_flow_tour', some values are generated ids. --- addons/web_tour/static/src/js/tour_manager.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/addons/web_tour/static/src/js/tour_manager.js b/addons/web_tour/static/src/js/tour_manager.js index 829c06e7dc5..8d6d35c102f 100644 --- a/addons/web_tour/static/src/js/tour_manager.js +++ b/addons/web_tour/static/src/js/tour_manager.js @@ -146,10 +146,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); From e989d6333c515d55d0552cc62357aeedd157dce7 Mon Sep 17 00:00:00 2001 From: Adrien Dieudonne Date: Wed, 26 Jul 2017 11:17:24 +0200 Subject: [PATCH 10/25] [ADD] test_main_flows This module defines tours meant to test some cross-app workflows in Odoo. At this time, it contains a 'tour' that do some important tasks like: - product creation - sale order creation - reordering rule - invoicing - reconciliation - ... --- odoo/addons/test_main_flows/__init__.py | 4 + odoo/addons/test_main_flows/__manifest__.py | 16 + .../test_main_flows/static/src/js/tour.js | 691 ++++++++++++++++++ odoo/addons/test_main_flows/tests/__init__.py | 3 + .../addons/test_main_flows/tests/test_flow.py | 11 + .../test_main_flows/views/templates.xml | 8 + 6 files changed, 733 insertions(+) create mode 100644 odoo/addons/test_main_flows/__init__.py create mode 100644 odoo/addons/test_main_flows/__manifest__.py create mode 100644 odoo/addons/test_main_flows/static/src/js/tour.js create mode 100644 odoo/addons/test_main_flows/tests/__init__.py create mode 100644 odoo/addons/test_main_flows/tests/test_flow.py create mode 100644 odoo/addons/test_main_flows/views/templates.xml 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 @@ + + + + From c3691a79cb878d4f00617e43e76ad77f1b6076f2 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 27 Jul 2017 13:43:32 +0200 Subject: [PATCH 11/25] [FIX] web: grouped list: update row after edition Grouped lists aren't editable. However, they may contain a widget allowing the edition in readonly (e.g. priority, toggle_button...). This is for example the case in Leaves > Reporting > Leave Details. When the toggle button is clicked, the model is correctly notified of the change, which is correctly sent to the server. However, before this rev., the edited row wasn't redrawn in the grouped list, so the former value of the boolean was still displayed. --- .../js/views/list/list_editable_renderer.js | 25 +++++++++++++++-- addons/web/static/tests/views/list_tests.js | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) 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/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(); + }); }); }); From 1efca8d0738a259fbb7ebc12738e088908f53542 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 27 Jul 2017 15:06:43 +0200 Subject: [PATCH 12/25] [FIX] web_kanban_gauge: correctly render gauge widget Before this rev., nothing was rendered when the 'gauge' widget was set on a field (e.g. Sales > Leads Management > Team Assignation), because the API of the fields in web evolved a lot recently, and the rendering should now be done in a private function called '_render', not 'render'. --- .../static/src/js/kanban_gauge.js | 14 +++++- .../static/tests/gauge_tests.js | 49 +++++++++++++++++++ .../views/web_kanban_gauge_templates.xml | 6 +++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 addons/web_kanban_gauge/static/tests/gauge_tests.js 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 @@ + + From 66c22ab20d08416124baaa43943398a5e22b46b2 Mon Sep 17 00:00:00 2001 From: Nicolas Martinelli Date: Thu, 27 Jul 2017 15:20:46 +0200 Subject: [PATCH 13/25] [FIX] crm: Leads Analysis report The Leads Analysis report should filter on "Leads" by default. opw-760265 --- addons/crm/i18n/crm.pot | 3 +-- addons/crm/report/crm_opportunity_report_views.xml | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/addons/crm/i18n/crm.pot b/addons/crm/i18n/crm.pot index 66583c1496a..f59841f7aa2 100644 --- a/addons/crm/i18n/crm.pot +++ b/addons/crm/i18n/crm.pot @@ -3853,8 +3853,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 7b5e3720a93..636e872f6c5 100644 --- a/addons/crm/report/crm_opportunity_report_views.xml +++ b/addons/crm/report/crm_opportunity_report_views.xml @@ -188,13 +188,12 @@ with the the sales teams. 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. Date: Thu, 27 Jul 2017 11:19:05 +0200 Subject: [PATCH 14/25] [FIX] purchase : procurement check fail if one move in cancel Use case to reproduce the bug: One procurement is running for a PO. Cancel one move in the picking and create it again. Pass the picking in done. --> The procurement stay in running state It happens because the check method of procurement look if all moves linked are in done state but in this case, there is one move in cancel and one move in done. This commit check instead if all move are in cancel or done and at least one is in done state. opw-753177 --- addons/purchase/models/purchase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index d9f26d54b77..66b2b8304b7 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -918,7 +918,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): From 4e12edc023dd20d435f5575b5517a3c11deb8421 Mon Sep 17 00:00:00 2001 From: Khoi Nguyen Date: Wed, 26 Jul 2017 16:03:39 +0200 Subject: [PATCH 15/25] [FIX] web: correct domain when using 'all' filters Since the switch to the new views, 'all' filters such as "Everybody's calendar" did not work as intended (activating the filter would sometimes lead to fewer events being shown). This commit corrects the way 'all' switches affect a domain. More precisely, the conversion from filters to domain has been changed in the following way. - Ticking all visible checkboxes associated with a filter no longer means the same as ticking the 'all' checkbox. The former choice was particularly problematic when a filter only had one visible checkbox, in which case it was not possible to see the list of records only associated with that one filter. - The conversion used to contain an impossible case in which we convert a filter into a 'not in' domain. All the related logic has now been deleted. - Minor changes (renaming variables etc.) to make the code more readable. --- .../src/js/views/calendar/calendar_model.js | 60 +++++++++---------- .../web/static/tests/views/calendar_tests.js | 49 +++++++++++++-- 2 files changed, 72 insertions(+), 37 deletions(-) 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/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(); + }); + }); }); From 97bac8d64c93e18669ca6d2ebea2a2a0f7027c3f Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Thu, 27 Jul 2017 22:35:03 +0200 Subject: [PATCH 16/25] [IMP] core: capture Warnings though logging --- odoo/netsvc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/odoo/netsvc.py b/odoo/netsvc.py index ff904fed2f3..326228bf4f9 100644 --- a/odoo/netsvc.py +++ b/odoo/netsvc.py @@ -115,6 +115,7 @@ def init_logger(): _logger_init = True logging.addLevelName(25, "INFO") + logging.captureWarnings(True) from tools.translate import resetlocale resetlocale() From c39f7e4987e8e768a9096c201fb7099beb9be7d2 Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Thu, 27 Jul 2017 23:03:02 +0200 Subject: [PATCH 17/25] [FIX] mrp: force help message in unicode --- addons/mrp/models/mrp_bom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index f56470a5aa8..d113b8d0f52 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -53,7 +53,7 @@ class MrpBom(models.Model): default='asap', required=True) picking_type_id = fields.Many2one( 'stock.picking.type', 'Picking Type', domain=[('code', '=', 'mrp_operation')], - help="When a procurement has a ‘produce’ route with a picking type set, it will try to create " + help=u"When a procurement has a ‘produce’ route with a picking type set, it will try to create " "a Manufacturing Order for that product using a BoM of the same picking type. That allows " "to define procurement rules which trigger different manufacturing orders with different BoMs. ") company_id = fields.Many2one( From 3b3f6f04af79d7dcc4461262e14e05096d76180b Mon Sep 17 00:00:00 2001 From: David Monjoie Date: Wed, 26 Jul 2017 12:00:42 +0200 Subject: [PATCH 18/25] [FIX] web: save changes when readonly if force_save is set In previous versions, readonly used to prevent sending the values of the fields to the server. However, it was not working properly for fields which have their own sub-arch in the view, as the value used to be sent regardless of the readonly value. In the new views, the readonly modifier is never overlooked, which created a bug in the stock_picking_return wizard which was not able to return products received from a po anymore. Basically the value of the product_id field was not sent so the create crashed. This commit introduces a new "force_save" modifier which is used to express that we want to save the value of the field even though it is readonly from a user interface point of view. --- .../wizard/stock_picking_return_views.xml | 2 +- .../static/src/js/views/basic/basic_model.js | 13 ++-- .../static/tests/views/basic_model_tests.js | 65 +++++++++++++++++++ odoo/addons/base/rng/view.rng | 1 + 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/addons/stock/wizard/stock_picking_return_views.xml b/addons/stock/wizard/stock_picking_return_views.xml index 9bb936610f8..a4efb8b0994 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/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index 73cd2863f21..04fe384afdc 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -2203,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; } @@ -2562,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 @@ -2573,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/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/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 @@ + From 2ba72fb2248665a4b06d1ea99452edc5acadc344 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 27 Jul 2017 16:03:10 +0200 Subject: [PATCH 19/25] [FIX] web: graph: reload with correct fields A traceback occurred when trying to reload a graph view with no groupBy in the searchview, because the array of fields to read given to the read_group RPC didn't contain the default groupBy field (Error "Fields in 'groupby' must appear in the list of fields to read"), e.g. in Sales > My Pipeline. --- .../static/src/js/views/graph/graph_model.js | 4 ++-- addons/web/static/tests/views/graph_tests.js | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) 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/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(); + }); }); }); From 586b97e17bc595607c4a88ffc84a0f8a02147520 Mon Sep 17 00:00:00 2001 From: Olivier Dony Date: Fri, 28 Jul 2017 11:18:57 +0200 Subject: [PATCH 20/25] [FIX] crm: correct duplicate record ID Introduced by a8c5b7810f412d2b956f8163e169159471494ea5 + spellchecking --- addons/crm/report/crm_opportunity_report_view.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/crm/report/crm_opportunity_report_view.xml b/addons/crm/report/crm_opportunity_report_view.xml index de068eae406..1afbfb8c867 100644 --- a/addons/crm/report/crm_opportunity_report_view.xml +++ b/addons/crm/report/crm_opportunity_report_view.xml @@ -78,8 +78,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']} From 08a958fe09d3f6255f0be8bee050030e1bb1ee27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 26 Jul 2017 10:43:09 +0200 Subject: [PATCH 21/25] [FIX] website_portal_sale: fix invoice pdf report link Link is /my/invoices/pdf/ . --- .../website_portal_sale/views/website_portal_sale_templates.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5ff48d8de4b..f9ecad3b4f6 100644 --- a/addons/website_portal_sale/views/website_portal_sale_templates.xml +++ b/addons/website_portal_sale/views/website_portal_sale_templates.xml @@ -286,7 +286,7 @@
- +
From 55d9c37520def47fd22593c26e5a7863d3f4604b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 28 Jul 2017 14:16:05 +0200 Subject: [PATCH 22/25] [FIX] payment_authorize: avoid crash when contact ogone without street on partner --- addons/payment_authorize/models/authorize_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index f31cf45801a..1ae0e71b6ed 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -111,7 +111,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 From a7b7576536e90b9f736591c62eb1bae8802b7a4f Mon Sep 17 00:00:00 2001 From: Christophe Simonis Date: Fri, 28 Jul 2017 15:33:21 +0200 Subject: [PATCH 23/25] [FIX] test_converter: declare string containing unicode as unicode --- odoo/addons/test_converter/models.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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() From d82933d8e77c0b6dc84fc9367e331ae95834c565 Mon Sep 17 00:00:00 2001 From: "Yenthe V.G" Date: Fri, 28 Jul 2017 15:44:18 +0200 Subject: [PATCH 24/25] [FIX] web: change exception name from openerp to Odoo The crash manager still used the old name for the exception. (PR #18522) --- addons/web/static/src/js/framework/crash_manager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/web/static/src/js/framework/crash_manager.js b/addons/web/static/src/js/framework/crash_manager.js index df21e87047c..bc9046f5719 100644 --- a/addons/web/static/src/js/framework/crash_manager.js +++ b/addons/web/static/src/js/framework/crash_manager.js @@ -175,7 +175,7 @@ var RedirectWarningHandler = Dialog.extend(ExceptionHandler, { } }); -core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler); +core.crash_registry.add('odoo.exceptions.RedirectWarning', RedirectWarningHandler); return CrashManager; }); From 1644b92fd8a7a38841333144064772fa50ef3e96 Mon Sep 17 00:00:00 2001 From: Thibault Francois Date: Mon, 17 Oct 2016 11:56:12 +0200 Subject: [PATCH 25/25] [FIX] account format error message correctly Cherry-Pick of a04c1a2ac5244fe2fd3098fc22c59d336e352981 --- addons/account/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/account/account.py b/addons/account/account.py index c08be3323b5..6f49b3cd464 100644 --- a/addons/account/account.py +++ b/addons/account/account.py @@ -1564,7 +1564,7 @@ class account_move(osv.osv): if line.account_id.currency_id and line.currency_id: if line.account_id.currency_id.id != line.currency_id.id and (line.account_id.currency_id.id != line.account_id.company_id.currency_id.id): - raise osv.except_osv(_('Error!'), _("""Cannot create move with currency different from ..""") % (line.account_id.code, line.account_id.name)) + raise osv.except_osv(_('Error'), _("""Couldn't create move with currency different from the secondary currency of the account "%s - %s". Clear the secondary currency field of the account definition if you want to accept all currencies.""") % (line.account_id.code, line.account_id.name)) if round(abs(amount), prec) < 10 ** (-max(5, prec)): # If the move is balanced