diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-05-13 15:26:12 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-05-13 15:26:12 +0200 |
| commit | 8dd5a85267b522ba569ab9ed44901f95fffbeb4f (patch) | |
| tree | ddb62d1e83e1b810b96c05f6819ca66f8d6b4955 /addons/web | |
| parent | 15716642e1975b4f2f29916011a4be29804cda88 (diff) | |
| parent | 60e71302a38b36214efec1213882f573e3ea453c (diff) | |
[MERGE] forward port branch saas-12.3 up to 60e71302a38
Diffstat (limited to 'addons/web')
38 files changed, 811 insertions, 120 deletions
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index a14a02ebb9b..b6bca60fedd 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -41,7 +41,7 @@ from odoo.tools.mimetypes import guess_mimetype from odoo.tools.translate import _ from odoo.tools.misc import str2bool, xlwt, file_open from odoo.tools.safe_eval import safe_eval -from odoo import http +from odoo import http, tools from odoo.http import content_disposition, dispatch_rpc, request, \ serialize_exception as _serialize_exception, Response from odoo.exceptions import AccessError, UserError, AccessDenied @@ -968,8 +968,7 @@ class View(http.Controller): class Binary(http.Controller): def placeholder(self, image='placeholder.png'): - addons_path = http.addons_manifest['web']['addons_path'] - return open(os.path.join(addons_path, 'web', 'static', 'src', 'img', image), 'rb').read() + return tools.file_open(get_resource_path('web', 'static/src/img', image), 'rb').read() @http.route(['/web/content', '/web/content/<string:xmlid>', @@ -999,6 +998,15 @@ class Binary(http.Controller): response.set_cookie('fileToken', token) return response + @http.route(['/web/partner_image', + '/web/partner_image/<int:rec_id>', + '/web/partner_image/<int:rec_id>/<string:field>', + '/web/partner_image/<int:rec_id>/<string:field>/<string:model>/'], type='http', auth="public") + def content_image_partner(self, rec_id, field='image_small', model='res.partner', **kwargs): + # other kwargs are ignored on purpose + return self._content_image(id=rec_id, model='res.partner', field=field, + placeholder='user_placeholder.jpg') + @http.route(['/web/image', '/web/image/<string:xmlid>', '/web/image/<string:xmlid>/<string:filename>', @@ -1019,7 +1027,16 @@ class Binary(http.Controller): def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename_field='datas_fname', unique=None, filename=None, mimetype=None, download=None, width=0, height=0, crop=False, access_token=None, - placeholder='placeholder.png', **kwargs): + **kwargs): + # other kwargs are ignored on purpose + return self._content_image(xmlid=xmlid, model=model, id=id, field=field, + filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype, + download=download, width=width, height=height, crop=crop, access_token=access_token) + + def _content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', + filename_field='datas_fname', unique=None, filename=None, mimetype=None, + download=None, width=0, height=0, crop=False, access_token=None, + placeholder='placeholder.png', **kwargs): status, headers, image_base64 = request.env['ir.http'].binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, diff --git a/addons/web/doc/module.rst b/addons/web/doc/module.rst index ffbe90d0933..ca808254567 100644 --- a/addons/web/doc/module.rst +++ b/addons/web/doc/module.rst @@ -412,7 +412,7 @@ responses": .. note:: - By defaut, tests cases don't load templates either. We had not + By default, tests cases don't load templates either. We had not needed to perform any template rendering before here, so we must now enable templates loading via :js:attr:`the corresponding option <TestOptions.templates>`. diff --git a/addons/web/static/lib/tempusdominus/tempusdominus.js b/addons/web/static/lib/tempusdominus/tempusdominus.js index ffca0c8e632..4eaf0df2a60 100644 --- a/addons/web/static/lib/tempusdominus/tempusdominus.js +++ b/addons/web/static/lib/tempusdominus/tempusdominus.js @@ -2754,7 +2754,8 @@ var TempusDominusBootstrap4 = function ($) { if ($target.length === 0) { return; } - if (config._options.debug || window.debug) { + // /!\ ODOO FIX: check on 'config' existence added by odoo + if (config && config._options.debug || window.debug) { return; } TempusDominusBootstrap4._jQueryInterface.call($target, 'hide', event); diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 9566dbda850..6b1a6249962 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -33,7 +33,9 @@ var qweb = core.qweb; var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMixin, { dependencies: ['notification'], - events: _.extend(KeyboardNavigationMixin.events, {}), + events: _.extend({}, KeyboardNavigationMixin.events, { + 'click .o_search_options .dropdown-menu': '_onClickDropDownMenu', + }), custom_events: { clear_uncommitted_changes: function (e) { this.clear_uncommitted_changes().then(e.data.callback); @@ -356,6 +358,16 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi //-------------------------------------------------------------------------- /** + * When clicking inside a dropdown to modify search options + * prevents the bootstrap dropdown to close on itself + * + * @private + * @param {Event} ev + */ + _onClickDropDownMenu: function (ev) { + ev.stopPropagation(); + }, + /** * Whenever the connection is lost, we need to notify the user. * * @private diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 81211788246..7be17700f98 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -153,10 +153,27 @@ var dom = { * * @param {jQuery} $from - the jQuery element(s) from which to search * @param {string} selector - the CSS selector to match + * @param {boolean} [addBack=false] - whether or not the $from element + * should be considered in the results * @returns {jQuery} */ - cssFind: function ($from, selector) { - return $from.find('*').filter(selector); + cssFind: function ($from, selector, addBack) { + var $results; + + // No way to correctly parse a complex jQuery selector but having no + // spaces should be a good-enough condition to use a simple find + var multiParts = selector.indexOf(' ') >= 0; + if (multiParts) { + $results = $from.find('*').filter(selector); + } else { + $results = $from.find(selector); + } + + if (addBack && $from.is(selector)) { + $results = $results.add($from); + } + + return $results; }, /** * Detaches widgets from the DOM and performs their on_detach_callback() diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 2330307b7a9..713ade2a989 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -808,7 +808,7 @@ var FieldBoolean = AbstractField.extend({ * @returns {jQuery} the focusable checkbox input */ getFocusableElement: function () { - return this.$input || $(); + return this.mode === 'readonly' ? $() : this.$input; }, /** * A boolean field is always set since false is a valid value. @@ -1042,7 +1042,13 @@ var FieldFloatToggle = AbstractField.extend({ * @returns {float} The current formatted value */ _getDisplayedValue: function () { - return parseFloat(this._formatValue(this.value)); + // this.value is a plain float + // Matches what is in Database + var usrFormatValue = this._formatValue(this.value); + // usrFormatValue is string + // contains a float represented in a user specific format + // the float is the fraction by [this.factor] of this.value + return field_utils.parse['float'](usrFormatValue); }, /** * Formats the HTML input tag for edit mode and stores selection status. @@ -1069,7 +1075,7 @@ var FieldFloatToggle = AbstractField.extend({ * one is not in the range, the next value of the closest one will be chosen. * * @private - * @returns {number} The next formatted value in the range + * @returns {number} The next value in the range */ _nextValue: function () { var range = this.nodeOptions.range; @@ -1098,7 +1104,8 @@ var FieldFloatToggle = AbstractField.extend({ if (this.mode === 'edit') { ev.stopPropagation(); // only stop propagation in edit mode var next_val = this._nextValue(); - this._setValue(next_val.toString()); // will be parsed in _setValue + next_val = field_utils.format['float'](next_val); + this._setValue(next_val); // will be parsed in _setValue } }, diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js index 9db99815009..b91728b22d1 100644 --- a/addons/web/static/src/js/libs/bootstrap.js +++ b/addons/web/static/src/js/libs/bootstrap.js @@ -6,7 +6,7 @@ odoo.define('web.bootstrap.extensions', function () { * patching in place. */ -/* Bootstrap defaults overwrite */ +/* Bootstrap tooltip defaults overwrite */ $.fn.tooltip.Constructor.Default.placement = 'auto'; $.fn.tooltip.Constructor.Default.fallbackPlacement = ['bottom', 'right', 'left', 'top']; $.fn.tooltip.Constructor.Default.html = true; @@ -24,4 +24,8 @@ $.fn.tooltip.Constructor.prototype.show = function () { return bootstrapShowFunction.call(this); }; + +/* Bootstrap popover defaults overwrite */ +$.fn.popover.Constructor.Default.sanitize = false; + }); diff --git a/addons/web/static/src/js/public/public_root.js b/addons/web/static/src/js/public/public_root.js index 2c91c795df3..31b39e97abb 100644 --- a/addons/web/static/src/js/public/public_root.js +++ b/addons/web/static/src/js/public/public_root.js @@ -2,6 +2,7 @@ odoo.define('web.public.root', function (require) { 'use strict'; var ajax = require('web.ajax'); +var dom = require('web.dom'); var ServiceProviderMixin = require('web.ServiceProviderMixin'); var session = require('web.session'); var utils = require('web.utils'); @@ -199,7 +200,7 @@ var PublicRoot = publicWidget.RootWidget.extend(ServiceProviderMixin, { var defs = _.map(this._getPublicWidgetsRegistry(options), function (PublicWidget) { var selector = PublicWidget.prototype.selector || ''; - var $target = $from.find(selector).addBack(selector); + var $target = dom.cssFind($from, selector, true); var defs = _.map($target, function (el) { var widget = new PublicWidget(self, options); @@ -293,8 +294,8 @@ var PublicRoot = publicWidget.RootWidget.extend(ServiceProviderMixin, { var $buttons = $(ev.currentTarget).find('button[type="submit"], a.a-submit'); _.each($buttons, function (btn) { var $btn = $(btn); - $btn.attr('data-loading-text', '<i class="fa fa-spinner fa-spin"></i> ' + $(btn).text()); - $btn.button('loading'); + $btn.html('<i class="fa fa-spinner fa-spin"></i> ' + $btn.text()); + $btn.prop('disabled', true); }); }, /** diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js index 54e98bbb87e..e5eff8d7fc1 100644 --- a/addons/web/static/src/js/tools/debug_manager.js +++ b/addons/web/static/src/js/tools/debug_manager.js @@ -436,6 +436,7 @@ DebugManager.include({ var fields = state.fields; var fieldsInfo = state.fieldsInfo.form; var fieldNamesInView = state.getFieldNames(); + var fieldNamesOnlyOnView = ['message_attachment_count']; var fieldsValues = state.data; var modifierDatas = {}; _.each(fieldNamesInView, function (fieldName) { @@ -444,6 +445,7 @@ DebugManager.include({ }); }); this.fields = _.chain(fieldNamesInView) + .difference(fieldNamesOnlyOnView) .map(function (fieldName) { var modifierData = modifierDatas[fieldName]; var invisibleOrReadOnly; diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js index c7a80864a15..607f49c8031 100644 --- a/addons/web/static/src/js/views/abstract_controller.js +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -495,7 +495,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, { additional_context: _.extend({}, data.context) }); } - return false; }, /** * FIXME: this logic should be rethought 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 6aa884bbacf..c87a6aca455 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -564,9 +564,16 @@ var BasicModel = AbstractModel.extend({ } } else if (field.type === 'one2many' || field.type === 'many2many') { if (options.raw) { - relDataPoint = this.localData[data[fieldName]]; - relDataPoint = this._applyX2ManyOperations(relDataPoint); - data[fieldName] = relDataPoint.res_ids; + if (typeof data[fieldName] === 'string') { + relDataPoint = this.localData[data[fieldName]]; + relDataPoint = this._applyX2ManyOperations(relDataPoint); + data[fieldName] = relDataPoint.res_ids; + } else { + // no datapoint has been created yet (because the loading of relational + // data has been batched, and hasn't started yet), so the value is still + // the list of ids in the relation + data[fieldName] = data[fieldName] || []; + } } else { data[fieldName] = this.get(data[fieldName]) || []; } diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index dda3978635b..61a966972aa 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -204,7 +204,9 @@ var CalendarController = AbstractController.extend({ * @param {OdooEvent} event */ _onDropRecord: function (event) { - this._updateRecord(event.data); + this._updateRecord(_.extend({}, event.data, { + 'drop': true, + })); }, /** * @private 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 1bcf4fbb298..0ad1b61e4d7 100644 --- a/addons/web/static/src/js/views/calendar/calendar_model.js +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -60,10 +60,11 @@ return AbstractModel.extend({ end.add(-1, 'days'); } + var isDateEvent = this.fields[this.mapping.date_start].type === 'date'; // An "allDay" event without the "all_day" option is not considered // as a 24h day. It's just a part of the day (by default: 7h-19h). if (event.allDay) { - if (!this.mapping.all_day) { + if (!this.mapping.all_day && !isDateEvent) { if (event.r_start) { start.hours(event.r_start.hours()) .minutes(event.r_start.minutes()) @@ -104,7 +105,9 @@ return AbstractModel.extend({ } if (this.mapping.date_delay) { - data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } } return data; diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js index a1da2925eb6..5ce53b20806 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js @@ -18,7 +18,6 @@ var ControlPanelRenderer = Renderer.extend({ get_action_info: '_onGetActionInfo', }, events: _.extend({}, Renderer.prototype.events, { - 'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked', 'click .o_searchview_more': '_onMore', }), @@ -368,15 +367,6 @@ var ControlPanelRenderer = Renderer.extend({ //-------------------------------------------------------------------------- /** - * Prevent the search dropdowns from closing when clicking inside them. - * - * @private - * @param {MouseEvent} ev - */ - _onDropdownClicked: function (ev) { - ev.stopPropagation(); - }, - /** * Toggle the search menus visibility. * * @private 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 10d3c0d54c5..ffeef454716 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -466,7 +466,8 @@ var ListController = BasicController.extend({ _update: function () { return this._super.apply(this, arguments) .then(this._toggleSidebar.bind(this)) - .then(this._toggleCreateButton.bind(this)); + .then(this._toggleCreateButton.bind(this)) + .then(this._updateButtons.bind(this, 'readonly')); }, /** * This helper simply makes sure that the control panel buttons matches the 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 c68f5ab573a..aaf073eeea0 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 @@ -162,7 +162,7 @@ ListRenderer.include({ * This is a specialized version of confirmChange, meant to be called when * the change may have affected more than one line (so, for example, an * onchange which add/remove a few lines in a x2many. This does not occur - * in a normal list view) + * in a normal list view). * * The update is more difficult when other rows could have been changed. We * need to potentially remove some lines, add some other lines, update some @@ -172,6 +172,9 @@ ListRenderer.include({ * except the 'main' one (the row which caused the update), then rerender * every new row and add them before/after the main one. * + * Note that this function assumes that the list isn't grouped, which is + * fine as it's never the case for x2many lists. + * * @param {Object} state * @param {string} id * @param {string[]} fields @@ -226,18 +229,17 @@ ListRenderer.include({ } } - // remove all rows except the one being edited, and insert rows - // of the re-rendered body before and after it + // remove all data rows except the one being edited, and insert + // data rows of the re-rendered body before and after it var $editedRow = self._getRow(id); - $editedRow.nextAll().remove(); - $editedRow.prevAll().remove(); + $editedRow.nextAll('.o_data_row').remove(); + $editedRow.prevAll('.o_data_row').remove(); var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]'); - var $tbody = self.$('tbody'); - $newRow.prevAll().each(function (i, prevRow) { - $tbody.prepend($(prevRow)); + $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertBefore($editedRow); }); - $newRow.nextAll().each(function (i, nextRow) { - $tbody.append($(nextRow)); + $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertAfter($editedRow); }); if (self.currentRow !== null) { @@ -309,7 +311,16 @@ ListRenderer.include({ if (this.state.count >= 4) { $row.remove(); } else { - $row.replaceWith(this._renderEmptyRow()); + // we want to always keep at least 4 (possibly empty) rows + var $emptyRow = this._renderEmptyRow(); + $row.replaceWith($emptyRow); + if (this.editable === "top") { + // move the empty row we just inserted after data rows + var $lastDataRow = this.$('.o_data_row:last'); + if ($lastDataRow.length) { + $emptyRow.insertAfter($lastDataRow); + } + } } }, /** 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 4bf0ac82941..0d04fd69585 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -441,7 +441,6 @@ var ListRenderer = BasicRenderer.extend({ * @returns {jQuery} a <button> element */ _renderGroupButton: function (list, node) { - var self = this; var $button = viewUtils.renderButtonFromNode(node, { extraClass: node.attrs.icon ? 'o_icon_button' : undefined, textAsTitle: !!node.attrs.icon, @@ -449,20 +448,10 @@ var ListRenderer = BasicRenderer.extend({ this._handleAttributes($button, node); this._registerModifiers(node, list.groupData, $button); - // TODO this should be moved to a handler - $button.on("click", function (e) { - e.stopPropagation(); - if (node.attrs.type === 'edit') { - self.trigger_up('group_edit_button_clicked', { - record: list.groupData, - }); - } else { - self.trigger_up('button_clicked', { - attrs: node.attrs, - record: list.groupData, - }); - } - }); + // TODO this should be moved to event handlers + $button.on("click", this._onGroupButtonClicked.bind(this, list.groupData, node)); + $button.on("keydown", this._onGroupButtonKeydown.bind(this)); + return $button; }, /** @@ -889,6 +878,40 @@ var ListRenderer = BasicRenderer.extend({ //-------------------------------------------------------------------------- /** + * @private + * @param {Object} record a record dataPoint on which the button applies + * @param {Object} node arch node of the button + * @param {Object} node.attrs the attrs of the button in the arch + * @param {jQueryEvent} ev + */ + _onGroupButtonClicked: function (record, node, ev) { + ev.stopPropagation(); + if (node.attrs.type === 'edit') { + this.trigger_up('group_edit_button_clicked', { + record: record, + }); + } else { + this.trigger_up('button_clicked', { + attrs: node.attrs, + record: record, + }); + } + }, + /** + * If the user presses ENTER on a group header button, we want to execute + * the button action. This is done automatically as the click handler is + * called. However, we have to stop the propagation of the event to prevent + * another handler from closing the group (see _onKeyDown). + * + * @private + * @param {jQueryEvent} ev + */ + _onGroupButtonKeydown: function (ev) { + if (ev.keyCode === $.ui.keyCode.ENTER) { + ev.stopPropagation(); + } + }, + /** * Manages the keyboard events on the list. If the list is not editable, when the user navigates to * a cell using the keyboard, if he presses enter, enter the model represented by the line * @@ -943,8 +966,11 @@ var ListRenderer = BasicRenderer.extend({ break; } if ($futureCell) { - // If the cell contains activable elements, focus them instead - var $activables = $futureCell.find(':focusable'); + // If the cell contains activable elements, focus them instead (except if it is in a + // group header, in which case we want to activate the whole header, so that we can + // open/close it with RIGHT/LEFT keystrokes) + var isInGroupHeader = $futureCell.closest('tr').hasClass('o_group_header'); + var $activables = !isInGroupHeader && $futureCell.find(':focusable'); if ($activables.length) { $activables[0].focus(); } else { diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index 4ae0e1833b6..3206ef7b47a 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -14,7 +14,9 @@ var DateWidget = Widget.extend({ events: { 'change.datetimepicker': 'changeDatetime', 'change .o_datepicker_input': 'changeDatetime', + 'click input': '_onInputClicked', 'input input': '_onInput', + 'keydown': '_onKeydown', 'show.datetimepicker': '_onDateTimePickerShow', 'hide.datetimepicker': '_onDateTimePickerHide', }, @@ -50,10 +52,13 @@ var DateWidget = Widget.extend({ }, widgetParent: 'body', keyBinds: null, - allowInputToggle: true, }, options || {}); this.__libInput = 0; + // tempusdominus doesn't offer any elegant way to check whether the + // datepicker is open or not, so we have to listen to hide/show events + // and manually keep track of the 'open' state + this.__isOpen = false; }, /** * @override @@ -238,6 +243,7 @@ var DateWidget = Widget.extend({ * @private */ _onDateTimePickerHide: function () { + this.__isOpen = false; if (this._onScroll) { window.removeEventListener('scroll', this._onScroll, true); } @@ -250,6 +256,7 @@ var DateWidget = Widget.extend({ * @private */ _onDateTimePickerShow: function () { + this.__isOpen = true; if (this.$input.val().length !== 0 && this.isValid()) { this.$input.select(); } @@ -264,6 +271,34 @@ var DateWidget = Widget.extend({ window.addEventListener('scroll', this._onScroll, true); }, /** + * @private + * @param {KeyEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ESCAPE) { + this.__libInput++; + this.$el.datetimepicker('hide'); + this.__libInput--; + } + }, + /** + * @private + * @param {KeyEvent} ev + */ + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ESCAPE) { + if (this.__isOpen) { + // we don't want any other effects than closing the datepicker, + // like leaving the edition of a row in editable list view + ev.stopImmediatePropagation(); + this.__libInput++; + this.$el.datetimepicker('hide'); + this.__libInput--; + this.focus(); + } + } + }, + /** * Prevents 'input' events triggered by the library to bubble up, as they * might have unwanted effects (like triggering 'field_changed' events in * the context of field widgets) @@ -276,6 +311,15 @@ var DateWidget = Widget.extend({ ev.stopImmediatePropagation(); } }, + /** + * @private + */ + _onInputClicked: function () { + this.__libInput++; + this.$el.datetimepicker('toggle'); + this.__libInput--; + this.focus(); + }, }); var DateTimeWidget = DateWidget.extend({ diff --git a/addons/web/static/src/scss/bootstrap_overridden.scss b/addons/web/static/src/scss/bootstrap_overridden.scss index 8cfd6dbd48b..dc1c2bab30c 100644 --- a/addons/web/static/src/scss/bootstrap_overridden.scss +++ b/addons/web/static/src/scss/bootstrap_overridden.scss @@ -61,7 +61,7 @@ $component-active-bg: $gray-200 !default; // // Font, line-height, and color for body text, headings, and more. -$font-family-sans-serif: "Lucida Grande", Helvetica, Verdana, Arial, sans-serif !default; +$font-family-sans-serif: "Noto", "Lucida Grande", Helvetica, Verdana, Arial, sans-serif !default; $font-size-base: $o-font-size-base !default; $line-height-base: $o-line-height-base !default; diff --git a/addons/web/static/src/scss/bootstrap_overridden_report.scss b/addons/web/static/src/scss/bootstrap_overridden_report.scss new file mode 100644 index 00000000000..a6e1cd23b32 --- /dev/null +++ b/addons/web/static/src/scss/bootstrap_overridden_report.scss @@ -0,0 +1,2 @@ +// remove Noto Color Emoji +$font-family-sans-serif: "Noto", "Lucida Grande", Helvetica, Verdana, Arial, sans-serif; diff --git a/addons/web/static/src/scss/fonts.scss b/addons/web/static/src/scss/fonts.scss index bfabf4fe6fc..0a8793c9a18 100644 --- a/addons/web/static/src/scss/fonts.scss +++ b/addons/web/static/src/scss/fonts.scss @@ -4,6 +4,36 @@ $lato-font-path: '../fonts/lato'; @mixin lato-font($type, $weight, $style) { + // Cyrillic: U+0400-04FF, U+0500-052F + @font-face { + font-family: 'Noto'; + src: url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.woff2') format('woff2'), + url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.woff') format('woff'), + url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.ttf') format('truetype'); + font-weight: $weight; + font-style: $style; + unicode-range: U+0400-04FF, U+0500-052F; + } + // Hebrew: U+0590-05FF, U+FB1D-FB4F + @font-face { + font-family: 'Noto'; + src: url('https://fonts.odoocdn.com/fonts/noto/NotoSansHebrew-#{$type}.woff2') format('woff2'), + url('https://fonts.odoocdn.com/fonts/noto/NotoSansHebrew-#{$type}.woff') format('woff'), + url('https://fonts.odoocdn.com/fonts/noto/NotoSansHebrew-#{$type}.ttf') format('truetype'); + font-weight: $weight; + font-style: $style; + unicode-range: U+0590-05FF, U+FB1D-FB4F; + } + // Arabic: U+0600-06FF, U+0750-077F + @font-face { + font-family: 'Noto'; + src: url('https://fonts.odoocdn.com/fonts/noto/NotoSansArabic-#{$type}.woff2') format('woff2'), + url('https://fonts.odoocdn.com/fonts/noto/NotoSansArabic-#{$type}.woff') format('woff'), + url('https://fonts.odoocdn.com/fonts/noto/NotoSansArabic-#{$type}.ttf') format('truetype'); + font-weight: $weight; + font-style: $style; + unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF; + } @font-face { font-family: 'Lato'; src: url('#{$lato-font-path}/Lato-#{$type}-webfont.eot'); diff --git a/addons/web/static/src/scss/layout_background.scss b/addons/web/static/src/scss/layout_background.scss index caeb1f0c2bf..93ac948b2ad 100644 --- a/addons/web/static/src/scss/layout_background.scss +++ b/addons/web/static/src/scss/layout_background.scss @@ -1,5 +1,5 @@ .o_background_footer, .o_background_header, .o_report_layout_background { - font-family: 'Lato'; + font-family: 'Lato', 'Noto'; color: gray('700'); } .o_background_header { diff --git a/addons/web/static/src/scss/layout_boxed.scss b/addons/web/static/src/scss/layout_boxed.scss index f0a854acdfd..1916725f812 100644 --- a/addons/web/static/src/scss/layout_boxed.scss +++ b/addons/web/static/src/scss/layout_boxed.scss @@ -1,5 +1,5 @@ .o_boxed_footer, .o_boxed_header, .o_report_layout_boxed { - font-family: 'Lato'; + font-family: 'Lato' 'Noto'; color: gray('700'); font-size: 15px; } diff --git a/addons/web/static/src/scss/layout_clean.scss b/addons/web/static/src/scss/layout_clean.scss index 910abc763a4..0a8f4b065af 100644 --- a/addons/web/static/src/scss/layout_clean.scss +++ b/addons/web/static/src/scss/layout_clean.scss @@ -1,5 +1,5 @@ .o_clean_footer, .o_clean_header, .o_report_layout_clean { - font-family: 'Lato'; + font-family: 'Lato', 'Noto'; color: #000; } .o_clean_header { diff --git a/addons/web/static/src/scss/list_view.scss b/addons/web/static/src/scss/list_view.scss index 86b989dbd10..45744a7a1f8 100644 --- a/addons/web/static/src/scss/list_view.scss +++ b/addons/web/static/src/scss/list_view.scss @@ -97,6 +97,7 @@ // Grouped list views tbody > tr.o_group_header { > th, > td { + vertical-align: middle; padding-top: ($table-cell-padding-sm*2); padding-bottom: ($table-cell-padding-sm*2); } @@ -105,8 +106,10 @@ } .o_group_buttons { display: inline-block; + margin-left: 10px; > button { + @include o-hover-text-color($o-brand-primary, darken($o-brand-primary, 20%)); background-color: transparent; border: none; padding-top: 0; diff --git a/addons/web/static/src/scss/list_view_extra.scss b/addons/web/static/src/scss/list_view_extra.scss index 7532ec2084f..f72d6b21dd6 100644 --- a/addons/web/static/src/scss/list_view_extra.scss +++ b/addons/web/static/src/scss/list_view_extra.scss @@ -11,4 +11,10 @@ background-image: linear-gradient(to bottom, #fcfcfc, $o-form-lightsecondary); } } + // Subpixels computation is different accross browsers and platforms as no + // rounding rule is specified by HTML5. Difference in this computation + // breaks one of the editable list tests on Chrome and Firefox on MacOS. + .o_field_many2manytags { + min-height: 21px; + } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index bb8e5e1c81a..4694aa74f97 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1368,13 +1368,14 @@ <div role="menuitem" class="dropdown-item-text o_favorite_name"> <input type="text" class="o_input"></input> </div> + <t t-set="favId" t-value="_.uniqueId('_checkbox_')" /> <div class="custom-control custom-checkbox"> - <input type="checkbox" id="o_favorite_use_by_default" class="custom-control-input"/> - <label for="o_favorite_use_by_default" class="custom-control-label">Use by default</label> + <input type="checkbox" t-attf-id="o_favorite_use_by_default#{favId}" class="custom-control-input"/> + <label t-attf-for="o_favorite_use_by_default#{favId}" class="custom-control-label">Use by default</label> </div> <div class="custom-control custom-checkbox"> - <input type="checkbox" id="o_favorite_share_all_users" class="custom-control-input"/> - <label for="o_favorite_share_all_users" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> + <input type="checkbox" t-attf-id="o_favorite_share_all_users#{favId}" class="custom-control-input"/> + <label t-attf-for="o_favorite_share_all_users#{favId}" class="custom-control-label">Share with all users <span class="fa fa-users" role="img" aria-label="Users" title="Users"/></label> </div> <div class="dropdown-item-text o_save_favorite"> <button type="button" class="btn btn-primary">Save</button> @@ -1558,7 +1559,7 @@ <t t-name="SwitchCompanyMenu"> <li class="o_switch_company_menu"> - <a role="button" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false" href="#" aria-label="Dropdown menu" title="Dropdown menu"> + <a role="button" class="dropdown-toggle" data-toggle="dropdown" data-display="static" aria-expanded="false" href="#" aria-label="Dropdown menu" title="Dropdown menu"> <span t-attf-class="#{widget.isMobile ? 'fa fa-building-o' : 'oe_topbar_name'}"> <t t-if="!widget.isMobile"><t t-raw="widget.current_company_name"/></t> </span> diff --git a/addons/web/static/src/xml/menu.xml b/addons/web/static/src/xml/menu.xml index 3838cc6c226..6a4749d1c25 100644 --- a/addons/web/static/src/xml/menu.xml +++ b/addons/web/static/src/xml/menu.xml @@ -15,7 +15,7 @@ --> <t t-name="AppsMenu"> <li class="dropdown"> - <a class="full" data-toggle="dropdown" href="#"> + <a class="full" data-toggle="dropdown" data-display="static" href="#"> <i class="fa fa-th-large"/> </a> <div class="dropdown-menu" role="menu"> @@ -77,7 +77,7 @@ </t> <t t-else=""> <li> - <a href="#" class="dropdown-toggle o-no-caret o_menu_header_lvl_1" t-att-data-menu-xmlid="second_level_menu.xmlid" data-toggle="dropdown" role="button" aria-expanded="false"> + <a href="#" class="dropdown-toggle o-no-caret o_menu_header_lvl_1" t-att-data-menu-xmlid="second_level_menu.xmlid" data-toggle="dropdown" data-display="static" role="button" aria-expanded="false"> <t t-esc="second_level_menu.name"/> </a> <div class="dropdown-menu" role="menu"> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index b7e487d5c12..02bf293e001 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -2887,10 +2887,7 @@ QUnit.module('basic_fields', { View: FormView, model: 'partner', data: this.data, - arch:'<form string="Partners">' + - '<field name="display_name" /> ' + // Do not focus on the date field right away - '<field name="date" />' + - '</form>', + arch: '<form><field name="date"/></form>', res_id: 1, viewOptions: { mode: 'edit', @@ -2906,7 +2903,8 @@ QUnit.module('basic_fields', { done(); } }); - form.$('.o_input[name="date"]').mouseenter().trigger('focus'); + + testUtils.dom.openDatepicker(form.$('.o_datepicker')); form.destroy(); }); @@ -2955,13 +2953,13 @@ QUnit.module('basic_fields', { QUnit.module('FieldDatetime'); QUnit.test('datetime field in form view', async function (assert) { - assert.expect(6); + assert.expect(7); var form = await createView({ View: FormView, model: 'partner', data: this.data, - arch:'<form string="Partners"><field name="datetime"/></form>', + arch: '<form string="Partners"><field name="datetime"/></form>', res_id: 1, translateParameters: { // Avoid issues due to localization formats date_format: '%m/%d/%Y', @@ -2982,8 +2980,14 @@ QUnit.module('basic_fields', { await testUtils.form.clickEdit(form); assert.strictEqual(form.$('.o_datepicker_input').val(), expectedDateString, 'the datetime should be correct in edit mode'); + + // datepicker should not open on focus + assert.containsNone($('body'), '.bootstrap-datetimepicker-widget'); + + testUtils.dom.openDatepicker(form.$('.o_datepicker')); + assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget'); + // select 22 February at 8:23:33 - assert.ok($('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch').first()); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch:eq(1)')); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .year:contains(2017)')); @@ -3065,7 +3069,7 @@ QUnit.module('basic_fields', { }); QUnit.test('datetime field in editable list view', async function (assert) { - assert.expect(8); + assert.expect(9); var list = await createView({ View: ListView, @@ -3101,8 +3105,11 @@ QUnit.module('basic_fields', { assert.strictEqual(list.$('input.o_datepicker_input').val(), expectedDateString, 'the date should be correct in edit mode'); + assert.containsNone($('body'), '.bootstrap-datetimepicker-widget'); + testUtils.dom.openDatepicker(list.$('.o_datepicker')); + assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget'); + // select 22 February at 8:23:33 - assert.ok($('.bootstrap-datetimepicker-widget').length, 'datepicker should be open'); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch').first()); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .picker-switch:eq(1)')); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .year:contains(2017)')); @@ -3278,6 +3285,7 @@ QUnit.module('basic_fields', { }); await testUtils.form.clickCreate(form); + testUtils.dom.openDatepicker(form.$('.o_datepicker')); $.each($('.day:last-child(),.day:nth-child(2)'), function (index, value) { assert.hasClass(value, 'disabled', 'first and last days must be disabled'); }); diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js index 52900bed5f9..a0c1b682f17 100644 --- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js +++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js @@ -3428,11 +3428,6 @@ QUnit.module('fields', {}, function () { await testUtils.dom.click($('.bootstrap-datetimepicker-widget .year:contains(2017)')); await testUtils.dom.click($('.bootstrap-datetimepicker-widget .month').eq(1)); await testUtils.dom.click($('.day:contains(22)')); - // trigger the blur of input before the click on Save, to closely mock what actually - // happens when we really click on Save - // this is required to close the datepicker before removing the input from the DOM - // (because of the re-rendering in readonly), otherwise tempusdominus crashes - await testUtils.dom.triggerEvents(form.$('.o_datepicker_input'), 'blur'); await testUtils.form.clickSave(form); assert.verifySteps(['read', 'read', 'onchange', 'write', 'read', 'read']); @@ -8343,6 +8338,65 @@ QUnit.module('fields', {}, function () { form.destroy(); }); + + QUnit.test('one2many editable list: edit and click on add a line', async function (assert) { + assert.expect(9); + + this.data.turtle.onchanges = { + turtle_int: function () {}, + }; + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="turtles">' + + '<tree editable="bottom"><field name="turtle_int"/></tree>' + + '</field>' + + '</form>', + res_id: 1, + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.step('onchange'); + } + return this._super.apply(this, arguments); + }, + // in this test, we want to to accurately mock what really happens, that is, input + // fields only trigger their changes on 'change' event, not on 'input' + fieldDebounce: 100000, + viewOptions: { + mode: 'edit', + }, + }); + + assert.containsOnce(form, '.o_data_row'); + + // edit first row + await testUtils.dom.click(form.$('.o_data_row:first .o_data_cell:first')); + assert.hasClass(form.$('.o_data_row:first'), 'o_selected_row'); + await testUtils.fields.editInput(form.$('.o_selected_row input[name=turtle_int]'), '44'); + + assert.verifySteps([]); + // simulate a long click on 'Add a line' (mousedown [delay] mouseup and click events) + var $addLine = form.$('.o_field_x2many_list_row_add a'); + testUtils.dom.triggerEvents($addLine, 'mousedown'); + // mousedown is supposed to trigger the change event on the edited input, but it doesn't + // in the test environment, for an unknown reason, so we trigger it manually to reproduce + // what really happens + testUtils.dom.triggerEvents(form.$('.o_selected_row input[name=turtle_int]'), 'change'); + await testUtils.nextTick(); + + // release the click + await testUtils.dom.triggerEvents($addLine, ['mouseup', 'click']); + assert.verifySteps(['onchange', 'onchange']); + + assert.containsN(form, '.o_data_row', 2); + assert.strictEqual(form.$('.o_data_row:first').text(), '44'); + assert.hasClass(form.$('.o_data_row:nth(1)'), 'o_selected_row'); + + form.destroy(); + }); }); }); }); diff --git a/addons/web/static/tests/helpers/qunit_config.js b/addons/web/static/tests/helpers/qunit_config.js index 8c6496bb8f9..8308f972a47 100644 --- a/addons/web/static/tests/helpers/qunit_config.js +++ b/addons/web/static/tests/helpers/qunit_config.js @@ -131,6 +131,9 @@ QUnit.on('OdooAfterTestHook', function () { } if (!isValid) { + console.error('Body still contains undesirable elements:' + + '\nInvalid element:\n' + bodyChild.outerHTML + + '\nBody HTML: \n' + $('body').html()); if (!document.body.classList.contains('debug')) { $(bodyChild).remove(); } @@ -142,6 +145,8 @@ QUnit.on('OdooAfterTestHook', function () { // check for leftovers in #qunit-fixture var qunitFixture = document.getElementById('qunit-fixture'); if (qunitFixture.children.length) { + console.error('#qunit-fixture still contains elements:' + + '\n#qunit-fixture HTML:\n' + qunitFixture.outerHTML); QUnit.pushFailure(`#qunit-fixture still contains elements`); if (!document.body.classList.contains('debug')) { $(qunitFixture.children).remove(); diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js index 315a00cbfe7..4c1ef8fbbcc 100644 --- a/addons/web/static/tests/helpers/test_utils_dom.js +++ b/addons/web/static/tests/helpers/test_utils_dom.js @@ -167,15 +167,6 @@ function triggerKeypressEvent(char) { } /** - * Opens the datepicker of a given element. - * - * @param {jQuery} $datepickerEl element to which a datepicker is attached - */ -function openDatepicker($datepickerEl) { - $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker'); -} - -/** * Click on a specified element. If the option first or last is not specified, * this method also check the unicity and the visibility of the target. * @@ -277,6 +268,16 @@ function triggerEvents($el, events) { } +/** + * Opens the datepicker of a given element. + * + * @param {jQuery} $datepickerEl element to which a datepicker is attached + */ +function openDatepicker($datepickerEl) { + click($datepickerEl.find('.o_datepicker_input')); +} + + return { triggerKeypressEvent: triggerKeypressEvent, triggerMouseEvent: triggerMouseEvent, diff --git a/addons/web/static/tests/views/abstract_view_banner_tests.js b/addons/web/static/tests/views/abstract_view_banner_tests.js index ab668dd8c0d..e5aa14faccc 100644 --- a/addons/web/static/tests/views/abstract_view_banner_tests.js +++ b/addons/web/static/tests/views/abstract_view_banner_tests.js @@ -40,13 +40,33 @@ QUnit.module('Views', { QUnit.test("The banner should be fetched from the route", function (assert) { var done = assert.async(); - assert.expect(5); + assert.expect(6); - var banner_html = - '<div>' + - '<link type="text/css" href="' + test_css_url + '" rel="stylesheet">' + - '<div class="hello_banner"/>' + - '</div>'; + var banner_html =` + <div class="modal o_onboarding_modal o_technical_modal" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-footer"> + <a type="action" class="btn btn-primary" data-dismiss="modal" + data-toggle="collapse" href=".o_onboarding_container"> + Remove + </a> + </div> + </div> + </div> + </div> + <div class="o_onboarding_container collapse show"> + <div class="o_onboarding_wrap"> + <a href="#" data-toggle="modal" data-target=".o_onboarding_modal" + class="float-right o_onboarding_btn_close"> + <i class="fa fa-times" title="Close the onboarding panel" /> + </a> + </div> + <div> + <link type="text/css" href="` + test_css_url + `" rel="stylesheet"> + <div class="hello_banner">Here is the banner</div> + </div> + </div>`; createView({ View: TestView, @@ -60,7 +80,7 @@ QUnit.module('Views', { } return this._super(route, args); }, - }).then(function (view) { + }).then(async function (view) { var $banner = view.$('.hello_banner'); assert.strictEqual($banner.length, 1, "The view should contain the response from the controller."); @@ -73,6 +93,12 @@ QUnit.module('Views', { var $banner_link = $('link[href$="' + test_css_url + '"]', $banner); assert.strictEqual($banner_link.length, 0, "The stylesheet should have been removed from the banner."); + + await testUtils.dom.click(view.$('.o_onboarding_btn_close')); // click on close to remove banner + await testUtils.dom.click(view.$('.o_technical_modal .btn-primary:contains("Remove")')); // click on button remove from techinal modal + assert.strictEqual(view.$('.o_onboarding_container.show').length, 0, + "Banner should be removed from the view"); + view.destroy(); done(); }); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 7335a46c242..d78424841ff 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -48,6 +48,7 @@ QUnit.module('Views', { stop_date: {string: "stop date", type: "date"}, start: {string: "start datetime", type: "datetime"}, stop: {string: "stop datetime", type: "datetime"}, + delay: {string: "delay", type: "float"}, allday: {string: "allday", type: "boolean"}, partner_ids: {string: "attendees", type: "one2many", relation: 'partner', default: [[6, 0, [1]]]}, type: {string: "type", type: "integer"}, @@ -2331,6 +2332,115 @@ QUnit.module('Views', { calendar.destroy(); }); + QUnit.test('timzeone does not affect calendar with date field', async function (assert) { + assert.expect(8); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start_date" mode="month">'+ + '<field name="name"/>'+ + '<field name="start_date"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "create") { + assert.strictEqual(args.args[0].start_date, "2016-12-20 00:00:00"); + } + if (args.method === "write") { + assert.step(args.args[1].start_date); + } + return this._super(route, args); + }, + session: { + getTZOffset: function () { + return 120; // 2 hours timezone + }, + }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + await testUtils.nextTick(); + + assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "12/20/2016"); + + // Move event to another day (on 27 november) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').first() + ); + await testUtils.nextTick(); + assert.verifySteps(["2016-11-27 00:00:00"]); + assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "11/27/2016"); + + // Move event to last day (on 7 january) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').last() + ); + await testUtils.nextTick(); + assert.verifySteps(["2017-01-07 00:00:00"]); + assert.strictEqual(calendar.$('.o_field_start_date').text().trim(), "01/07/2017"); + calendar.destroy(); + }); + + QUnit.test('drag and drop on month mode with date_start and date_delay', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: + '<calendar date_start="start" date_delay="delay" mode="month">'+ + '<field name="name"/>'+ + '<field name="start"/>'+ + '<field name="delay"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + }, + mockRPC: function (route, args) { + if (args.method === "write") { + // delay should not be written at drag and drop + assert.equal(args.args[1].delay, undefined) + } + return this._super(route, args); + }, + }); + + // Create event (on 20 december) + var $cell = calendar.$('.fc-day-grid .fc-row:eq(3) .fc-day:eq(2)'); + testUtils.triggerMouseEvent($cell, "mousedown"); + testUtils.triggerMouseEvent($cell, "mouseup"); + await testUtils.nextTick(); + var $input = $('.modal-body input:first'); + await testUtils.fields.editInput($input, "An event"); + await testUtils.dom.click($('.modal button.btn:contains(Create)')); + await testUtils.nextTick(); + + // Move event to another day (on 27 november) + await testUtils.dragAndDrop( + calendar.$('.fc-event').first(), + calendar.$('.fc-day-top').first() + ); + + calendar.destroy(); + }); + QUnit.test('form_view_id attribute works (for creating events)', async function (assert) { assert.expect(1); diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js index e69b08aa3a8..39b601d9a51 100644 --- a/addons/web/static/tests/views/kanban_model_tests.js +++ b/addons/web/static/tests/views/kanban_model_tests.js @@ -302,6 +302,72 @@ QUnit.module('Views', { }) }); + + QUnit.test('call get (raw: true) before loading x2many data', async function (assert) { + // Sometimes, get can be called on a datapoint that is currently being + // reloaded, and thus in a partially updated state (e.g. in a kanban + // view, the user interacts with the searchview, and before the view is + // fully reloaded, it clicks on CREATE). Ideally, this shouldn't happen, + // but with the sync API of get, we can't change that easily. So at most, + // we can ensure that it doesn't crash. Moreover, sensitive functions + // requesting the state for more precise information that, e.g., the + // count, can do that in the mutex to ensure that the state isn't + // currently being reloaded. + // In this test, we have a grouped kanban view with a one2many, whose + // relational data is loaded in batch, once for all groups. We call get + // when the search_read for the first group has returned, but not the + // second (and thus, the read of the one2many hasn't started yet). + // Note: this test can be removed as soon as search_reads are performed + // alongside read_group. + var done = assert.async(); + assert.expect(2); + + this.data.partner.records[1].product_ids = [37, 41]; + this.params.fieldsInfo = { + kanban: { + product_ids: { + fieldsInfo: { + default: { display_name: {}, color: {} }, + }, + relatedFields: this.data.product.fields, + viewType: 'default', + }, + }, + }; + this.params.viewType = 'kanban'; + this.params.groupedBy = ['foo']; + + var block; + var def = testUtils.makeTestPromise(); + var model = await createModel({ + Model: KanbanModel, + data: this.data, + mockRPC: function (route) { + var result = this._super.apply(this, arguments); + if (route === '/web/dataset/search_read' && block) { + block = false; + return Promise.all([def]).then(_.constant(result)); + } + return result; + }, + }); + + model.load(this.params).then(function (handle) { + block = true; + model.reload(handle, {}); + + var state = model.get(handle, {raw: true}); + assert.strictEqual(state.count, 2); + + def.resolve(); + + state = model.get(handle, {raw: true}); + assert.strictEqual(state.count, 2); + }).then(function() { + model.destroy(); + done(); + }); + }); }); }); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index cd10d232506..32028a25932 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -300,7 +300,7 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.test('editable list datetimepicker destroy widget', async function (assert) { + QUnit.test('editable list datetimepicker destroy widget (edition)', async function (assert) { assert.expect(7); var eventPromise = testUtils.makeTestPromise(); @@ -314,22 +314,60 @@ QUnit.module('Views', { }); list.$el.on({ 'show.datetimepicker': async function () { - assert.equal($('.bootstrap-datetimepicker-widget').length, 1, - 'The datetimepicker is open'); + assert.containsOnce(list, '.o_selected_row'); + assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget'); - assert.equal(list.$('.o_data_row').length, 5, - 'There should be 5 rows'); + await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape'); + + assert.containsOnce(list, '.o_selected_row'); + assert.containsNone($('body'), '.bootstrap-datetimepicker-widget'); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'escape'); + + assert.containsNone(list, '.o_selected_row'); + + eventPromise.resolve(); + } + }); + + assert.containsN(list, '.o_data_row', 4); + assert.containsNone(list, '.o_selected_row'); - assert.equal(list.$('.o_selected_row').length, 1, - 'One row in edit mode'); + await testUtils.dom.click(list.$('.o_data_cell:first')); + await testUtils.dom.openDatepicker(list.$('.o_datepicker')); + + await eventPromise; + list.destroy(); + }); + + QUnit.test('editable list datetimepicker destroy widget (new line)', async function (assert) { + assert.expect(10); + var eventPromise = testUtils.makeTestPromise(); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top">' + + '<field name="date"/>' + + '</tree>', + }); + list.$el.on({ + 'show.datetimepicker': async function () { + assert.containsOnce($('body'), '.bootstrap-datetimepicker-widget'); + assert.containsN(list, '.o_data_row', 5); + assert.containsOnce(list, '.o_selected_row'); await testUtils.fields.triggerKeydown(list.$('.o_datepicker_input'), 'escape'); - assert.equal(list.$('.o_data_row').length, 4, - 'There should be 4 rows'); + assert.containsNone($('body'), '.bootstrap-datetimepicker-widget'); + assert.containsN(list, '.o_data_row', 5); + assert.containsOnce(list, '.o_selected_row'); + + await testUtils.fields.triggerKeydown($(document.activeElement), 'escape'); - assert.equal(list.$('.o_selected_row').length, 0, - 'No row should be in edit mode'); + assert.containsN(list, '.o_data_row', 4); + assert.containsNone(list, '.o_selected_row'); eventPromise.resolve(); } @@ -341,6 +379,7 @@ QUnit.module('Views', { 'No row should be in edit mode'); await testUtils.dom.click(list.$buttons.find('.o_list_button_add')); + await testUtils.dom.openDatepicker(list.$('.o_datepicker')); await eventPromise; list.destroy(); @@ -361,6 +400,34 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('discard a new record in editable="top" list with less than 4 records', async function (assert) { + assert.expect(7); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top"><field name="bar"/></tree>', + domain: [['bar', '=', true]], + }); + + assert.containsN(list, '.o_data_row', 3); + assert.containsN(list, 'tbody tr', 4); + + await testUtils.dom.click(list.$('.o_list_button_add')); + + assert.containsN(list, '.o_data_row', 4); + assert.hasClass(list.$('tbody tr:first'), 'o_selected_row'); + + await testUtils.dom.click(list.$('.o_list_button_discard')); + + assert.containsN(list, '.o_data_row', 3); + assert.containsN(list, 'tbody tr', 4); + assert.hasClass(list.$('tbody tr:first'), 'o_data_row'); + + list.destroy(); + }); + QUnit.test('basic grouped list rendering', async function (assert) { assert.expect(4); @@ -4628,6 +4695,50 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('readonly boolean in editable list is readonly', async function (assert) { + assert.expect(6); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="bar" attrs="{\'readonly\': [(\'foo\', \'!=\', \'yop\')]}"/>' + + '</tree>', + }); + + // clicking on disabled checkbox with active row does not work + var $disabledCell = list.$('.o_data_row:eq(1) .o_data_cell:last-child'); + await testUtils.dom.click($disabledCell.prev()); + assert.containsOnce($disabledCell, ':disabled:checked'); + var $disabledLabel = $disabledCell.find('.custom-control-label'); + await testUtils.dom.click($disabledLabel); + assert.containsOnce($disabledCell, ':checked', + "clicking disabled checkbox did not work" + ); + assert.ok( + $(document.activeElement).is('input[type="text"]'), + "disabled checkbox is not focused after click" + ); + + // clicking on enabled checkbox with active row toggles check mark + var $enabledCell = list.$('.o_data_row:eq(0) .o_data_cell:last-child'); + await testUtils.dom.click($enabledCell.prev()); + assert.containsOnce($enabledCell, ':checked:not(:disabled)'); + var $enabledLabel = $enabledCell.find('.custom-control-label'); + await testUtils.dom.click($enabledLabel); + assert.containsNone($enabledCell, ':checked', + "clicking enabled checkbox worked and unchecked it" + ); + assert.ok( + $(document.activeElement).is('input[type="checkbox"]'), + "enabled checkbox is focused after click" + ); + + list.destroy(); + }); + QUnit.test('grouped list with async widget', async function (assert) { assert.expect(4); @@ -5744,6 +5855,78 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('execute group header button with keyboard navigation', async function (assert) { + assert.expect(13); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree>' + + '<field name="foo"/>' + + '<groupby name="m2o">' + + '<button type="object" name="some_method" string="Do this"/>' + + '</groupby>' + + '</tree>', + groupBy: ['m2o'], + intercepts: { + execute_action: function (ev) { + assert.strictEqual(ev.data.action_data.name, 'some_method'); + }, + }, + }); + + assert.containsNone(list, '.o_data_row', "all groups should be closed"); + + // focus create button as a starting point + list.$('.o_list_button_add').focus(); + assert.ok(document.activeElement.classList.contains('o_list_button_add')); + await testUtils.fields.triggerKeydown($(document.activeElement), 'down'); + assert.strictEqual(document.activeElement.tagName, 'INPUT', + 'focus should now be on the record selector (list header)'); + await testUtils.fields.triggerKeydown($(document.activeElement), 'down'); + assert.strictEqual(document.activeElement.textContent, 'Value 1 (3)', + 'focus should be on first group header'); + + // unfold first group + await testUtils.fields.triggerKeydown($(document.activeElement), 'enter'); + assert.containsN(list, '.o_data_row', 3, "first group should be open"); + + // move to first record of opened group + await testUtils.fields.triggerKeydown($(document.activeElement), 'down'); + assert.strictEqual(document.activeElement.tagName, 'INPUT', + 'focus should be in first row checkbox'); + + // move back to the group header + await testUtils.fields.triggerKeydown($(document.activeElement), 'up'); + assert.ok(document.activeElement.classList.contains('o_group_name'), + 'focus should be back on first group header'); + + // fold the group + await testUtils.fields.triggerKeydown($(document.activeElement), 'enter'); + assert.ok(document.activeElement.classList.contains('o_group_name'), + 'focus should still be on first group header'); + assert.containsNone(list, '.o_data_row', "first group should now be folded"); + + // unfold the group + await testUtils.fields.triggerKeydown($(document.activeElement), 'enter'); + assert.ok(document.activeElement.classList.contains('o_group_name'), + 'focus should still be on first group header'); + assert.containsN(list, '.o_data_row', 3, "first group should be open"); + + // simulate a move to the group header button with tab (we can't trigger a native event + // programmatically, see https://stackoverflow.com/a/32429197) + list.$('.o_group_header .o_group_buttons button:first').focus(); + assert.strictEqual(document.activeElement.tagName, 'BUTTON', + 'focus should be on the group header button'); + + // click on the button by pressing enter + await testUtils.fields.triggerKeydown($(document.activeElement), 'enter'); + assert.containsN(list, '.o_data_row', 3, "first group should still be open"); + + list.destroy(); + }); + QUnit.test('add a new row in grouped editable="top" list', async function (assert) { assert.expect(7); diff --git a/addons/web/static/tests/views/search_view_tests.js b/addons/web/static/tests/views/search_view_tests.js index 2ea9f2f137b..3fe15d661dd 100644 --- a/addons/web/static/tests/views/search_view_tests.js +++ b/addons/web/static/tests/views/search_view_tests.js @@ -732,6 +732,37 @@ QUnit.module('Search View', { actionManager.destroy(); }); + QUnit.test('Filter with JSON-parsable domain works', async function (assert) { + assert.expect(1); + + var domain = [['foo' ,'=', 'Gently Weeps']]; + var xml_domain = JSON.stringify(domain); + + var actionManager = await createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + mockRPC: function (route, args) { + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, domain, + 'A JSON parsable xml domain should be handled just like any other'); + } + return this._super.apply(this, arguments); + } + }); + + this.archs['partner,5,search'] = + '<search>'+ + '<filter string="Foo" name="gently_weeps" domain="' + _.escape(xml_domain) + '" />' + + '</search>'; + this.actions[0].search_view_id = [5, 'search']; + this.actions[0].context = {search_default_gently_weeps: true}; + + await actionManager.doAction(1); + + actionManager.destroy(); + }); + QUnit.module('Favorites Menu'); QUnit.test('dynamic filters are saved dynamic', async function (assert) { @@ -1249,7 +1280,7 @@ QUnit.module('Search View', { }); QUnit.test('Customizing filter does not close the filter dropdown', async function (assert) { - assert.expect(3); + assert.expect(5); var self = this; _.each(this.data.partner.records.slice(), function (rec) { @@ -1273,6 +1304,13 @@ QUnit.module('Search View', { 'partner,false,search': '<search><field name="date_field"/></search>', }, res_id: 1, + intercepts: { + create_filter: function (ev) { + var data = ev.data; + assert.strictEqual(data.filter.name, 'Fire on the bayou'); + assert.strictEqual(data.filter.is_default, true); + }, + }, }); await testUtils.fields.many2one.clickOpenDropdown('bar'); @@ -1296,9 +1334,22 @@ QUnit.module('Search View', { $input.click(); await testUtils.nextTick(); }); - assert.isVisible($filterDropdown); + // Favorites Menu + var $modal = $('.modal'); + await testUtils.dom.click($modal.find('.o_favorites_menu_button')); + await testUtils.dom.click($modal.find('.o_add_favorite')); + $modal.find('.o_search_options .dropdown-menu').one('click', function (ev) { + // This handler is on the webClient + // But since the test suite doesn't have one + // We manually set it here + ev.stopPropagation(); + }); + await testUtils.fields.editInput($modal.find('div.o_favorite_name input'), 'Fire on the bayou'); + await testUtils.dom.click($modal.find('.o_add_favorite ~ div label:contains(Use by default)')); + await testUtils.dom.click($modal.find('.o_save_favorite button')); + form.destroy(); }); }); diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml index ea963fb926b..4865160be0e 100644 --- a/addons/web/views/report_templates.xml +++ b/addons/web/views/report_templates.xml @@ -5,7 +5,8 @@ <template id="report_assets_common"> <t t-call="web._assets_helpers"> <!-- FIXME web_editor ? --> - <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/bootstrap_overridden.scss"/> + <link rel="stylesheet" type="text/scss" href="/web/static/src/scss/bootstrap_overridden_report.scss"/> + <link rel="stylesheet" type="text/scss" href="/web_editor/static/src/scss/bootstrap_overridden.scss"/> </t> <t t-call="web._assets_bootstrap"/> |
