summaryrefslogtreecommitdiff
path: root/addons/web
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-05-13 15:26:12 +0200
committerChristophe Simonis <chs@odoo.com>2019-05-13 15:26:12 +0200
commit8dd5a85267b522ba569ab9ed44901f95fffbeb4f (patch)
treeddb62d1e83e1b810b96c05f6819ca66f8d6b4955 /addons/web
parent15716642e1975b4f2f29916011a4be29804cda88 (diff)
parent60e71302a38b36214efec1213882f573e3ea453c (diff)
[MERGE] forward port branch saas-12.3 up to 60e71302a38
Diffstat (limited to 'addons/web')
-rw-r--r--addons/web/controllers/main.py25
-rw-r--r--addons/web/doc/module.rst2
-rw-r--r--addons/web/static/lib/tempusdominus/tempusdominus.js3
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js14
-rw-r--r--addons/web/static/src/js/core/dom.js21
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js15
-rw-r--r--addons/web/static/src/js/libs/bootstrap.js6
-rw-r--r--addons/web/static/src/js/public/public_root.js7
-rw-r--r--addons/web/static/src/js/tools/debug_manager.js2
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js1
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js13
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js4
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js7
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_renderer.js10
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js3
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js33
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js60
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js46
-rw-r--r--addons/web/static/src/scss/bootstrap_overridden.scss2
-rw-r--r--addons/web/static/src/scss/bootstrap_overridden_report.scss2
-rw-r--r--addons/web/static/src/scss/fonts.scss30
-rw-r--r--addons/web/static/src/scss/layout_background.scss2
-rw-r--r--addons/web/static/src/scss/layout_boxed.scss2
-rw-r--r--addons/web/static/src/scss/layout_clean.scss2
-rw-r--r--addons/web/static/src/scss/list_view.scss3
-rw-r--r--addons/web/static/src/scss/list_view_extra.scss6
-rw-r--r--addons/web/static/src/xml/base.xml11
-rw-r--r--addons/web/static/src/xml/menu.xml4
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js28
-rw-r--r--addons/web/static/tests/fields/relational_fields/field_one2many_tests.js64
-rw-r--r--addons/web/static/tests/helpers/qunit_config.js5
-rw-r--r--addons/web/static/tests/helpers/test_utils_dom.js19
-rw-r--r--addons/web/static/tests/views/abstract_view_banner_tests.js40
-rw-r--r--addons/web/static/tests/views/calendar_tests.js110
-rw-r--r--addons/web/static/tests/views/kanban_model_tests.js66
-rw-r--r--addons/web/static/tests/views/list_tests.js205
-rw-r--r--addons/web/static/tests/views/search_view_tests.js55
-rw-r--r--addons/web/views/report_templates.xml3
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"/>