summaryrefslogtreecommitdiff
path: root/addons/web
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web')
-rw-r--r--addons/web/controllers/main.py52
-rw-r--r--addons/web/models/ir_http.py3
-rw-r--r--addons/web/static/src/js/chrome/search_inputs.js2
-rw-r--r--addons/web/static/src/js/chrome/view_manager.js2
-rw-r--r--addons/web/static/src/js/core/session.js15
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js10
-rw-r--r--addons/web/static/src/js/fields/field_utils.js15
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js53
-rw-r--r--addons/web/static/src/js/fields/special_fields.js2
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js4
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js207
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js27
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js53
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js5
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js13
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js22
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js12
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js2
-rw-r--r--addons/web/static/src/js/widgets/data_export.js2
-rw-r--r--addons/web/static/src/js/widgets/debug_manager.js112
-rw-r--r--addons/web/static/src/xml/base.xml4
-rw-r--r--addons/web/static/src/xml/kanban.xml4
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js197
-rw-r--r--addons/web/static/tests/fields/field_utils_tests.js28
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js236
-rw-r--r--addons/web/static/tests/helpers/test_utils.js7
-rw-r--r--addons/web/static/tests/views/basic_model_tests.js78
-rw-r--r--addons/web/static/tests/views/calendar_tests.js271
-rw-r--r--addons/web/static/tests/views/form_tests.js58
-rw-r--r--addons/web/static/tests/views/kanban_tests.js78
-rw-r--r--addons/web/static/tests/views/list_tests.js65
-rw-r--r--addons/web/static/tests/views/view_dialogs_tests.js31
-rw-r--r--addons/web/static/tests/widgets/data_export_tests.js84
33 files changed, 1518 insertions, 236 deletions
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index 4e37cb482fd..f0a6650f3a6 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
import babel.messages.pofile
import base64
-import csv
import datetime
import functools
import glob
import hashlib
import imghdr
+import io
import itertools
import jinja2
import json
@@ -22,8 +23,8 @@ import werkzeug.utils
import werkzeug.wrappers
import zlib
from xml.etree import ElementTree
-from cStringIO import StringIO
from werkzeug import url_decode
+from werkzeug import iri_to_uri
import odoo
@@ -117,12 +118,11 @@ def ensure_db(redirect='/web/database/selector'):
# Thus, we redirect the user to the same page but with the session cookie set.
# This will force using the database route dispatcher...
r = request.httprequest
- url_redirect = r.base_url
+ url_redirect = werkzeug.urls.url_parse(r.base_url)
if r.query_string:
- # Can't use werkzeug.wrappers.BaseRequest.url with encoded hashes:
- # https://github.com/amigrave/werkzeug/commit/b4a62433f2f7678c234cdcac6247a869f90a7eb7
- url_redirect += '?' + r.query_string
- response = werkzeug.utils.redirect(url_redirect, 302)
+ # in P3, request.query_string is bytes, the rest is text, can't mix them
+ query_string = iri_to_uri(r.query_string)
+ url_redirect = url_redirect.replace(query=query_string)
request.session.db = db
abort_and_redirect(url_redirect)
@@ -964,7 +964,7 @@ class Binary(http.Controller):
def force_contenttype(self, headers, contenttype='image/png'):
dictheaders = dict(headers)
dictheaders['Content-Type'] = contenttype
- return list(pycompat.items(dictheaders))
+ return list(dictheaders.items())
@http.route(['/web/content',
'/web/content/<string:xmlid>',
@@ -1137,8 +1137,8 @@ class Binary(http.Controller):
""", (uid,))
row = cr.fetchone()
if row and row[0]:
- image_base64 = str(row[0]).decode('base64')
- image_data = StringIO(image_base64)
+ image_base64 = base64.b64decode(row[0])
+ image_data = io.BytesIO(image_base64)
imgext = '.' + (imghdr.what(None, h=image_base64) or 'png')
response = http.send_file(image_data, filename=imgname + imgext, mtime=row[1])
else:
@@ -1217,7 +1217,7 @@ class Export(http.Controller):
else:
fields['.id'] = fields.pop('id', {'string': 'ID'})
- fields_sequence = sorted(pycompat.items(fields),
+ fields_sequence = sorted(fields.items(),
key=lambda field: odoo.tools.ustr(field[1].get('string', '')))
records = []
@@ -1228,7 +1228,7 @@ class Export(http.Controller):
if field.get('readonly'):
# If none of the field's states unsets readonly, skip the field
if all(dict(attrs).get('readonly', True)
- for attrs in pycompat.values(field.get('states', {}))):
+ for attrs in field.get('states', {}).values()):
continue
if not field.get('exportable', True):
continue
@@ -1320,7 +1320,7 @@ class Export(http.Controller):
export_fields = [field.split('/', 1)[1] for field in fields]
return (
(prefix + '/' + k, prefix_string + '/' + v)
- for k, v in pycompat.items(self.fields_info(model, export_fields)))
+ for k, v in self.fields_info(model, export_fields).items())
class ExportFormat(object):
raw_data = False
@@ -1388,32 +1388,22 @@ class CSVExport(ExportFormat, http.Controller):
return base + '.csv'
def from_data(self, fields, rows):
- fp = StringIO()
- writer = csv.writer(fp, quoting=csv.QUOTE_ALL)
+ fp = io.BytesIO()
+ writer = pycompat.csv_writer(fp, quoting=1)
- writer.writerow([name.encode('utf-8') for name in fields])
+ writer.writerow(fields)
for data in rows:
row = []
for d in data:
- if isinstance(d, unicode):
- try:
- d = d.encode('utf-8')
- except UnicodeError:
- pass
- if d is False: d = None
-
# Spreadsheet apps tend to detect formulas on leading =, + and -
- if type(d) is str and d.startswith(('=', '-', '+')):
+ if isinstance(d, pycompat.string_types) and d.startswith(('=', '-', '+')):
d = "'" + d
- row.append(d)
+ row.append(pycompat.to_text(d))
writer.writerow(row)
- fp.seek(0)
- data = fp.read()
- fp.close()
- return data
+ return fp.getvalue()
class ExcelExport(ExportFormat, http.Controller):
# Excel needs raw data to correctly handle numbers and date values
@@ -1446,7 +1436,7 @@ class ExcelExport(ExportFormat, http.Controller):
for row_index, row in enumerate(rows):
for cell_index, cell_value in enumerate(row):
cell_style = base_style
- if isinstance(cell_value, basestring):
+ if isinstance(cell_value, pycompat.string_types):
cell_value = re.sub("\r", " ", cell_value)
elif isinstance(cell_value, datetime.datetime):
cell_style = datetime_style
@@ -1454,7 +1444,7 @@ class ExcelExport(ExportFormat, http.Controller):
cell_style = date_style
worksheet.write(row_index + 1, cell_index, cell_value, cell_style)
- fp = StringIO()
+ fp = io.BytesIO()
workbook.save(fp)
fp.seek(0)
data = fp.read()
diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py
index 36ffe593acb..23a87f4cd02 100644
--- a/addons/web/models/ir_http.py
+++ b/addons/web/models/ir_http.py
@@ -5,8 +5,6 @@ import json
from odoo import models
from odoo.http import request
-import pytz
-import datetime
import odoo
@@ -26,7 +24,6 @@ class Http(models.AbstractModel):
version_info = odoo.service.common.exp_version()
return {
"session_id": request.session.sid,
- "tzOffset": self._context.get('tz') and datetime.datetime.now(pytz.timezone(self._context['tz'])).utcoffset().total_seconds() / 60 or False,
"uid": request.session.uid,
"is_system": request.env.user._is_system(),
"is_superuser": request.env.user._is_superuser(),
diff --git a/addons/web/static/src/js/chrome/search_inputs.js b/addons/web/static/src/js/chrome/search_inputs.js
index 2a265ff0fdd..2dba4c0dd99 100644
--- a/addons/web/static/src/js/chrome/search_inputs.js
+++ b/addons/web/static/src/js/chrome/search_inputs.js
@@ -232,7 +232,7 @@ var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerFiel
error_message: _t("not a valid integer"),
parse: function (value) {
try {
- return field_utils.parse.interger(value);
+ return field_utils.parse.integer(value);
} catch (e) {
return NaN;
}
diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js
index 5962bb28acb..2dae906c6db 100644
--- a/addons/web/static/src/js/chrome/view_manager.js
+++ b/addons/web/static/src/js/chrome/view_manager.js
@@ -26,7 +26,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
search: function(event) {
var d = event.data;
_.extend(this.env, this._process_search_data(d.domains, d.contexts, d.groupbys));
- this.active_view.controller.reload(this.env);
+ this.active_view.controller.reload(_.extend({}, this.env));
},
switch_view: function(event) {
if ('res_id' in event.data) {
diff --git a/addons/web/static/src/js/core/session.js b/addons/web/static/src/js/core/session.js
index c21a855bab5..065b3b360e5 100644
--- a/addons/web/static/src/js/core/session.js
+++ b/addons/web/static/src/js/core/session.js
@@ -290,10 +290,6 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
var result = _.extend({}, window.odoo.session_info);
delete result.session_id;
_.extend(this, result);
- if (this.tzOffset === false) {
- // send by server to have the user timezone and not the browser timezone
- this.tzOffset = -new Date().getTimezoneOffset();
- }
return $.when();
},
check_session_id: function () {
@@ -415,6 +411,17 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
}) ? '' : this.prefix;
return prefix + path + qs;
},
+ /**
+ * Returns the time zone difference (in minutes) from the current locale
+ * (host system settings) to UTC, for a given date. The offset is positive
+ * if the local timezone is behind UTC, and negative if it is ahead.
+ *
+ * @param {string || moment} date a valid string date or moment instance
+ * @returns {integer}
+ */
+ getTZOffset: function (date) {
+ return -new Date(date).getTimezoneOffset();
+ },
});
return Session;
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 2d18a0ddb56..a1f87a38fd2 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -341,6 +341,9 @@ var FieldChar = InputField.extend(TranslatableFieldMixin, {
*/
_renderEdit: function () {
var def = this._super.apply(this, arguments);
+ if (this.field.size && this.field.size > 0) {
+ this.$el.attr('maxlength', this.field.size);
+ }
this.$el = this.$el.add(this._renderTranslateButton());
return def;
},
@@ -450,7 +453,7 @@ var FieldDateTime = FieldDate.extend({
*/
_getValue: function () {
var value = this.datewidget.getValue();
- return value && value.add(-this.getSession().tzOffset, 'minutes');
+ return value && value.add(-this.getSession().getTZOffset(value), 'minutes');
},
/**
@@ -460,7 +463,7 @@ var FieldDateTime = FieldDate.extend({
* @private
*/
_makeDatePicker: function () {
- var value = this.value && this.value.clone().add(this.getSession().tzOffset, 'minutes');
+ var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes');
return new datepicker.DateTimeWidget(this, {defaultDate: value});
},
@@ -471,7 +474,7 @@ var FieldDateTime = FieldDate.extend({
* @private
*/
_renderEdit: function () {
- var value = this.value && this.value.clone().add(this.getSession().tzOffset, 'minutes');
+ var value = this.value && this.value.clone().add(this.getSession().getTZOffset(this.value), 'minutes');
this.datewidget.setValue(value);
this.$input = this.datewidget.$input;
},
@@ -791,6 +794,7 @@ var FieldFloatTime = FieldFloat.extend({
var FieldText = InputField.extend(TranslatableFieldMixin, {
className: 'o_field_text',
supportedFieldTypes: ['text'],
+ tagName: 'span',
/**
* @constructor
diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js
index 9d3ae75af92..cbb2a856337 100644
--- a/addons/web/static/src/js/fields/field_utils.js
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -92,8 +92,10 @@ function formatDate(value, field, options) {
if (value === false) {
return "";
}
- if (!options || !('timezone' in options) || options.timezone) {
- value = value.clone().add(session.tzOffset < 0 ? -1 : 0, 'days');
+ if (field && field.type === 'datetime') {
+ if (!options || !('timezone' in options) || options.timezone) {
+ value = value.clone().add(session.getTZOffset(value), 'minutes');
+ }
}
var l10n = core._t.database.parameters;
var date_format = time.strftime_to_moment_format(l10n.date_format);
@@ -118,7 +120,7 @@ function formatDateTime(value, field, options) {
return "";
}
if (!options || !('timezone' in options) || options.timezone) {
- value = value.clone().add(session.tzOffset, 'minutes');
+ value = value.clone().add(session.getTZOffset(value), 'minutes');
}
var l10n = core._t.database.parameters;
var date_format = time.strftime_to_moment_format(l10n.date_format);
@@ -354,11 +356,8 @@ function parseDate(value, field, options) {
date = moment.utc(value);
} else {
date = moment.utc(value, [datePattern, datePatternWoZero, moment.ISO_8601], true);
- if (options && options.timezone) {
- date.add(session.tzOffset > 0 ? -1 : 0, 'days');
- }
}
- if (date.isValid() && date.year() >= 1900) {
+ if (date.isValid()) {
if (date.year() === 0) {
date.year(moment.utc().year());
}
@@ -402,7 +401,7 @@ function parseDateTime(value, field, options) {
} else {
datetime = moment.utc(value, [pattern1, pattern2, moment.ISO_8601], true);
if (options && options.timezone) {
- datetime.add(-session.tzOffset, 'minutes');
+ datetime.add(-session.getTZOffset(datetime), 'minutes');
}
}
if (datetime.isValid()) {
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 0dce6ec2bb4..e05a53194c3 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -21,7 +21,6 @@ var dialogs = require('web.view_dialogs');
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
-var field_utils = require('web.field_utils');
var KanbanRenderer = require('web.KanbanRenderer');
var ListRenderer = require('web.ListRenderer');
var Pager = require('web.Pager');
@@ -226,7 +225,7 @@ var FieldMany2One = AbstractField.extend({
var self = this;
var def = $.Deferred();
var slowCreate = function () {
- var dialog = self._searchCreatePopup.bind(self, "form", false, self._createContext(name));
+ var dialog = self._searchCreatePopup("form", false, self._createContext(name));
dialog.on('closed', self, def.resolve.bind(def));
};
if (this.nodeOptions.quick_create) {
@@ -290,7 +289,7 @@ var FieldMany2One = AbstractField.extend({
_reset: function () {
this._super.apply(this, arguments);
this.floating = false;
- this.m2o_value = field_utils.format.many2one(this.value);
+ this.m2o_value = this._formatValue(this.value);
},
/**
* @private
@@ -394,7 +393,7 @@ var FieldMany2One = AbstractField.extend({
*/
_searchCreatePopup: function (view, ids, context) {
var self = this;
- new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, {
+ return new dialogs.SelectCreateDialog(this, _.extend({}, this.nodeOptions, {
res_model: this.field.relation,
domain: this.record.getDomain({fieldName: this.name}),
context: _.extend({}, this.record.getContext(this.recordParams), context || {}),
@@ -567,7 +566,7 @@ var KanbanFieldMany2One = AbstractField.extend({
tagName: 'span',
init: function () {
this._super.apply(this, arguments);
- this.m2o_value = field_utils.format.many2one(this.value);
+ this.m2o_value = this._formatValue(this.value);
},
//--------------------------------------------------------------------------
@@ -2023,7 +2022,8 @@ var FieldRadio = FieldSelection.extend({
* `name_get` as data).
*/
var FieldReference = FieldMany2One.extend({
- supportedFieldTypes: ['reference'],
+ specialData: "_fetchSpecialReference",
+ supportedFieldTypes: ['char', 'reference'],
template: 'FieldReference',
events: _.extend({}, FieldMany2One.prototype.events, {
'change select': '_onSelectionChange',
@@ -2036,22 +2036,8 @@ var FieldReference = FieldMany2One.extend({
// needs to be copied as it is an unmutable object
this.field = _.extend({}, this.field);
- if (this.value) {
- var model = null;
- // The value of a Reference field can either be a string in the form
- // of model,record_id or an object
- if (typeof this.value === "string") {
- var value = this.value.split(',');
- model = value[0];
- this.value = {
- res_id: parseInt(value[1]),
- };
- } else {
- model = this.value.model;
- }
- this._setRelation(model);
- }
+ this._setState();
},
/**
* @override
@@ -2071,7 +2057,13 @@ var FieldReference = FieldMany2One.extend({
* @override
*/
_formatValue: function () {
- return this.recordData.display_name;
+ var value;
+ if (this.field.type === 'char') {
+ value = this.record.specialData[this.name];
+ } else {
+ value = this.value;
+ }
+ return value && value.data && value.data.display_name || '';
},
/**
@@ -2097,8 +2089,10 @@ var FieldReference = FieldMany2One.extend({
* @private
*/
_reset: function () {
+ var value = this.$('select').val();
+ this._setState();
this._super.apply(this, arguments);
- this._setRelation(this.value && this.value.model);
+ this.$('select').val(this.value && this.value.model || value);
},
/**
* Set `relation` key in field properties.
@@ -2111,6 +2105,19 @@ var FieldReference = FieldMany2One.extend({
this.field.relation = model;
},
/**
+ * @private
+ */
+ _setState: function () {
+ if (this.field.type === 'char') {
+ // in this case, the value is stored in specialData instead
+ this.value = this.record.specialData[this.name];
+ }
+
+ if (this.value) {
+ this._setRelation(this.value.model);
+ }
+ },
+ /**
* @override
* @private
*/
diff --git a/addons/web/static/src/js/fields/special_fields.js b/addons/web/static/src/js/fields/special_fields.js
index 69647902afe..7a0bb9ed092 100644
--- a/addons/web/static/src/js/fields/special_fields.js
+++ b/addons/web/static/src/js/fields/special_fields.js
@@ -54,7 +54,7 @@ var FieldTimezoneMismatch = FieldSelection.extend({
}
var offset = this.recordData.tz_offset.match(/([+-])([0-9]{2})([0-9]{2})/);
offset = (offset[1] === '-' ? -1 : 1) * (parseInt(offset[2])*60 + parseInt(offset[3]));
- var datetime = field_utils.format.datetime(moment.utc().add(offset, 'minutes'), {timezone: false});
+ var datetime = field_utils.format.datetime(moment.utc().add(offset, 'minutes'), this.field, {timezone: false});
var content = this.$option.html().split(' ')[0];
content += ' ('+ datetime + ')';
this.$option.html(content);
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 6ba9dbea477..cfdee6793dd 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -329,9 +329,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
if (options && options.readonlyIfRealDiscard && !needDiscard) {
return;
}
- if (needDiscard) { // Just some optimization
- self.model.discardChanges(recordID);
- }
+ self.model.discardChanges(recordID);
if (self.model.isNew(recordID)) {
self._abandonRecord(recordID);
return;
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 8755805f993..0d95c292070 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -186,6 +186,29 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * Onchange RPCs may return values for fields that are not in the current
+ * view. Those fields might even be unknown when the onchange returns (e.g.
+ * in x2manys, we only know the fields that are used in the inner view, but
+ * not those used in the potential form view opened in a dialog when a sub-
+ * record is clicked). When this happens, we can't infer their type, so the
+ * given value can't be processed. It is instead stored in the '_rawChanges'
+ * key of the record, without any processing. Later on, if this record is
+ * displayed in another view (e.g. the user clicked on it in the x2many
+ * list, and the record opens in a dialog), those changes that were left
+ * behind must be applied. This function applies changes stored in
+ * '_rawChanges' for a given viewType.
+ *
+ * @param {string} id local resource id of a record
+ * @param {string} viewType the current viewType
+ * @returns {Deferred -> string} resolves to the id of the record
+ */
+ applyRawChanges: function (recordID, viewType) {
+ var record = this.localData[recordID];
+ return this._applyOnChange(record._rawChanges, record, viewType).then(function () {
+ return record.id;
+ });
+ },
+ /**
* Delete a list of records, then, if the records have a parent, reload it.
*
* @todo we should remove the deleted records from the localData
@@ -754,14 +777,15 @@ var BasicModel = AbstractModel.extend({
if (newValue instanceof Array) {
rec._savePoint = newValue.slice(0);
} else {
- // save the viewType of edition, so that the correct readonly modifiers
- // can be evaluated when the record will be saved
- for (var fieldName in (rec._changes || {})) {
- rec._editionViewType[fieldName] = options.viewType;
- }
rec._savePoint = _.extend({}, newValue);
}
});
+
+ // save the viewType of edition, so that the correct readonly modifiers
+ // can be evaluated when the record will be saved
+ _.each((record._changes || {}), function (value, fieldName) {
+ record._editionViewType[fieldName] = options.viewType;
+ });
}
var shouldReload = 'reload' in options ? options.reload : true;
var method = self.isNew(record_id) ? 'create' : 'write';
@@ -896,18 +920,23 @@ var BasicModel = AbstractModel.extend({
* @returns {Deferred -> string} resolves to the group id
*/
toggleGroup: function (groupId) {
+ var self = this;
var group = this.localData[groupId];
if (group.isOpen) {
group.isOpen = false;
group.data = [];
+ group.res_ids = [];
group.offset = 0;
+ this._updateParentResIDs(group);
return $.when(groupId);
}
if (!group.isOpen) {
group.isOpen = true;
var def;
if (group.count > 0) {
- def = this._load(group);
+ def = this._load(group).then(function () {
+ self._updateParentResIDs(group);
+ });
}
return $.when(def).then(function () {
return groupId;
@@ -1093,21 +1122,33 @@ var BasicModel = AbstractModel.extend({
* @param {Object} values the result of the onchange RPC (a mapping of
* fieldnames to their value)
* @param {Object} record
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred}
*/
- _applyOnChange: function (values, record) {
+ _applyOnChange: function (values, record, viewType) {
var self = this;
var defs = [];
var rec;
+ viewType = viewType || record.viewType;
record._changes = record._changes || {};
_.each(values, function (val, name) {
var field = record.fields[name];
if (!field) {
- return; // ignore changes of unknown fields
+ // this field is unknown so we can't process it for now (it is not
+ // in the current view anyway, otherwise it wouldn't be unknown.
+ // we store its value without processing it, so that if we later
+ // on switch to another view in which this field is displayed,
+ // we could process it as we would know its type then.
+ // use case: an onchange sends a create command for a one2many,
+ // in the dict of values, there is a value for a field that is
+ // not in the one2many list, but that is in the one2many form.
+ record._rawChanges[name] = val;
+ return;
}
-
- if (field.type === 'many2one' ) {
- var id = false;
+ var id;
+ if (field.type === 'many2one') {
+ id = false;
// in some case, the value returned by the onchange can
// be false (no value), so we need to avoid creating a
// local record for that.
@@ -1126,13 +1167,27 @@ var BasicModel = AbstractModel.extend({
id = rec.id;
}
record._changes[name] = id;
+ } else if (field.type === 'reference') {
+ id = false;
+ if (val) {
+ var ref = val.split(',');
+ rec = self._makeDataPoint({
+ context: record.context,
+ data: {id: parseInt(ref[1])},
+ modelName: ref[0],
+ parentID: record.id,
+ });
+ defs.push(self._fetchNameGet(rec));
+ id = rec.id;
+ }
+ record._changes[name] = id;
} else if (field.type === 'one2many' || field.type === 'many2many') {
var listId = record._changes[name] || record.data[name];
var list;
if (listId) {
list = self.localData[listId];
} else {
- var fieldInfo = record.fieldsInfo[record.viewType][name];
+ var fieldInfo = record.fieldsInfo[viewType][name];
if (!fieldInfo) {
return; //ignore changes of x2many not in view
}
@@ -1142,7 +1197,7 @@ var BasicModel = AbstractModel.extend({
parentID: record.id,
static: true,
type: 'list',
- viewtype: fieldInfo.viewType,
+ viewType: fieldInfo.viewType,
});
}
record._changes[name] = list.id;
@@ -1535,15 +1590,17 @@ var BasicModel = AbstractModel.extend({
var self = this;
var model;
var records = [];
+ var ids = [];
list = this._applyX2ManyOperations(list);
- var ids = _.map(list.data, function (localId) {
+ _.each(list.data, function (localId) {
var record = self.localData[localId];
var data = record._changes || record.data;
var many2oneId = data[fieldName];
+ if (!many2oneId) { return; }
var many2oneRecord = self.localData[many2oneId];
records.push(many2oneRecord);
+ ids.push(many2oneRecord.res_id);
model = many2oneRecord.model;
- return many2oneRecord.res_id;
});
return this._rpc({
model: model,
@@ -1600,6 +1657,39 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * Fetch the `name_get` for a reference field.
+ *
+ * @private
+ * @param {Object} record
+ * @param {string} fieldName
+ * @returns {Deferred}
+ */
+ _fetchReference: function (record, fieldName) {
+ var self = this;
+ var def;
+ var value = record._changes && record._changes[fieldName] || record.data[fieldName];
+ var model = value && value.split(',')[0];
+ var resID = value && parseInt(value.split(',')[1]);
+ if (model && model !== 'False' && resID) {
+ def = self._rpc({
+ model: model,
+ method: 'name_get',
+ args: [resID],
+ context: record.getContext({fieldName: fieldName}),
+ }).then(function (result) {
+ return self._makeDataPoint({
+ data: {
+ id: result[0][0],
+ display_name: result[0][1],
+ },
+ modelName: model,
+ parentID: record.id,
+ });
+ });
+ }
+ return $.when(def);
+ },
+ /**
* Fetch the extra data (`name_get`) for the reference fields of the record
* model.
*
@@ -1614,28 +1704,12 @@ var BasicModel = AbstractModel.extend({
_.each(fieldNames, function (fieldName) {
var field = record.fields[fieldName];
if (field.type === 'reference') {
- var value = record.data[fieldName];
- if (value) {
- var model = value.split(',')[0];
- var resID = parseInt(value.split(',')[1]);
- var def = self._rpc({
- model: model,
- method: 'name_get',
- args: [resID],
- context: record.getContext({fieldName: fieldName}),
- }).then(function (result) {
- var referenceDp = self._makeDataPoint({
- data: {
- id: result[0][0],
- display_name: result[0][1],
- },
- modelName: model,
- parentID: record.id,
- });
- record.data[fieldName] = referenceDp.id;
- });
- defs.push(def);
- }
+ var def = self._fetchReference(record, fieldName).then(function (dataPoint) {
+ if (dataPoint) {
+ record.data[fieldName] = dataPoint.id;
+ }
+ });
+ defs.push(def);
}
});
return $.when.apply($, defs);
@@ -1906,6 +1980,25 @@ var BasicModel = AbstractModel.extend({
});
},
/**
+ * Fetches the `name_get` associated to the reference widget if the field is
+ * a `char` (which is a supported case).
+ *
+ * @private
+ * @param {Object} record - an element from the localData
+ * @param {Object} fieldName - the name of the field
+ * @returns {Deferred}
+ */
+ _fetchSpecialReference: function (record, fieldName) {
+ var def;
+ var field = record.fields[fieldName];
+ if (field.type === 'char') {
+ // if the widget reference is set on a char field, the name_get
+ // needs to be fetched a posteriori
+ def = this._fetchReference(record, fieldName);
+ }
+ return $.when(def);
+ },
+ /**
* Fetches all the m2o records associated to the given fieldName. If the
* given fieldName is not a m2o field, nothing is done. The difference with
* _fetchSpecialMany2ones is that the field given by options.fold_field is
@@ -2712,6 +2805,7 @@ var BasicModel = AbstractModel.extend({
_cache: type === 'list' ? {} : undefined,
_changes: null,
_domains: {},
+ _rawChanges: {},
aggregateValues: params.aggregateValues || {},
context: params.context || {},
count: params.count || res_ids.length,
@@ -2826,14 +2920,25 @@ var BasicModel = AbstractModel.extend({
var field = params.fields[name];
data[name] = null;
record._changes = record._changes || {};
+ var dp;
if (field.type === 'many2one' && result[name]) {
- var rec = self._makeDataPoint({
+ dp = self._makeDataPoint({
context: record.context,
data: {id: result[name]},
modelName: field.relation,
parentID: record.id,
});
- record._changes[name] = rec.id;
+ record._changes[name] = dp.id;
+ } else if (field.type === 'reference' && result[name]) {
+ var ref = result[name].split(',');
+ dp = self._makeDataPoint({
+ context: record.context,
+ data: {id: parseInt(ref[1])},
+ modelName: ref[0],
+ parentID: record.id,
+ });
+ defs.push(self._fetchNameGet(dp));
+ record._changes[name] = dp.id;
} else if (field.type === 'one2many' || field.type === 'many2many') {
var fieldInfo = record.fieldsInfo[record.viewType][name];
var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
@@ -2859,6 +2964,7 @@ var BasicModel = AbstractModel.extend({
x2manyList._changes = [];
var many2ones = {};
var r;
+ result[name] = result[name] || []; // handle false value
var isCommandList = result[name].length && _.isArray(result[name][0]);
if (!isCommandList) {
result[name] = [[6, false, result[name]]];
@@ -2885,7 +2991,7 @@ var BasicModel = AbstractModel.extend({
r._changes = _.defaults(value[2], r.data);
for (var name in r._changes) {
- if (r._changes[name] === null) {
+ if (!r._changes[name]) {
continue;
}
var isFieldInView = name in r.fields;
@@ -3318,6 +3424,7 @@ var BasicModel = AbstractModel.extend({
list.data = data;
list.res_ids = ids;
}
+ self._updateParentResIDs(list);
return list;
});
},
@@ -3364,6 +3471,26 @@ var BasicModel = AbstractModel.extend({
}
},
/**
+ * Updates the res_ids of the parent of a given element of type list.
+ *
+ * After some operations (e.g. loading more records, folding/unfolding a
+ * group), the res_ids list of an element may be updated. When this happens,
+ * the res_ids of its ancestors need to be updated as well. This is the
+ * purpose of this function.
+ *
+ * @param {Object} element
+ */
+ _updateParentResIDs: function (element) {
+ var self = this;
+ if (element.parentID) {
+ var parent = this.localData[element.parentID];
+ parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) {
+ return self.localData[dataPointID].res_ids;
+ }));
+ this._updateParentResIDs(parent);
+ }
+ },
+ /**
* Helper method. Recursively traverses the data, starting from the element
* record (or list), then following all relations. This is useful when one
* want to determine a property for the current record.
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index 736416dea27..bdc250011f0 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -63,6 +63,7 @@ var BasicView = AbstractView.extend({
*/
_loadData: function () {
if (this.recordID) {
+ var self = this;
var record = this.model.get(this.recordID);
var viewType = this.viewType;
var viewFields = Object.keys(record.fieldsInfo[viewType]);
@@ -107,15 +108,27 @@ var BasicView = AbstractView.extend({
}
}
});
- if (fieldNames.length && !this.model.isNew(record.id)) {
- return this.model.reload(this.recordID, {
- fieldNames: fieldNames,
- keepChanges: true,
- viewType: viewType,
+
+ var def;
+ if (fieldNames.length) {
+ // Some fields in the new view weren't in the previous one, so
+ // we might have stored changes for them (e.g. coming from
+ // onchange RPCs), that we haven't been able to process earlier
+ // (because those fields were unknow at that time). So we ask
+ // the model to process them.
+ def = this.model.applyRawChanges(record.id, viewType).then(function () {
+ if (!self.model.isNew(record.id)) {
+ return self.model.reload(record.id, {
+ fieldNames: fieldNames,
+ keepChanges: true,
+ viewType: viewType,
+ });
+ }
});
- } else {
- return $.when(this.recordID);
}
+ return $.when(def).then(function () {
+ return record.id;
+ });
}
return this._super.apply(this, arguments);
},
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
index d2b0c02c524..c8b9fd574b2 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -38,39 +38,47 @@ return AbstractModel.extend({
var start = event.start.clone();
var end = event.end && event.end.clone();
- if (!end || end.diff(start) < 0) {
- end = start.clone().add(1, 'h');
+ // Detects allDay events (86400000 = 1 day in ms)
+ if (event.allDay || (end && end.diff(start) % 86400000 === 0)) {
+ event.allDay = true;
}
- if (event.allDay || end.diff(start) % 86400000 === 0) {
- event.allDay = true;
+ // Set end date if not existing
+ if (!end || end.diff(start) < 0) { // undefined or invalid end date
+ if (event.allDay) {
+ end = start.clone();
+ } else {
+ // in week mode or day mode, convert allday event to event
+ end = start.clone().add(2, 'h');
+ }
+ } else if (event.allDay) {
+ // For an "allDay", FullCalendar gives the end day as the
+ // next day at midnight (instead of 23h59).
+ end.add(-1, 'days');
+ }
- if (this.scale === 'month') {
+ // 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 (event.r_start) {
start.hours(event.r_start.hours())
.minutes(event.r_start.minutes())
.seconds(event.r_start.seconds())
.utc();
- end.add(-1, 'days')
- .hours(event.r_end.hours())
+ end.hours(event.r_end.hours())
.minutes(event.r_end.minutes())
.seconds(event.r_end.seconds())
.utc();
} else {
- start.utc();
- end.utc();
+ // default hours in the user's timezone
+ start.hours(7).add(-this.getSession().getTZOffset(start), 'minutes');
+ end.hours(19).add(-this.getSession().getTZOffset(end), 'minutes');
}
- } else if (this.mapping.all_day) {
- start.startOf('day');
- end.startOf('day').add(-1, 'days');
- } else {
- // default hours in the user's timezone
- start.hours(7).add(-this.getSession().tzOffset, 'minutes');
- end.hours(19).add(-this.getSession().tzOffset, 'minutes');
}
} else {
- start.add(-this.getSession().tzOffset, 'minutes');
- end.add(-this.getSession().tzOffset, 'minutes');
+ start.add(-this.getSession().getTZOffset(start), 'minutes');
+ end.add(-this.getSession().getTZOffset(end), 'minutes');
}
if (this.mapping.all_day) {
@@ -365,6 +373,7 @@ return AbstractModel.extend({
monthNamesShort: moment.monthsShort(),
dayNames: moment.weekdays(),
dayNamesShort: moment.weekdaysShort(),
+ firstDay: moment().startOf('week').isoWeekday(),
};
},
_getRangeDomain: function () {
@@ -599,8 +608,8 @@ return AbstractModel.extend({
date_stop = date_start.clone().add(date_delay,'hours');
}
- date_start.add(this.getSession().tzOffset, 'minutes');
- date_stop.add(this.getSession().tzOffset, 'minutes');
+ date_start.add(this.getSession().getTZOffset(date_start), 'minutes');
+ date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes');
if (this.mapping.all_day && evt[this.mapping.all_day]) {
date_stop.add(1, 'days');
@@ -622,6 +631,10 @@ return AbstractModel.extend({
// r.start = date_start.format('YYYY-MM-DD');
// r.end = date_stop.format('YYYY-MM-DD');
} else if (this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') {
+ // In month, FullCalendar gives the end day as the
+ // next day at midnight (instead of 23h59).
+ date_stop.add(1, 'days');
+
// allow to resize in month mode
r.reset_allday = r.allDay;
r.allDay = true;
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
index 5fb79cfa3a0..422258b4035 100644
--- a/addons/web/static/src/js/views/calendar/calendar_renderer.js
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -331,12 +331,15 @@ return AbstractRenderer.extend({
}
element.find('.fc-content .fc-time').text(display_hour);
},
+ // Dirty hack to ensure a correct first render
+ eventAfterAllRender: function (view) {
+ window.dispatchEvent(new Event('resize'));
+ },
height: 'parent',
unselectAuto: false,
});
this.$calendar.fullCalendar(fc_options);
- window.dispatchEvent(new Event('resize'));
},
/**
* Initialize the mini calendar in the sidebar
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index 459f8768c04..3fa3b76d601 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -107,7 +107,10 @@ var KanbanController = BasicController.extend({
* @returns {Deferred}
*/
_resequenceRecords: function (column_id, ids) {
- return this.model.resequence(this.modelName, ids, column_id);
+ var self = this;
+ return this.model.resequence(this.modelName, ids, column_id).then(function () {
+ self._updateEnv();
+ });
},
/**
* In grouped mode, set 'Create' button as btn-default if there is no column
@@ -181,6 +184,7 @@ var KanbanController = BasicController.extend({
.then(function (db_id) {
var data = self.model.get(db_id);
self.renderer.updateColumn(db_id, data);
+ self._updateEnv();
});
}
},
@@ -218,6 +222,7 @@ var KanbanController = BasicController.extend({
this.model.loadMore(column.db_id).then(function (db_id) {
var data = self.model.get(db_id);
self.renderer.updateColumn(db_id, data);
+ self._updateEnv();
});
},
/**
@@ -328,9 +333,12 @@ var KanbanController = BasicController.extend({
* @param {OdooEvent} event
*/
_onResequenceColumn: function (event) {
+ var self = this;
var state = this.model.get(this.handle, {raw: true});
var model = state.fields[state.groupedBy[0]].relation;
- this.model.resequence(model, event.data.ids, this.handle);
+ this.model.resequence(model, event.data.ids, this.handle).then(function () {
+ self._updateEnv();
+ });
},
/**
* @param {OdooEvent} event
@@ -341,6 +349,7 @@ var KanbanController = BasicController.extend({
this.model.toggleGroup(column.db_id).then(function (db_id) {
var data = self.model.get(db_id);
self.renderer.updateColumn(db_id, data);
+ self._updateEnv();
});
},
/**
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index 71402fc92ec..e21a1376e51 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -35,12 +35,8 @@ var KanbanModel = BasicModel.extend({
group.count++;
// update the res_ids and count of the parent
- var self = this;
- var parent = this.localData[group.parentID];
- parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) {
- return self.localData[dataPointID].res_ids;
- }));
- parent.count++;
+ this.localData[group.parentID].count++;
+ this._updateParentResIDs(group);
return this._fetchRecord(new_record).then(function (result) {
return result.id;
@@ -151,6 +147,7 @@ var KanbanModel = BasicModel.extend({
return this.notifyChanges(recordID, changes).then(function () {
return self.save(recordID);
}).then(function () {
+ var resID = self.localData[recordID].res_id;
// Remove record from its current group
var old_group;
for (var i = 0; i < parent.data.length; i++) {
@@ -159,11 +156,14 @@ var KanbanModel = BasicModel.extend({
if (index >= 0) {
old_group.data.splice(index, 1);
old_group.count--;
+ old_group.res_ids = _.without(old_group.res_ids, resID);
+ self._updateParentResIDs(old_group);
break;
}
}
// Add record to its new group
new_group.data.push(recordID);
+ new_group.res_ids.push(resID);
new_group.count++;
return [old_group.id, new_group.id];
});
@@ -201,6 +201,16 @@ var KanbanModel = BasicModel.extend({
data.data = _.sortBy(data.data, function (d) {
return _.indexOf(resIDs, self.localData[d].res_id);
});
+ data.res_ids = [];
+ _.each(data.data, function (d) {
+ var dataPoint = self.localData[d];
+ if (dataPoint.type === 'record') {
+ data.res_ids.push(dataPoint.res_id);
+ } else {
+ data.res_ids = data.res_ids.concat(dataPoint.res_ids);
+ }
+ });
+ self._updateParentResIDs(data);
return parentID;
});
},
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 3ef48792f06..3327b1bb3e5 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -388,15 +388,15 @@ var ListRenderer = BasicRenderer.extend({
var $th = $('<th>')
.addClass('o_group_name')
.text(name + ' (' + group.count + ')');
- if (group.count > 0) {
- var $arrow = $('<span>')
+ var $arrow = $('<span>')
.css('padding-left', (groupLevel * 20) + 'px')
.css('padding-right', '5px')
- .addClass('fa')
- .toggleClass('fa-caret-right', !group.isOpen)
- .toggleClass('fa-caret-down', group.isOpen);
- $th.prepend($arrow);
+ .addClass('fa');
+ if (group.count > 0) {
+ $arrow.toggleClass('fa-caret-right', !group.isOpen)
+ .toggleClass('fa-caret-down', group.isOpen);
}
+ $th.prepend($arrow);
if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) {
var $pager = this._renderGroupPager(group);
var $lastCell = $cells[$cells.length-1];
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index 97b17f00413..99ffd1e1aa3 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -350,6 +350,7 @@ var SelectCreateDialog = ViewDialog.extend({
groupBy: searchResult.groupBy,
modelName: self.dataset.model,
hasSelectors: !self.options.disable_multiple_selection,
+ readonly: true,
}, self.options.list_view_options));
listView.setController(SelectCreateListController);
return listView.getController(self);
@@ -408,6 +409,7 @@ var SelectCreateDialog = ViewDialog.extend({
},
})).open();
dialog.on('closed', this, this.close);
+ return dialog;
},
});
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index fde9001f860..4b9f6a29a47 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -179,7 +179,7 @@ var DataExport = Dialog.extend({
var self = this;
this.exports.create({
name: value,
- resource: this.dataset.model,
+ resource: this.record.model,
export_fields: _.map(fields, function (field) {
return [0, 0, {name: field}];
}),
diff --git a/addons/web/static/src/js/widgets/debug_manager.js b/addons/web/static/src/js/widgets/debug_manager.js
index baa147d9a1f..b2c175fc028 100644
--- a/addons/web/static/src/js/widgets/debug_manager.js
+++ b/addons/web/static/src/js/widgets/debug_manager.js
@@ -348,7 +348,117 @@ DebugManager.include({
});
},
set_defaults: function() {
- this._active_view.controller.open_defaults_dialog();
+ var self = this;
+
+ var display = function (fieldInfo, value) {
+ var displayed = value;
+ if (value && fieldInfo.type === 'many2one') {
+ displayed = value.data.display_name;
+ value = value.data.id;
+ } else if (value && fieldInfo.type === 'selection') {
+ displayed = _.find(fieldInfo.selection, function (option) {
+ return option[0] === value;
+ })[1];
+ }
+ return [value, displayed];
+ };
+
+ var renderer = this._active_view.controller.renderer;
+ var fields = self._active_view.fields_view.fields;
+ var fieldsInfo = self._active_view.fields_view.fieldsInfo.form;
+ var fieldNamesInView = renderer.state.getFieldNames();
+ var fieldsValues = renderer.state.data;
+ var modifierDatas = {};
+ _.each(fieldNamesInView, function (fieldName) {
+ modifierDatas[fieldName] = _.find(renderer.allModifiersData, function (modifierdata) {
+ return modifierdata.node.attrs.name === fieldName;
+ });
+ });
+ this.fields = _.chain(fieldNamesInView)
+ .map(function (fieldName) {
+ var modifierData = modifierDatas[fieldName];
+ var invisibleOrReadOnly;
+ if (modifierData) {
+ var evaluatedModifiers = modifierData.evaluatedModifiers[renderer.state.id];
+ invisibleOrReadOnly = evaluatedModifiers.invisible || evaluatedModifiers.readonly;
+ }
+ var fieldInfo = fields[fieldName];
+ var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]);
+ var value = valueDisplayed[0];
+ var displayed = valueDisplayed[1];
+ // ignore fields which are empty, invisible, readonly, o2m
+ // or m2m
+ if (!value || invisibleOrReadOnly || fieldInfo.type === 'one2many' ||
+ fieldInfo.type === 'many2many' || fieldInfo.type === 'binary' ||
+ fieldsInfo[fieldName].options.isPassword) {
+ return false;
+ }
+ return {
+ name: fieldName,
+ string: fieldInfo.string,
+ value: value,
+ displayed: displayed,
+ };
+ })
+ .compact()
+ .sortBy(function (field) { return field.string; })
+ .value();
+
+ var conditions = _.chain(fieldNamesInView)
+ .filter(function (fieldName) {
+ var fieldInfo = fields[fieldName];
+ return fieldInfo.change_default;
+ })
+ .map(function (fieldName) {
+ var fieldInfo = fields[fieldName];
+ var valueDisplayed = display(fieldInfo, fieldsValues[fieldName]);
+ var value = valueDisplayed[0];
+ var displayed = valueDisplayed[1];
+ return {
+ name: fieldName,
+ string: fieldInfo.string,
+ value: value,
+ displayed: displayed,
+ };
+ })
+ .value();
+ var d = new Dialog(this, {
+ title: _t("Set Default"),
+ buttons: [
+ {text: _t("Close"), close: true},
+ {text: _t("Save default"), click: function () {
+ var $defaults = d.$el.find('#formview_default_fields');
+ var fieldToSet = $defaults.val();
+ if (!fieldToSet) {
+ $defaults.parent().addClass('o_form_invalid');
+ return;
+ }
+ var allUsers = d.$el.find('#formview_default_all').is(':checked');
+ var condition = d.$el.find('#formview_default_conditions').val();
+ var value = _.find(self.fields, function (field) {
+ return field.name === fieldToSet;
+ }).value;
+ self._rpc({
+ model: 'ir.values',
+ method: 'set_default',
+ args: [
+ self._active_view.fields_view.model,
+ fieldToSet,
+ value,
+ allUsers,
+ true,
+ condition || false,
+ ],
+ }).done(function () { d.close(); });
+ }}
+ ]
+ });
+ d.args = {
+ fields: this.fields,
+ conditions: conditions,
+ };
+ d.template = 'FormView.set_default';
+ d.open();
},
fvg: function() {
var self = this;
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index f0a08b5dc62..b986c08ff4d 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -448,7 +448,7 @@
</label>
</td>
<td class="oe_form_required">
- <select id="formview_default_fields">
+ <select id="formview_default_fields" class="o_input">
<option value=""/>
<option t-foreach="args.fields" t-as="field"
t-att-value="field.name">
@@ -465,7 +465,7 @@
</label>
</td>
<td>
- <select id="formview_default_conditions">
+ <select id="formview_default_conditions" class="o_input">
<option value=""/>
<option t-foreach="args.conditions" t-as="cond"
t-att-value="cond.name + '=' + cond.value">
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index 5206393960f..8def5642a8b 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -48,7 +48,7 @@
<span class="o_kanban_title">Add new Column</span>
</div>
<div class="o_kanban_quick_create">
- <input type="text" placeholder="Add"/>
+ <input type="text" class="o_input" placeholder="Add"/>
<button class="btn btn-sm btn-primary o_kanban_add">Add</button>
<button class="btn btn-sm btn-default o_kanban_cancel">Discard</button>
</div>
@@ -57,7 +57,7 @@
<t t-name="KanbanView.QuickCreate">
<div class="o_kanban_quick_create">
- <input type="text" placeholder="Add"/>
+ <input type="text" class="o_input" placeholder="Add"/>
<button class="btn btn-sm btn-primary o_kanban_add">Add</button>
<button class="btn btn-sm btn-primary o_kanban_edit">Edit</button>
<button class="btn btn-sm btn-default o_kanban_cancel">Discard</button>
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index c2ab8121c16..b533f5056d2 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -736,6 +736,31 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('char field with size attribute', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.foo.size = 5; // max length
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<group><field name="foo"/></group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('input.o_field_widget').attr('maxlength'), '5',
+ "maxlength attribute should have been set correctly on the input");
+
+ form.destroy();
+ });
+
QUnit.test('char field in editable list view', function (assert) {
assert.expect(6);
@@ -1072,8 +1097,8 @@ QUnit.module('basic_fields', {
res_id: 1,
});
- assert.ok(form.$('div.o_field_text').length, "should have a text area");
- assert.strictEqual(form.$('div.o_field_text').text(), 'yop', 'should be "yop" in readonly');
+ assert.ok(form.$('.o_field_text').length, "should have a text area");
+ assert.strictEqual(form.$('.o_field_text').text(), 'yop', 'should be "yop" in readonly');
form.$buttons.find('.o_form_button_edit').click();
@@ -1089,7 +1114,7 @@ QUnit.module('basic_fields', {
form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('div.o_field_text').text(), 'hello world',
+ assert.strictEqual(form.$('.o_field_text').text(), 'hello world',
'should be "hello world" after save');
form.destroy();
});
@@ -1109,7 +1134,7 @@ QUnit.module('basic_fields', {
res_id: 1,
});
- var $field = form.$('div.o_field_text');
+ var $field = form.$('.o_field_text');
assert.strictEqual($field.outerHeight(), $field[0].scrollHeight,
"text field should not have a scroll bar");
@@ -1211,6 +1236,31 @@ QUnit.module('basic_fields', {
list.destroy();
});
+ // Firefox-specific
+ // Copying from <div style="white-space:pre-wrap"> does not keep line breaks
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1390115
+ QUnit.test('copying text fields in RO mode should preserve line breaks', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="txt"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // Copying from a div tag with white-space:pre-wrap doesn't work in Firefox
+ assert.strictEqual(form.$('[name="txt"]').prop("tagName").toLowerCase(), 'span',
+ "the field contents should be surrounded by a span tag");
+ });
+
QUnit.module('FieldBinary');
QUnit.test('binary fields are correctly rendered', function (assert) {
@@ -1563,7 +1613,7 @@ QUnit.module('basic_fields', {
form.destroy();
});
- QUnit.test('date field in form view', function (assert) {
+ QUnit.test('date field in form view (with positive time zone offset)', function (assert) {
assert.expect(8);
var form = createView({
@@ -1582,7 +1632,9 @@ QUnit.module('basic_fields', {
date_format: '%m/%d/%Y',
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120; // Should be ignored by date fields
+ },
},
});
@@ -1614,6 +1666,36 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('date field in form view (with negative time zone offset)', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners"><field name="date"/></form>',
+ res_id: 1,
+ translateParameters: { // Avoid issues due to localization formats
+ date_format: '%m/%d/%Y',
+ },
+ session: {
+ getTZOffset: function () {
+ return -120; // Should be ignored by date fields
+ },
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_date').text(), '02/03/2017',
+ 'the date should be correctly displayed in readonly');
+
+ // switch to edit mode
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('.o_datepicker_input').val(), '02/03/2017',
+ 'the date should be correct in edit mode');
+
+ form.destroy();
+ });
+
QUnit.test('date field in editable list view', function (assert) {
assert.expect(8);
@@ -1628,7 +1710,9 @@ QUnit.module('basic_fields', {
date_format: '%m/%d/%Y',
},
session: {
- tzOffset: 0
+ getTZOffset: function () {
+ return 0;
+ },
},
});
@@ -1717,7 +1801,9 @@ QUnit.module('basic_fields', {
time_format: '%H:%M:%S',
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -1774,7 +1860,9 @@ QUnit.module('basic_fields', {
time_format: '%H:%M:%S',
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -1844,7 +1932,9 @@ QUnit.module('basic_fields', {
time_format: '%H:%M:%S',
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -1863,6 +1953,93 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('datetime field with date/datetime widget (with day change)', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].datetime = "2017-02-08 02:00:00"; // UTC
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="datetime"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="datetime" widget="date"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ translateParameters: { // Avoid issues due to localization formats
+ date_format: '%m/%d/%Y',
+ time_format: '%H:%M:%S',
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ var expectedDateString = "02/07/2017 22:00:00"; // local time zone
+ assert.strictEqual(form.$('.o_field_widget[name=p] .o_data_cell').text(), expectedDateString,
+ 'the datetime (datetime widget) should be correctly displayed in tree view');
+
+ // switch to form view
+ form.$('.o_field_widget[name=p] .o_data_row').click();
+ assert.strictEqual($('.modal .o_field_date[name=datetime]').text(), '02/07/2017',
+ 'the datetime (date widget) should be correctly displayed in form view');
+
+ form.destroy();
+ });
+
+ QUnit.test('datetime field with date/datetime widget (without day change)', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].p = [2];
+ this.data.partner.records[1].datetime = "2017-02-08 10:00:00"; // without timezone
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="datetime"/>' +
+ '</tree>' +
+ '<form>' +
+ '<field name="datetime" widget="date"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ translateParameters: { // Avoid issues due to localization formats
+ date_format: '%m/%d/%Y',
+ time_format: '%H:%M:%S',
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ var expectedDateString = "02/08/2017 06:00:00"; // with timezone
+ assert.strictEqual(form.$('.o_field_widget[name=p] .o_data_cell').text(), expectedDateString,
+ 'the datetime (datetime widget) should be correctly displayed in tree view');
+
+ // switch to form view
+ form.$('.o_field_widget[name=p] .o_data_row').click();
+ assert.strictEqual($('.modal .o_field_date[name=datetime]').text(), '02/08/2017',
+ 'the datetime (date widget) should be correctly displayed in form view');
+
+ form.destroy();
+ });
QUnit.module('FieldMonetary');
diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js
index a49197f6e65..a0284798553 100644
--- a/addons/web/static/tests/fields/field_utils_tests.js
+++ b/addons/web/static/tests/fields/field_utils_tests.js
@@ -4,6 +4,7 @@ odoo.define('web.field_utils_tests', function (require) {
var core = require('web.core');
var session = require('web.session');
var fieldUtils = require('web.field_utils');
+var testUtils = require('web.test_utils');
QUnit.module('fields', {}, function () {
@@ -59,10 +60,35 @@ QUnit.test("format_datetime", function (assert) {
var date_string = "2009-05-04 12:34:23";
var date = fieldUtils.parse.datetime(date_string, {}, {timezone: false});
- var str = fieldUtils.format.datetime(date, {timezone: false});
+ var str = fieldUtils.format.datetime(date, {}, {timezone: false});
assert.strictEqual(str, moment(date).format("MM/DD/YYYY HH:mm:ss"));
});
+QUnit.test("format_datetime (with different timezone offset)", function (assert) {
+ assert.expect(2);
+
+ // mock the date format to avoid issues due to localisation
+ var dateFormat = core._t.database.parameters.date_format;
+ core._t.database.parameters.date_format = '%m/%d/%Y';
+ session.getTZOffset = function (date) {
+ // simulate daylight saving time
+ var startDate = new Date(2017, 2, 26);
+ var endDate = new Date(2017, 9, 29);
+ if (startDate < date && date < endDate) {
+ return 120; // UTC+2
+ } else {
+ return 60; // UTC+1
+ }
+ };
+
+ var str = fieldUtils.format.datetime(moment.utc('2017-01-01T10:00:00Z'));
+ assert.strictEqual(str, '01/01/2017 11:00:00');
+ str = fieldUtils.format.datetime(moment.utc('2017-06-01T10:00:00Z'));
+ assert.strictEqual(str, '06/01/2017 12:00:00');
+
+ core._t.database.parameters.date_format = dateFormat;
+});
+
QUnit.test("format_many2one", function (assert) {
assert.expect(2);
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 29f8666455a..89f4601efc4 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -958,6 +958,45 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('list in form: create with one2many with many2one (version 2)', function (assert) {
+ // This test simulates the exact same scenario as the previous one,
+ // except that the value for the many2one is explicitely set to false,
+ // which is stupid, but this happens, so we have to handle it
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="trululu"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]});
+ } else if (args.method === 'name_get') {
+ // This should not be called at all and thus is not accounted for
+ // in the assert.expect. If this is called, you broke this test.
+ assert.notOk(_.str.startsWith(args.args[0][0], 'virtual_'),
+ "should not call name_get for the m2o inside o2m which has no value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual($('td.o_data_cell:first').text(), 'new record',
+ "should have created the new record in the o2m with the correct name");
+
+ form.destroy();
+ });
+
QUnit.test('list in form: default_get with x2many create', function (assert) {
assert.expect(3);
@@ -1250,6 +1289,38 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('slow create on a many2one', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:
+ '<form>' +
+ '<sheet>' +
+ '<field name="product_id" options="{\'quick_create\': False}"/>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ 'product,false,form':
+ '<form>' +
+ '<field name="name"/>' +
+ '</form>',
+ },
+ });
+
+ form.$('.o_field_many2one input').focus();
+ form.$('.o_field_many2one input').val('new partner').trigger('keyup').trigger('focusout');
+
+ $('.modal .modal-footer .btn-primary').first().click();
+
+ assert.strictEqual($('.modal:visible:last .o_form_view').length, 1,
+ 'a new modal should be opened and contain a form view');
+
+ form.destroy();
+ });
+
QUnit.test('no_create option on a many2one', function (assert) {
assert.expect(1);
@@ -1732,7 +1803,9 @@ QUnit.module('relational_fields', {
'</form>',
res_id: 1,
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
assert.strictEqual(form.$('td:eq(0)').text(), "01/25/2017",
@@ -5227,6 +5300,59 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many: onchange that returns unknow field in list, but not in form', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.onchanges = {
+ name: function () {},
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="name"/>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="timmy" widget="many2many_tags"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'onchange') {
+ return $.when({
+ value: {
+ p: [[5], [0, 0, {display_name: 'new', timmy: [[5], [4, 12]]}]],
+ },
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "the one2many should contain one row");
+ assert.strictEqual(form.$('.o_field_widget[name="timmy"]').length, 0,
+ "timmy should not be displayed in the list view");
+
+ form.$('.o_data_row td:first').click(); // open the record
+
+ assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"]').length, 1,
+ "timmy should be displayed in the form view");
+ assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .badge').length, 1,
+ "m2mtags should contain one tag");
+ assert.strictEqual($('.modal .o_field_many2manytags[name="timmy"] .o_badge_text').text(),
+ 'gold', "tag name should have been correctly loaded");
+
+ form.destroy();
+ });
+
+
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
@@ -7214,7 +7340,7 @@ QUnit.module('relational_fields', {
"should do a do_action with correct parameters");
});
- assert.strictEqual(form.$('a.o_form_uri:contains(first record)').length, 1,
+ assert.strictEqual(form.$('a.o_form_uri:contains(xphone)').length, 1,
"should contain a link");
form.$('a.o_form_uri').click(); // click on the link in readonly mode (should trigger do_action)
@@ -7226,7 +7352,7 @@ QUnit.module('relational_fields', {
"should contain one many2one");
assert.strictEqual(form.$('.o_field_widget select').val(), "product",
"widget should contain one select with the model");
- assert.strictEqual(form.$('.o_field_widget input').val(), "first record",
+ assert.strictEqual(form.$('.o_field_widget input').val(), "xphone",
"widget should contain one input with the record");
var options = _.map(form.$('.o_field_widget select > option'), function (el) {
@@ -7251,12 +7377,114 @@ QUnit.module('relational_fields', {
$dropdown.find('li:first()').click();
form.$buttons.find('.o_form_button_save').click();
- assert.strictEqual(form.$('a.o_form_uri').length, 1,
+ assert.strictEqual(form.$('a.o_form_uri:contains(gold)').length, 1,
"should contain a link with the new value");
form.destroy();
});
+ QUnit.test('default_get and onchange with a reference field', function (assert) {
+ assert.expect(8);
+
+ this.data.partner.fields.reference.default = 'product,37';
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ if (obj.int_field !== 0) {
+ obj.reference = 'partner_type,' + obj.int_field;
+ }
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="int_field"/>' +
+ '<field name="reference"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ assert.step(args.model);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.verifySteps(['product'], "the first name_get should have been done");
+ assert.strictEqual(form.$('.o_field_widget[name="reference"] select').val(), "product",
+ "reference field model should be correctly set");
+ assert.strictEqual(form.$('.o_field_widget[name="reference"] input').val(), "xphone",
+ "reference field value should be correctly set");
+
+ // trigger onchange
+ form.$('.o_field_widget[name=int_field]').val(12).trigger('input');
+
+ assert.verifySteps(['product', 'partner_type'], "the second name_get should have been done");
+ assert.strictEqual(form.$('.o_field_widget[name="reference"] select').val(), "partner_type",
+ "reference field model should be correctly set");
+ assert.strictEqual(form.$('.o_field_widget[name="reference"] input').val(), "gold",
+ "reference field value should be correctly set");
+ form.destroy();
+ });
+
+ QUnit.test('widget reference on char field, reset by onchange', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.records[0].foo = 'product,37';
+ this.data.partner.onchanges = {
+ int_field: function (obj) {
+ obj.foo = 'product,' + obj.int_field;
+ },
+ };
+
+ var nbNameGet = 0;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group>' +
+ '<field name="int_field"/>' +
+ '<field name="foo" widget="reference" readonly="1"/>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.model === 'product' && args.method === 'name_get') {
+ nbNameGet++;
+ }
+ return this._super(route, args);
+ },
+ });
+
+ assert.strictEqual(nbNameGet, 1,
+ "the first name_get should have been done");
+ assert.strictEqual(form.$('a[name="foo"]').text(), "xphone",
+ "foo field should be correctly set");
+
+ // trigger onchange
+ form.$('.o_field_widget[name=int_field]').val(41).trigger('input');
+
+ assert.strictEqual(nbNameGet, 2,
+ "the second name_get should have been done");
+ assert.strictEqual(form.$('a[name="foo"]').text(), "xpad",
+ "foo field should have been updated");
+ form.destroy();
+ });
+
});
});
});
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
index fbfdb315fa4..343f04d9fad 100644
--- a/addons/web/static/tests/helpers/test_utils.js
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -257,8 +257,11 @@ function addMockEnvironment(widget, params) {
var initialDebounce = DebouncedField.prototype.DEBOUNCE;
DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
var initialSession, initialConfig, initialParameters;
+ initialSession = _.extend({}, session);
+ session.getTZOffset = function () {
+ return 0; // by default, but may be overriden in specific tests
+ };
if ('session' in params) {
- initialSession = _.extend({}, session);
_.extend(session, params.session);
}
if ('config' in params) {
@@ -283,8 +286,8 @@ function addMockEnvironment(widget, params) {
for (key in session) {
delete session[key];
}
- _.extend(session, initialSession);
}
+ _.extend(session, initialSession);
if ('config' in params) {
for (key in config) {
delete config[key];
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index 8c23fc4f97c..318b715a504 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -1323,6 +1323,64 @@ QUnit.module('Views', {
model.destroy();
});
+ QUnit.test('default_get: fetch many2one with default (empty & not) inside x2manys', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.fields.o2m = {
+ string: "O2M", type: 'one2many', relation: 'partner', default: [
+ [6, 0, []],
+ [0, 0, {category: false}],
+ [0, 0, {category: 12}],
+ ],
+ };
+ this.data.partner.fields.category.type = 'many2one';
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get' && args.model === 'partner_type') {
+ assert.deepEqual(args.args, [[12]], "should name_get on category 12");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var params = {
+ fieldNames: ['o2m'],
+ fields: this.data.partner.fields,
+ fieldsInfo: {
+ form: {
+ o2m: {
+ relatedFields: this.data.partner.fields,
+ fieldsInfo: {
+ list: {
+ category: {
+ relatedFields: { display_name: {} },
+ },
+ },
+ },
+ viewType: 'list',
+ },
+ },
+ },
+ modelName: 'partner',
+ type: 'record',
+ viewType: 'form',
+ };
+
+ model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.o2m.count, 2, "o2m field should contain 2 records");
+ assert.strictEqual(record.data.o2m.data[0].data.category, false,
+ "first category field should be empty");
+ assert.strictEqual(record.data.o2m.data[1].data.category.data.display_name, "gold",
+ "second category field should have been correctly fetched");
+ });
+
+ model.destroy();
+ });
+
QUnit.test('default_get: fetch x2manys inside x2manys', function (assert) {
assert.expect(3);
@@ -2007,5 +2065,25 @@ QUnit.module('Views', {
model.destroy();
});
+ QUnit.test('default_get with value false for a one2many', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.product_ids.default = false;
+ this.params.fieldNames = ['product_ids'];
+ this.params.res_id = undefined;
+ this.params.type = 'record';
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ model.load(this.params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.deepEqual(record.data.product_ids.data, [], "o2m default should be []");
+ });
+
+ model.destroy();
+ });
});});
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 6aa01daac5a..9f9be3a4042 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -440,7 +440,9 @@ QUnit.module('Views', {
initialDate: initialDate,
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
mockRPC: function (route, args) {
if (args.method === "create") {
@@ -507,7 +509,7 @@ QUnit.module('Views', {
});
QUnit.test('create event with timezone in week mode with formViewDialog', function (assert) {
- assert.expect(7);
+ assert.expect(8);
this.data.event.records = [];
this.data.event.onchanges = {
@@ -541,7 +543,9 @@ QUnit.module('Views', {
initialDate: initialDate,
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
mockRPC: function (route, args) {
if (args.method === "create") {
@@ -554,12 +558,8 @@ QUnit.module('Views', {
"should send the context to create events");
}
if (args.method === "write") {
- assert.deepEqual(args.args[1], {
- "allday": false,
- "start": "2016-12-12 06:00:00",
- "stop": "2016-12-12 08:00:00"
- },
- "should move the event");
+ assert.deepEqual(args.args[1], expectedEvent,
+ "should move the event");
}
return this._super(route, args);
},
@@ -631,17 +631,34 @@ QUnit.module('Views', {
left = pos.left + 5;
top = pos.top + 5;
+ // Mode this event to another day
+ var expectedEvent = {
+ "allday": false,
+ "start": "2016-12-12 06:00:00",
+ "stop": "2016-12-12 08:00:00"
+ };
testUtils.triggerPositionalMouseEvent(left, top, "mousedown");
left = calendar.$('.fc-day:eq(1)').offset().left + 5;
testUtils.triggerPositionalMouseEvent(left, top, "mousemove");
testUtils.triggerPositionalMouseEvent(left, top, "mouseup");
+ // Move to "All day"
+ expectedEvent = {
+ "allday": true,
+ "start": "2016-12-12 00:00:00",
+ "stop": "2016-12-12 00:00:00"
+ };
+ testUtils.triggerPositionalMouseEvent(left, top, "mousedown");
+ top = calendar.$('.fc-day:eq(1)').offset().top + 5;
+ testUtils.triggerPositionalMouseEvent(left, top, "mousemove");
+ testUtils.triggerPositionalMouseEvent(left, top, "mouseup");
+
calendar.destroy();
$view.remove();
});
- QUnit.test('create all day event', function (assert) {
- assert.expect(2);
+ QUnit.test('create all day event in week mode', function (assert) {
+ assert.expect(3);
this.data.event.records = [];
@@ -664,7 +681,9 @@ QUnit.module('Views', {
initialDate: initialDate,
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -690,6 +709,8 @@ QUnit.module('Views', {
assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent",
"should display the new event with time and title");
+ assert.strictEqual($newevent.parent().attr('colspan'), "2",
+ "should appear over two days.");
assert.deepEqual($newevent.data('fcSeg').event.record,
{
@@ -706,6 +727,141 @@ QUnit.module('Views', {
$view.remove();
});
+ QUnit.test('create all day event in week mode (no quickCreate)', function (assert) {
+ assert.expect(1);
+
+ this.data.event.records = [];
+
+ var calendar = createView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="week" '+
+ 'quick_add="False" '+
+ 'readonly_form_view_id="1">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ intercepts: {
+ do_action: function (event) {
+ assert.deepEqual(event.data.action.context, {
+ "default_name": null,
+ "default_start": "2016-12-14 05:00:00",
+ "default_stop": "2016-12-15 17:00:00",
+ },
+ "should send the correct data to create events");
+ },
+ },
+ });
+
+ var $view = $('#qunit-fixture').contents();
+ $view.prependTo('body'); // => select with click position
+
+
+ var pos = calendar.$('.fc-bg td:eq(4)').offset();
+ try {
+ testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ $view.remove();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ pos = calendar.$('.fc-bg td:eq(5)').offset();
+ testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove");
+ testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup");
+
+ calendar.destroy();
+ $view.remove();
+ });
+
+ QUnit.test('create event in month mode', function (assert) {
+ assert.expect(4);
+
+ this.data.event.records = [];
+
+ var calendar = createView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'event_open_popup="true" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month" '+
+ 'readonly_form_view_id="1">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return 120;
+ },
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "create") {
+ assert.deepEqual(args.args[0], {
+ "name": "new event",
+ "start": "2016-12-14 05:00:00",
+ "stop": "2016-12-15 17:00:00",
+ },
+ "should send the correct data to create events");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var $view = $('#qunit-fixture').contents();
+ $view.prependTo('body'); // => select with click position
+
+ var pos = calendar.$('.fc-bg td:eq(20)').offset();
+ try {
+ testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousedown");
+ } catch (e) {
+ calendar.destroy();
+ $view.remove();
+ throw new Error('The test fails to simulate a click in the screen. Your screen is probably too small or your dev tools is open.');
+ }
+ pos = calendar.$('.fc-bg td:eq(21)').offset();
+ testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mousemove");
+ testUtils.triggerPositionalMouseEvent(pos.left+15, pos.top+15, "mouseup");
+
+ $('.modal input:first').val('new event').trigger('input');
+ $('.modal button.btn:contains(Create)').trigger('click');
+ var $newevent = calendar.$('.fc-event:contains(new event)');
+
+ assert.strictEqual($newevent.text().replace(/[\s\n\r]+/g, ''), "newevent",
+ "should display the new event with time and title");
+ assert.strictEqual($newevent.parent().attr('colspan'), "2",
+ "should appear over two days.");
+
+ assert.deepEqual($newevent.data('fcSeg').event.record, {
+ display_name: "new event",
+ start: fieldUtils.parse.datetime("2016-12-14 05:00:00", this.data.event.fields.start, {isUTC: true}),
+ stop: fieldUtils.parse.datetime("2016-12-15 17:00:00", this.data.event.fields.stop, {isUTC: true}),
+ name: "new event",
+ id: 1
+ }, "the new record should have the utc datetime (quickCreate)");
+
+ calendar.destroy();
+ $view.remove();
+ });
+
QUnit.test('use mini calendar', function (assert) {
assert.expect(12);
@@ -728,7 +884,9 @@ QUnit.module('Views', {
initialDate: initialDate,
},
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -866,6 +1024,62 @@ QUnit.module('Views', {
assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body");
});
+ QUnit.test('create and edit event in month mode (all_day: false)', function (assert) {
+ assert.expect(2);
+
+ var calendar = createView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch:
+ '<calendar class="o_calendar_test" '+
+ 'string="Events" ' +
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month" '+
+ 'readonly_form_view_id="1">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ session: {
+ getTZOffset: function () {
+ return -240;
+ },
+ },
+ });
+
+ // create a new event and edit it
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(4) .fc-day:eq(2)');
+ testUtils.triggerMouseEvent($cell, "mousedown");
+ testUtils.triggerMouseEvent($cell, "mouseup");
+ $('.modal-body input:first').val('coucou').trigger('input');
+
+ testUtils.intercept(calendar, 'do_action', function (event) {
+ assert.deepEqual(event.data.action,
+ {
+ type: "ir.actions.act_window",
+ res_model: "event",
+ views: [[false, "form"]],
+ target: "current",
+ context: {
+ "default_name": "coucou",
+ "default_start": "2016-12-27 11:00:00", // 7:00 + 4h
+ "default_stop": "2016-12-27 23:00:00", // 19:00 + 4h
+ }
+ },
+ "should open the form view with the context default values");
+ });
+
+ $('.modal button.btn:contains(Edit)').trigger('click');
+
+ calendar.destroy();
+ assert.strictEqual($('#ui-datepicker-div:empty').length, 0, "should have a clean body");
+ });
+
+
QUnit.test('readonly date_start field', function (assert) {
assert.expect(4);
@@ -981,37 +1195,6 @@ QUnit.module('Views', {
calendar.destroy();
});
- QUnit.test('ensure calendar adapts to screen size', function (assert) {
- assert.expect(2);
-
- // We want to be able to restore changes on 'body'
- var bodyStyle = $('body').attr('style');
-
- // Force the view height to be smaller than the natural calendar height
- // (FullCalendar requires that the height be modified on body not $target)
- var height = 500;
- $('body').height(height);
-
- var calendar = createView({
- View: CalendarView,
- model: 'event',
- data: this.data,
- arch: '<calendar mode="week" date_start="start" />',
- });
-
- var $scroller = calendar.$('.fc-scroller')
- assert.strictEqual($scroller.height() < height, true,
- "the scroller should be smaller than the view height");
- assert.strictEqual($scroller.prop('scrollHeight') > height, true,
- "the scroller should have a scrollbar");
- calendar.destroy();
-
- $('body').removeAttr('style');
- if (bodyStyle) {
- $('body').attr('style', bodyStyle);
- }
- });
-
QUnit.test('ensure events are still shown if filters give an empty domain', function (assert) {
assert.expect(2);
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index b1ded45d9a2..c71ec8f2d93 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -2885,7 +2885,9 @@ QUnit.module('Views', {
'</form>',
res_id: 1,
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -3779,6 +3781,60 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('one2manys (list editable) inside one2manys are saved correctly', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="p"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ archs: {
+ "partner,false,form": '<form>' +
+ '<field name="p">' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>'
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0].p,
+ [[0, false, {
+ p: [[0, false, {display_name: "xtv"}]],
+ }]],
+ "create should be called with the correct arguments");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // add a o2m subrecord
+ form.$('.o_field_x2many_list_row_add a').click();
+ $('.modal-body .o_field_one2many .o_field_x2many_list_row_add a').click();
+ $('.modal-body input').val('xtv').trigger('input');
+ $('.modal-footer button:first').click(); // save & close
+ assert.strictEqual($('.modal').length, 0,
+ "dialog should be closed");
+
+ var row = form.$('.o_field_one2many .o_list_view .o_data_row');
+ assert.strictEqual(row.children()[0].textContent, '1 record',
+ "the cell should contains the number of record: 1");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
QUnit.test('*_view_ref in context are passed correctly', function (assert) {
var done = assert.async();
assert.expect(4);
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index be8a8a496af..84091f91a08 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -136,11 +136,12 @@ QUnit.module('Views', {
});
QUnit.test('basic grouped rendering with active field', function (assert) {
- assert.expect(4);
+ assert.expect(6);
// add active field on partner model and make all records active
this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
+ var envIDs = [1, 2, 3, 4]; // the ids that should be in the environment during this test
var kanban = createView({
View: KanbanView,
model: 'partner',
@@ -152,6 +153,12 @@ QUnit.module('Views', {
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
groupBy: ['bar'],
+ intercepts: {
+ env_updated: function (event) {
+ assert.deepEqual(event.data.ids, envIDs,
+ "should notify the environment with the correct ids");
+ },
+ },
});
// check available actions in kanban header's config dropdown
@@ -163,6 +170,7 @@ QUnit.module('Views', {
// archive the records of the first column
assert.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 3,
"last column should contain 3 records");
+ envIDs = [4];
kanban.$('.o_kanban_group:last .o_column_archive').click(); // click on 'Archive'
assert.strictEqual(kanban.$('.o_kanban_group:last .o_kanban_record').length, 0,
"last column should contain no record");
@@ -546,8 +554,9 @@ QUnit.module('Views', {
});
QUnit.test('can drag and drop a record from one column to the next', function (assert) {
- assert.expect(7);
+ assert.expect(9);
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
this.data.partner.fields.sequence = {type: 'number', string: "Sequence"};
var kanban = createView({
View: KanbanView,
@@ -569,6 +578,12 @@ QUnit.module('Views', {
}
return this._super(route, args);
},
+ intercepts: {
+ env_updated: function (event) {
+ assert.deepEqual(event.data.ids, envIDs,
+ "should notify the environment with the correct ids");
+ },
+ },
});
assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 2,
"column should contain 2 record(s)");
@@ -578,6 +593,7 @@ QUnit.module('Views', {
assert.strictEqual(kanban.$('.thisiseditable').length, 4, "all records should be editable");
var $record = kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record:first');
var $group = kanban.$('.o_kanban_group:nth-child(2)');
+ envIDs = [3, 2, 4, 1]; // first record of first column moved to the bottom of second column
testUtils.dragAndDrop($record, $group);
assert.strictEqual(kanban.$('.o_kanban_group:nth-child(1) .o_kanban_record').length, 1,
@@ -676,6 +692,42 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('environment is updated when (un)folding groups', function (assert) {
+ assert.expect(3);
+
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<field name="product_id"/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates>' +
+ '</kanban>',
+ groupBy: ['product_id'],
+ intercepts: {
+ env_updated: function (event) {
+ assert.deepEqual(envIDs, event.data.ids,
+ "should notify the environment with the correct ids");
+ },
+ },
+ });
+
+ // fold the second group and check that the res_ids it contains are no
+ // longer in the environment
+ envIDs = [1, 3];
+ kanban.$('.o_kanban_group:last .o_kanban_toggle_fold').click();
+
+ // re-open the second group and check that the res_ids it contains are
+ // back in the environment
+ envIDs = [1, 3, 2, 4];
+ kanban.$('.o_kanban_group:last .o_kanban_toggle_fold').click();
+
+ kanban.destroy();
+ });
+
QUnit.test('create a column in grouped on m2o', function (assert) {
assert.expect(13);
@@ -1282,8 +1334,9 @@ QUnit.module('Views', {
});
QUnit.test('resequence columns in grouped by m2o', function (assert) {
- assert.expect(4);
+ assert.expect(7);
+ var envIDs = [1, 3, 2, 4]; // the ids that should be in the environment during this test
var kanban = createView({
View: KanbanView,
model: 'partner',
@@ -1301,6 +1354,12 @@ QUnit.module('Views', {
}
return this._super.apply(this, arguments);
},
+ intercepts: {
+ env_updated: function (event) {
+ assert.deepEqual(event.data.ids, envIDs,
+ "should notify the environment with the correct ids");
+ },
+ },
});
assert.ok(kanban.$('.o_kanban_view').hasClass('ui-sortable'),
@@ -1313,6 +1372,7 @@ QUnit.module('Views', {
// there is a 100ms delay on the d&d feature (jquery sortable) for
// kanban columns, making it hard to test. So we rather bypass the d&d
// for this test, and directly call the event handler
+ envIDs = [2, 4, 1, 3]; // the columns will be inverted
kanban._onResequenceColumn({data: {ids: [5, 3]}});
kanban.update({}, {reload: false}); // re-render without reloading
@@ -1441,8 +1501,9 @@ QUnit.module('Views', {
});
QUnit.test('load more records in column', function (assert) {
- assert.expect(9);
+ assert.expect(12);
+ var envIDs = [1, 2, 4]; // the ids that should be in the environment during this test
var kanban = createView({
View: KanbanView,
model: 'partner',
@@ -1462,12 +1523,19 @@ QUnit.module('Views', {
}
return this._super.apply(this, arguments);
},
+ intercepts:{
+ env_updated: function (event) {
+ assert.deepEqual(event.data.ids, envIDs,
+ "should notify the environment with the correct ids");
+ },
+ },
});
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2,
"there should be 2 records in the column");
// load more
+ envIDs = [1, 2, 3, 4]; // id 3 will be loaded
kanban.$('.o_kanban_group:eq(1)').find('.o_kanban_load_more').click();
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 3,
@@ -1476,6 +1544,8 @@ QUnit.module('Views', {
assert.verifySteps([[2, undefined], [2, undefined], [2, 2]],
"the records should be correctly fetched");
+ // reload
+ envIDs = [1, 2, 4]; // first group is limited again to 2 records
kanban.reload();
assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_record').length, 2,
"there should be 2 records in the column after reload");
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 2247a64fa3c..4b33af9f0a6 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -1271,7 +1271,9 @@ QUnit.module('Views', {
data: this.data,
arch: '<tree><field name="date"/><field name="datetime"/></tree>',
session: {
- tzOffset: 120
+ getTZOffset: function () {
+ return 120;
+ },
},
});
@@ -1322,12 +1324,13 @@ QUnit.module('Views', {
});
QUnit.test('list view with nested groups', function (assert) {
- assert.expect(37);
+ assert.expect(42);
this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1});
this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 2});
var nbRPCs = {readGroup: 0, searchRead: 0};
+ var envIDs = []; // the ids that should be in the environment during this test
var list = createView({
View: ListView,
@@ -1352,10 +1355,14 @@ QUnit.module('Views', {
return this._super.apply(this, arguments);
},
intercepts: {
- switch_view: function (event) {
+ switch_view: function (event) {
assert.strictEqual(event.data.res_id, 4,
"'switch_view' event has been triggered");
},
+ env_updated: function (event) {
+ assert.deepEqual(event.data.ids, envIDs,
+ "should notify the environment with the correct ids");
+ },
},
});
@@ -1398,6 +1405,7 @@ QUnit.module('Views', {
// open subgroup
nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = [4, 5]; // the opened subgroup contains these two records
$openGroup.find('.o_group_header:nth(2)').click();
assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
@@ -1414,6 +1422,7 @@ QUnit.module('Views', {
// sort by int_field (ASC) and check that open groups are still open
nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = [5, 4]; // order of the records changed
list.$('thead th:last').click();
assert.strictEqual(nbRPCs.readGroup, 2, "should have done two read_groups");
assert.strictEqual(nbRPCs.searchRead, 1, "should have done one search_read");
@@ -1427,6 +1436,7 @@ QUnit.module('Views', {
// close first level group
nbRPCs = {readGroup: 0, searchRead: 0};
+ envIDs = []; // the group being closed, there is no more record in the environment
list.$('.o_group_header:first').click();
assert.strictEqual(nbRPCs.readGroup, 0, "should have done no read_group");
assert.strictEqual(nbRPCs.searchRead, 0, "should have done no search_read");
@@ -2519,6 +2529,55 @@ QUnit.module('Views', {
list.destroy();
});
+
+ QUnit.test('grouped list view, indentation for empty group', function (assert) {
+ assert.expect(3);
+
+ this.data.foo.fields.priority = {
+ string: "Priority",
+ type: "selection",
+ selection: [[1, "Low"], [2, "Medium"], [3, "High"]],
+ default: 1,
+ };
+ this.data.foo.records.push({id: 5, foo: "blip", int_field: -7, m2o: 1, priority: 2});
+ this.data.foo.records.push({id: 6, foo: "blip", int_field: 5, m2o: 1, priority: 3});
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '<tree><field name="id"/></tree>',
+ groupBy: ['priority', 'm2o'],
+ mockRPC: function (route, args) {
+ // Override of the read_group to display the row even if there is no record in it,
+ // to mock the behavihour of some fields e.g stage_id on the sale order.
+ if (args.method === 'read_group' && args.kwargs.groupby[0] === "m2o") {
+ return $.when([
+ {
+ id: 8,
+ m2o:[1,"Value 1"],
+ m2o_count: 0
+ }, {
+ id: 2,
+ m2o:[2,"Value 2"],
+ m2o_count: 1
+ }
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // open the first group
+ list.$('.o_group_header:first').click();
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().length, 1,
+ "There should be an empty element creating the indentation for the subgroup.");
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().eq(0).hasClass('fa'), true,
+ "The first element of the row name should have the fa class");
+ assert.strictEqual(list.$('th.o_group_name').eq(1).children().eq(0).is('span'), true,
+ "The first element of the row name should be a span");
+ list.destroy();
+ });
});
});
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
index 8c46d8fff46..43bf4ee6694 100644
--- a/addons/web/static/tests/views/view_dialogs_tests.js
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -140,6 +140,35 @@ QUnit.module('Views', {
dialog.destroy();
});
+ QUnit.test('SelectCreateDialog list view in readonly', function (assert) {
+ assert.expect(1);
+
+ var parent = createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,list':
+ '<tree string="Partner" editable="bottom">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>',
+ 'partner,false,search':
+ '<search/>'
+ },
+ });
+
+ var dialog = new dialogs.SelectCreateDialog(parent, {
+ res_model: 'partner',
+ }).open();
+
+ // click on the first row to see if the list is editable
+ dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first').click();
+
+ assert.equal(dialog.$('.o_list_view tbody tr:first td:not(.o_list_record_selector):first input').length, 0,
+ "list view should not be editable in a SelectCreateDialog");
+
+ dialog.destroy();
+ });
+
});
-}); \ No newline at end of file
+});
diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js
index 83169a64e1f..d251e3dea15 100644
--- a/addons/web/static/tests/widgets/data_export_tests.js
+++ b/addons/web/static/tests/widgets/data_export_tests.js
@@ -4,6 +4,7 @@ odoo.define('web.data_export_tests', function (require) {
var framework = require('web.framework');
var ListView = require('web.ListView');
var testUtils = require('web.test_utils');
+var data = require('web.data');
var createView = testUtils.createView;
@@ -127,6 +128,89 @@ QUnit.module('widgets', {
]);
});
+ QUnit.test('saving fields list when exporting data', function (assert) {
+ assert.expect(6);
+
+ var create = data.DataSet.prototype.create;
+
+ data.DataSet.prototype.create = function (data, options) {
+ assert.step('create');
+ return $.when([]);
+ };
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '<tree><field name="foo"/></tree>',
+ viewOptions: {
+ sidebar: true,
+ },
+ mockRPC: function (route) {
+ if (route === '/web/export/formats') {
+ return $.when([
+ {tag: 'csv', label: 'CSV'},
+ {tag: 'xls', label: 'Excel'},
+ ]);
+ }
+ if (route === '/web/export/get_fields') {
+ return $.when([
+ {
+ field_type: "one2many",
+ string: "Activities",
+ required: false,
+ value: "activity_ids/id",
+ id: "activity_ids",
+ params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities"},
+ relation_field: "res_id",
+ children: true,
+ },
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ list.getParent().searchview = {
+ build_search_data: function () {
+ assert.step('build_search_data');
+ return {
+ contexts: [],
+ domains: [],
+ groupbys: [],
+ };
+ },
+ };
+
+ // Open the export modal
+ list.$('thead th.o_list_record_selector input').click();
+ list.sidebar.$('a:contains(Export)').click();
+ assert.strictEqual($('.modal').length, 1,
+ "a modal dialog should be open");
+
+ // Select 'Activities' in fields to export
+ assert.strictEqual($('.modal select.o_fields_list option').length, 0,
+ "the fields list should be empty");
+ $('.modal .o_export_tree_item:contains(Activities)').click();
+ $('.modal button:contains(Add)').click();
+ assert.strictEqual($('.modal select.o_fields_list option').length, 1,
+ "there should be one item in the fields list");
+
+ // Save fields list
+ $('.modal a:contains(Save fields list)').click();
+ $('.modal .o_save_list > input').val('fields list').trigger('input');
+ $('.modal .o_save_list > button').click();
+ assert.verifySteps(['build_search_data', 'create'],
+ "create should have been called");
+
+ // Close the modal and destroy list
+ $('.modal button span:contains(Close)').click();
+ list.destroy();
+
+ // restore create function
+ data.DataSet.prototype.create = create;
+ });
+
});
});