|
- Hours
+ Hours
|
h
|
Time and material |
- Rates
+ Rates
|
%
|
Time and material |
- Profitability
+ Profitability
|
diff --git a/addons/sales_team/views/crm_team_views.xml b/addons/sales_team/views/crm_team_views.xml
index a2d25ca0c38..b4ad3372495 100644
--- a/addons/sales_team/views/crm_team_views.xml
+++ b/addons/sales_team/views/crm_team_views.xml
@@ -17,7 +17,9 @@
+
+
diff --git a/addons/stock/models/procurement.py b/addons/stock/models/procurement.py
index 8012a1b2a19..011fe65ea8c 100644
--- a/addons/stock/models/procurement.py
+++ b/addons/stock/models/procurement.py
@@ -317,6 +317,8 @@ class ProcurementOrder(models.Model):
substract_quantity = location_orderpoints.subtract_procurements_from_orderpoints()
for group in location_data['groups']:
+ if group.get('from_date'):
+ product_context['from_date'] = group['from_date'].strftime(DEFAULT_SERVER_DATETIME_FORMAT)
if group['to_date']:
product_context['to_date'] = group['to_date'].strftime(DEFAULT_SERVER_DATETIME_FORMAT)
product_quantity = location_data['products'].with_context(product_context)._product_available()
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index 63a9be564aa..54b4a877fd7 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -255,11 +255,11 @@ class Product(models.Model):
# TDE FIXME: should probably clean the search methods
# to prevent sql injections
if field not in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'):
- raise UserError('Invalid domain left operand')
+ raise UserError(_('Invalid domain left operand %s') % field)
if operator not in ('<', '>', '=', '!=', '<=', '>='):
- raise UserError('Invalid domain operator')
+ raise UserError(_('Invalid domain operator %s') % operator)
if not isinstance(value, (float, int)):
- raise UserError('Invalid domain right operand')
+ raise UserError(_('Invalid domain right operand %s') % value)
# TODO: Still optimization possible when searching virtual quantities
ids = []
diff --git a/addons/stock/models/stock_incoterms.py b/addons/stock/models/stock_incoterms.py
index 0d8a1258fbf..c315e36abe5 100644
--- a/addons/stock/models/stock_incoterms.py
+++ b/addons/stock/models/stock_incoterms.py
@@ -9,7 +9,7 @@ class Incoterms(models.Model):
_description = "Incoterms"
name = fields.Char(
- 'Name', required=True,
+ 'Name', required=True, translate=True,
help="Incoterms are series of sales terms. They are used to divide transaction costs and responsibilities between buyer and seller and reflect state-of-the-art transportation practices.")
code = fields.Char(
'Code', size=3, required=True,
diff --git a/addons/stock/report/report_deliveryslip.xml b/addons/stock/report/report_deliveryslip.xml
index 7a24f7e6559..f64d2318b2a 100644
--- a/addons/stock/report/report_deliveryslip.xml
+++ b/addons/stock/report/report_deliveryslip.xml
@@ -19,9 +19,6 @@
-
- t-field-options='{"widget": "contact", "fields": ["address", "name", "phone", "fax"], "no_marker": true}'/>
-
diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml
index bbcbafb7b08..f49daf00cf7 100644
--- a/addons/stock/views/product_views.xml
+++ b/addons/stock/views/product_views.xml
@@ -118,6 +118,7 @@
+
product.template.stock.property.form.inherit
product.template
@@ -133,7 +134,7 @@
days
-
+
@@ -260,6 +261,7 @@
+
product.product.procurement
product.product
@@ -330,6 +332,7 @@
+
product.template_procurement
product.template
@@ -391,6 +394,12 @@
class="oe_stat_button" icon="fa-cogs"
groups="stock.group_adv_location"/>
+
+
+
+ {'invisible':['|', ('type', 'not in', ['product', 'consu']), ('product_variant_count', '>', 1)]}
+
diff --git a/addons/stock/views/stock_quant_views.xml b/addons/stock/views/stock_quant_views.xml
index a33039ce7bd..67997c74854 100644
--- a/addons/stock/views/stock_quant_views.xml
+++ b/addons/stock/views/stock_quant_views.xml
@@ -86,7 +86,7 @@
-
+
diff --git a/addons/stock_account/models/account_invoice.py b/addons/stock_account/models/account_invoice.py
index aacdd6c7177..6291da2922f 100644
--- a/addons/stock_account/models/account_invoice.py
+++ b/addons/stock_account/models/account_invoice.py
@@ -38,7 +38,7 @@ class AccountInvoice(models.Model):
cacc = accounts['expense'].id
if dacc and cacc:
price_unit = i_line._get_anglo_saxon_price_unit()
- if inv.currency_id.id != company_currency:
+ if inv.currency_id != company_currency:
currency_id = inv.currency_id.id
amount_currency = i_line._get_price(company_currency, price_unit)
else:
@@ -99,6 +99,6 @@ class AccountInvoiceLine(models.Model):
def get_invoice_line_account(self, type, product, fpos, company):
if company.anglo_saxon_accounting and type in ('in_invoice', 'in_refund') and product and product.type == 'product':
accounts = product.product_tmpl_id.get_product_accounts(fiscal_pos=fpos)
- if accounts['stock_input']:
+ if product.categ_id.property_valuation != 'manual_periodic' and accounts['stock_input']:
return accounts['stock_input']
return super(AccountInvoiceLine, self).get_invoice_line_account(type, product, fpos, company)
diff --git a/addons/stock_account/wizard/wizard_valuation_history_views.xml b/addons/stock_account/wizard/wizard_valuation_history_views.xml
index 48e8162e793..abadf22cd07 100644
--- a/addons/stock_account/wizard/wizard_valuation_history_views.xml
+++ b/addons/stock_account/wizard/wizard_valuation_history_views.xml
@@ -25,6 +25,7 @@
form
new
+ {'default_compute_at_date': 0}
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 982fd05b1e0..9f56cd01ef4 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -76,6 +76,10 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, {
if (event.data.on_success) {
event.data.on_success(result);
}
+ }).fail(function (result) {
+ if (event.data.on_fail) {
+ event.data.on_fail(result);
+ }
});
},
},
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index b02376ef6b7..c81479ec1e0 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -182,19 +182,35 @@ var ViewManagerAction = WidgetAction.extend({
},
/**
* Sets the scroll position of the widgets's active_view
- * @param {int} [scrollTop] the number of pixels to scroll
+ * @todo: replace this with a generic get/set local state mechanism.
+ * @see getScrollTop
+ *
+ * @override
+ * @param {integer} [scrollTop] the number of pixels to scroll
*/
- setScrollTop: function(scrollTop) {
- var viewController = this.widget.active_view.controller;
+ setScrollTop: function (scrollTop) {
+ var activeView = this.widget.active_view;
+ var viewController = activeView && activeView.controller;
if (viewController) {
viewController.setScrollTop(scrollTop);
}
},
/**
- * @return {int} the number of pixels the webclient is scrolled when leaving the action
+ * Returns the current scrolling offset for the current action. We have to
+ * ask nicely the question to the active view, because the answer depends
+ * on the view.
+ *
+ * @todo: replace this mechanism with a generic getLocalState and
+ * getLocalState. Scrolling behaviour is only a part of what we might want
+ * to restore.
+ *
+ * @override
+ * @returns {integer} the number of pixels the webclient is currently
+ * scrolled
*/
- getScrollTop: function() {
- var viewController = this.widget.active_view.controller;
+ getScrollTop: function () {
+ var activeView = this.widget.active_view;
+ var viewController = activeView && activeView.controller;
return viewController ? viewController.getScrollTop() : 0;
},
/**
diff --git a/addons/web/static/src/js/chrome/view_manager.js b/addons/web/static/src/js/chrome/view_manager.js
index bc441f7accf..8d760c568b0 100644
--- a/addons/web/static/src/js/chrome/view_manager.js
+++ b/addons/web/static/src/js/chrome/view_manager.js
@@ -20,7 +20,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
custom_events: {
execute_action: function(event) {
var data = event.data;
- this.do_execute_action(data.action_data, data.model, data.record_id, data.on_closed)
+ this.do_execute_action(data.action_data, data.model, data.res_ids, data.on_closed)
.then(data.on_success, data.on_fail);
},
search: function(event) {
@@ -551,13 +551,14 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
* @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
* @param {Object} [action_data.context=null] additional action context, to add to the current context
* @param {DataSet} dataset a dataset object used to communicate with the server
- * @param {Object} [record_id] the identifier of the object on which the action is to be applied
+ * @param {integer[]} [res_ids] the res_ids of the objects on which the action is to be applied (fallback on env.ids if not set)
* @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
*/
- do_execute_action: function (action_data, model, record_id, on_closed) {
+ do_execute_action: function (action_data, model, res_ids, on_closed) {
var self = this;
var result_handler = on_closed || function () {};
var context = new Context(this.env.context, action_data.context || {});
+ var record_id = res_ids && res_ids[0];
// response handler
var handler = function (action) {
@@ -572,10 +573,10 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
);
ncontext.add(action_data.context || {});
ncontext.add({active_model: self.env.modelName});
- if (record_id) {
+ if (res_ids) {
ncontext.add({
active_id: record_id,
- active_ids: [record_id],
+ active_ids: res_ids,
});
}
ncontext.add(action.context || {});
@@ -592,7 +593,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
if (action_data.special) {
return handler({"type":"ir.actions.act_window_close"});
} else if (action_data.type === "object") {
- var args = record_id ? [[record_id]] : [this.env.ids];
+ var args = res_ids ? [res_ids] : [this.env.ids];
if (action_data.args) {
try {
// Warning: quotes and double quotes problem due to json and xml clash
@@ -610,7 +611,7 @@ var ViewManager = Widget.extend(ControlPanelMixin, {
return data_manager.load_action(action_data.name, _.extend(pyeval.eval('context', context), {
active_model: this.env.modelName,
active_ids: this.env.ids,
- active_id: record_id
+ active_id: record_id,
})).then(handler);
}
},
diff --git a/addons/web/static/src/js/core/mixins.js b/addons/web/static/src/js/core/mixins.js
index 9c643597f04..cc99cc1aaa4 100644
--- a/addons/web/static/src/js/core/mixins.js
+++ b/addons/web/static/src/js/core/mixins.js
@@ -511,6 +511,7 @@ var ServicesMixin = {
action: action,
options: options,
on_success: function (result) { def.resolve(result); },
+ on_fail: function (result) { def.reject(result); },
});
return def;
},
diff --git a/addons/web/static/src/js/fields/abstract_field.js b/addons/web/static/src/js/fields/abstract_field.js
index 725a237750d..d09174b2b20 100644
--- a/addons/web/static/src/js/fields/abstract_field.js
+++ b/addons/web/static/src/js/fields/abstract_field.js
@@ -348,8 +348,11 @@ var AbstractField = Widget.extend({
*
* @private
* @param {any} value
+ * @param {Object} [options]
+ * @param {boolean} [options.forceChange=false] if true, the change event will be
+ * triggered even if the new value is the same as the old one
*/
- _setValue: function (value) {
+ _setValue: function (value, options) {
// we try to avoid doing useless work, if the value given has not
// changed. Note that we compare the unparsed values.
if (this.lastSetValue === value || (this.value === false && value === '')) {
@@ -363,7 +366,7 @@ var AbstractField = Widget.extend({
this._isValid = false;
return;
}
- if (this._isSameValue(value)) {
+ if (!(options && options.forceChange) && this._isSameValue(value)) {
return;
}
var changes = {};
@@ -371,6 +374,7 @@ var AbstractField = Widget.extend({
this.trigger_up('field_changed', {
dataPointID: this.dataPointID,
changes: changes,
+ viewType: this.viewType,
});
},
@@ -404,6 +408,9 @@ var AbstractField = Widget.extend({
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'next_line'});
break;
+ case $.ui.keyCode.ESCAPE:
+ this.trigger_up('navigation_move', {direction: 'cancel'});
+ break;
case $.ui.keyCode.UP:
ev.stopPropagation();
this.trigger_up('navigation_move', {direction: 'up'});
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 49bcccd2640..a5ceba50d3f 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -83,10 +83,10 @@ var DebouncedField = AbstractField.extend({
init: function () {
this._super.apply(this, arguments);
- // _debouncedStarted is used to detect that the user interacted at least
+ // _isDirty is used to detect that the user interacted at least
// once with the widget, so that we can prevent it from triggering a
// field_changed in commitChanges if the user didn't change anything
- this._debouncedStarted = false;
+ this._isDirty = false;
if (this.mode === 'edit') {
if (this.DEBOUNCE) {
this._doDebouncedAction = _.debounce(this._doAction, this.DEBOUNCE);
@@ -97,7 +97,7 @@ var DebouncedField = AbstractField.extend({
var self = this;
var debouncedFunction = this._doDebouncedAction;
this._doDebouncedAction = function () {
- self._debouncedStarted = true;
+ self._isDirty = true;
debouncedFunction.apply(self, arguments);
};
}
@@ -115,7 +115,7 @@ var DebouncedField = AbstractField.extend({
* @override
*/
commitChanges: function () {
- if (this._debouncedStarted && this.mode === 'edit') {
+ if (this._isDirty && this.mode === 'edit') {
this._doAction();
}
},
@@ -132,7 +132,13 @@ var DebouncedField = AbstractField.extend({
* @private
*/
_doAction: function () {
- this._setValue(this._getValue());
+ // as _doAction may be debounced, it may happen that it is called after
+ // the widget has been destroyed, and in this case, we don't want it to
+ // do anything (commitChanges ensures that if it has local changes, they
+ // are triggered up before the widget is destroyed, if necessary).
+ if (!this.isDestroyed()) {
+ this._setValue(this._getValue());
+ }
},
/**
* Should return the current value of the field, in the DOM (for example,
@@ -159,6 +165,7 @@ var InputField = DebouncedField.extend({
*/
init: function () {
this._super.apply(this, arguments);
+ this.nodeOptions.isPassword = 'password' in this.attrs;
if (this.mode === 'edit') {
this.tagName = 'input';
}
@@ -216,8 +223,11 @@ var InputField = DebouncedField.extend({
this.$input = $input || $(" ");
this.$input.addClass('o_input');
this.$input.attr({
- type: 'text',
+ type: this.nodeOptions.isPassword ? 'password' : 'text',
placeholder: this.attrs.placeholder || "",
+ autocomplete: this.nodeOptions.isPassword ?
+ 'new-password' :
+ this.attrs.autocomplete,
});
this.$input.val(this._formatValue(this.value));
return this.$input;
@@ -240,7 +250,7 @@ var InputField = DebouncedField.extend({
* @private
*/
_renderReadonly: function () {
- this.$el.html(this._formatValue(this.value));
+ this.$el.text(this._formatValue(this.value));
},
//--------------------------------------------------------------------------
@@ -523,6 +533,13 @@ var FieldMonetary = InputField.extend({
this.$el.prepend($currencySymbol);
}
},
+ /**
+ * @override
+ * @private
+ */
+ _renderReadonly: function () {
+ this.$el.html(this._formatValue(this.value));
+ },
/**
* Re-gets the currency as its value may have changed.
* @see FieldMonetary.resetOnAnyFieldChange
@@ -541,7 +558,7 @@ var FieldMonetary = InputField.extend({
* @private
*/
_setCurrency: function () {
- var currencyField = this.attrs.currency_field || this.field.currency_field || 'currency_id';
+ var currencyField = this.nodeOptions.currency_field || this.field.currency_field || 'currency_id';
var currencyID = this.record.data[currencyField] && this.record.data[currencyField].res_id;
this.currency = session.get_currency(currencyID);
},
@@ -885,8 +902,8 @@ var FieldPhone = FieldEmail.extend({
_renderReadonly: function () {
this._super();
if (this._canCall()) {
- var text = this.$el.text();
// Split phone number into two to prevent Skype app from finding it
+ var text = this.$el.text();
var part1 = _.escape(text.substr(0, text.length/2));
var part2 = _.escape(text.substr(text.length/2));
this.$el.html(part1 + "" + part2);
@@ -1543,7 +1560,7 @@ var FieldPercentPie = AbstractField.extend({
this.$leftMask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg});
this.$rightMask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg});
- this.$pieValue.html(Math.round(value) + '%');
+ this.$pieValue.text(Math.round(value) + '%');
},
});
@@ -1684,9 +1701,9 @@ var FieldProgressBar = AbstractField.extend({
if (!this.write_mode) {
if (max_value !== 100) {
- this.$('.o_progressbar_value').html(utils.human_number(value) + " / " + utils.human_number(max_value));
+ this.$('.o_progressbar_value').text(utils.human_number(value) + " / " + utils.human_number(max_value));
} else {
- this.$('.o_progressbar_value').html(utils.human_number(value) + "%");
+ this.$('.o_progressbar_value').text(utils.human_number(value) + "%");
}
} else if (isNaN(v)) {
this.$('.o_progressbar_value').val(this.edit_max_value ? max_value : value);
diff --git a/addons/web/static/src/js/fields/field_utils.js b/addons/web/static/src/js/fields/field_utils.js
index ed3014d9bb6..9a6734b7baf 100644
--- a/addons/web/static/src/js/fields/field_utils.js
+++ b/addons/web/static/src/js/fields/field_utils.js
@@ -52,10 +52,23 @@ function formatBoolean(value) {
* an empty string.
*
* @param {string|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ * @param {boolean} [options.isPassword=false] if true, returns '********'
+ * instead of the formatted value
* @returns {string}
*/
-function formatChar(value) {
- return typeof value === 'string' ? value : '';
+function formatChar(value, field, options) {
+ value = typeof value === 'string' ? value : '';
+ if (options && options.isPassword) {
+ return _.str.repeat('*', value ? value.length : 0);
+ }
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
}
/**
@@ -169,9 +182,16 @@ function formatFloatTime(value) {
* return an empty string.
*
* @param {integer|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.isPassword=false] if true, returns '********'
* @returns {string}
*/
-function formatInteger(value) {
+function formatInteger(value, field, options) {
+ if (options && options.isPassword) {
+ return _.str.repeat('*', String(value).length);
+ }
if (!value && value !== 0) {
// previously, it returned 'false'. I don't know why. But for the Pivot
// view, I want to display the concept of 'no value' with an empty
@@ -189,10 +209,18 @@ function formatInteger(value) {
* case, we assume that it is a record from a BasicModel.
*
* @param {Array|Object|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
* @returns {string}
*/
-function formatMany2one(value) {
- return value && (_.isArray(value) ? value[1] : value.data.display_name) || '';
+function formatMany2one(value, field, options) {
+ value = value && (_.isArray(value) ? value[1] : value.data.display_name) || '';
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
}
/**
@@ -270,14 +298,27 @@ function formatMonetary(value, field, options) {
}
}
-function formatSelection(value, field) {
- if (!value) {
- return '';
- }
+/**
+ * Returns a string representing the value of the selection.
+ *
+ * @param {string|false} value
+ * @param {Object} [field]
+ * a description of the field (note: this parameter is ignored)
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.escape=false] if true, escapes the formatted value
+ */
+function formatSelection(value, field, options) {
var val = _.find(field.selection, function (option) {
return option[0] === value;
});
- return val[1];
+ if (!val) {
+ return '';
+ }
+ value = val[1];
+ if (options && options.escape) {
+ value = _.escape(value);
+ }
+ return value;
}
////////////////////////////////////////////////////////////////////////////////
@@ -318,7 +359,7 @@ function parseDate(value, field, options) {
}
if (date.year() >= 1900) {
date.toJSON = function () {
- return this.format('YYYY-MM-DD');
+ return this.clone().locale('en').format('YYYY-MM-DD');
};
return date;
}
@@ -365,7 +406,7 @@ function parseDateTime(value, field, options) {
}
if (datetime.year() >= 1900) {
datetime.toJSON = function () {
- return this.format('YYYY-MM-DD HH:mm:ss');
+ return this.clone().locale('en').format('YYYY-MM-DD HH:mm:ss');
};
return datetime;
}
@@ -374,7 +415,10 @@ function parseDateTime(value, field, options) {
}
function parseFloat(value) {
- value = value.replace(new RegExp(core._t.database.parameters.thousands_sep, "g"), '');
+ if (core._t.database.parameters.thousands_sep) {
+ var escapedSep = _.str.escapeRegExp(core._t.database.parameters.thousands_sep);
+ value = value.replace(new RegExp(escapedSep, 'g'), '');
+ }
value = value.replace(core._t.database.parameters.decimal_point, '.');
var parsed = Number(value);
if (isNaN(parsed)) {
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 10d72a1398d..a5f9b443ae0 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -88,6 +88,7 @@ var FieldMany2One = AbstractField.extend({
'click .o_external_button': '_onExternalButtonClick',
'click': '_onClick',
}),
+ AUTOCOMPLETE_DELAY: 200,
init: function () {
this._super.apply(this, arguments);
@@ -147,6 +148,11 @@ var FieldMany2One = AbstractField.extend({
});
},
select: function (event, ui) {
+ // we do not want the select event to trigger any additional
+ // effect, such as navigating to another field.
+ event.stopImmediatePropagation();
+ event.preventDefault();
+
var item = ui.item;
self.floating = false;
if (item.id) {
@@ -159,10 +165,17 @@ var FieldMany2One = AbstractField.extend({
focus: function (event) {
event.preventDefault(); // don't automatically select values on focus
},
+ close: function (event) {
+ // it is necessary to prevent ESC key from propagating to field
+ // root, to prevent unwanted discard operations.
+ if (event.which === $.ui.keyCode.ESCAPE) {
+ event.stopPropagation();
+ }
+ },
autoFocus: true,
html: true,
minLength: 0,
- delay: 200,
+ delay: this.AUTOCOMPLETE_DELAY,
});
this.$input.autocomplete("option", "position", { my : "left top", at: "left bottom" });
this.autocomplete_bound = true;
@@ -250,6 +263,7 @@ var FieldMany2One = AbstractField.extend({
*/
_reset: function () {
this._super.apply(this, arguments);
+ this.floating = false;
this.m2o_value = field_utils.format.many2one(this.value);
},
/**
@@ -427,6 +441,7 @@ var FieldMany2One = AbstractField.extend({
view_id: view_id,
readonly: !self.can_write,
on_saved: function () {
+ self._setValue(self.value.data, {forceChange: true});
self.trigger_up('reload', {db_id: self.value.id});
},
}).open();
@@ -461,6 +476,14 @@ var FieldMany2One = AbstractField.extend({
this._updateExternalButton();
}
},
+ /**
+ * @override
+ * @private
+ */
+ _onKeydown: function () {
+ this.floating = false;
+ this._super.apply(this, arguments);
+ },
/**
* Stops the left/right navigation move event if the cursor is not at the
* start/end of the input element. Stops any navigation move event if the
@@ -531,6 +554,7 @@ var FieldX2Many = AbstractField.extend({
tagName: 'div',
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
add_record: '_onAddRecord',
+ discard_changes: '_onDiscardChanges',
edit_line: '_onEditLine',
field_changed: '_onFieldChanged',
kanban_record_delete: '_onDeleteRecord',
@@ -740,7 +764,7 @@ var FieldX2Many = AbstractField.extend({
this.renderer.commitChanges(recordID).then(function () { // TODO wrong as no mutex protection
var fieldNames = self.renderer.canBeSaved(recordID);
if (fieldNames.length) {
- self.trigger_up('discard_x2m_changes', {
+ self.trigger_up('discard_changes', {
recordID: recordID,
onSuccess: def.resolve.bind(def),
onFailure: def.reject.bind(def),
@@ -782,6 +806,18 @@ var FieldX2Many = AbstractField.extend({
ids: [ev.data.id],
});
},
+ /**
+ * When the discard_change event go through this field, we can just decorate
+ * the data with the name of the field. The origin field ignore this
+ * information (it is a subfield in a o2m), and the controller will need to
+ * know which field needs to be handled.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ ev.data.fieldName = this.name;
+ },
/**
* Called when the renderer asks to edit a line, in that case simply tells
* him back to toggle the mode of this row.
@@ -837,15 +873,30 @@ var FieldX2Many = AbstractField.extend({
* changes; if the row could be saved, we make the row readonly. Otherwise,
* we trigger a new event for the view to tell it to discard the changes
* made to that row.
+ * Note that we do that in the controller mutex to ensure that the check on
+ * the row (whether or not it can be saved) is done once all potential
+ * onchange RPCs are done (those RPCs being executed in the same mutex).
+ * This particular handling is done in this handler, instead of in the
+ * _saveLine function directly, because _saveLine is also called from
+ * the controller (via commitChanges), and in this case, it is already
+ * executed in the mutex.
*
* @private
* @param {OdooEvent} ev
+ * @param {string} ev.recordID
+ * @param {function} ev.onSuccess success callback (see '_saveLine')
+ * @param {function} ev.onFailure fail callback (see '_saveLine')
*/
_onSaveLine: function (ev) {
+ var self = this;
ev.stopPropagation();
- this._saveLine(ev.data.recordID)
- .done(ev.data.onSuccess)
- .fail(ev.data.onFailure);
+ this.trigger_up('mutexify', {
+ action: function () {
+ return self._saveLine(ev.data.recordID)
+ .done(ev.data.onSuccess)
+ .fail(ev.data.onFailure);
+ },
+ });
},
/**
* Adds field name information to the event, so that the view upstream is
@@ -863,6 +914,16 @@ var FieldOne2Many = FieldX2Many.extend({
className: 'o_field_one2many',
supportedFieldTypes: ['one2many'],
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ // boolean used to prevent concurrent record creation
+ this.creatingRecord = false;
+ },
+
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -880,6 +941,7 @@ var FieldOne2Many = FieldX2Many.extend({
var index = self.editable === 'top' ? 0 : self.value.data.length - 1;
var newID = self.value.data[index].id;
self.renderer.editRecord(newID);
+ self.creatingRecord = false;
}
}
});
@@ -933,10 +995,13 @@ var FieldOne2Many = FieldX2Many.extend({
ev.stopPropagation();
if (this.editable) {
- this._setValue({
- operation: 'CREATE',
- position: this.editable,
- });
+ if (!this.creatingRecord) {
+ this.creatingRecord = true;
+ this._setValue({
+ operation: 'CREATE',
+ position: this.editable,
+ });
+ }
} else {
var self = this;
this._openFormDialog({
@@ -962,7 +1027,7 @@ var FieldOne2Many = FieldX2Many.extend({
this._openFormDialog({
id: ev.data.id,
- on_saved: this._setValue.bind(this, { operation: 'NOOP' }),
+ on_saved: this._setValue.bind(this, { operation: 'NOOP' }, {}),
readonly: this.mode === 'readonly',
});
},
@@ -1026,11 +1091,15 @@ var FieldMany2Many = FieldX2Many.extend({
* @param {OdooEvent} ev
*/
_onOpenRecord: function (ev) {
+ var self = this;
_.extend(ev.data, {
context: this.record.getContext(this.recordParams),
domain: this.record.getDomain(this.recordParams),
fields_view: this.attrs.views && this.attrs.views.form,
- on_saved: this.trigger_up.bind(this, 'reload', {db_id: ev.data.id}),
+ on_saved: function () {
+ self._setValue({operation: 'TRIGGER_ONCHANGE'}, {forceChange: true});
+ self.trigger_up('reload', {db_id: ev.data.id});
+ },
readonly: this.mode === 'readonly',
string: this.string,
});
@@ -1671,7 +1740,7 @@ var FieldSelection = AbstractField.extend({
},
//--------------------------------------------------------------------------
- // Private
+ // Public
//--------------------------------------------------------------------------
/**
@@ -1681,6 +1750,12 @@ var FieldSelection = AbstractField.extend({
getFocusableElement: function () {
return this.$el.is('select') ? this.$el : $();
},
+ /**
+ * @override
+ */
+ isSet: function () {
+ return this.value !== false;
+ },
//--------------------------------------------------------------------------
// Private
@@ -1710,7 +1785,7 @@ var FieldSelection = AbstractField.extend({
* @private
*/
_renderReadonly: function () {
- this.$el.empty().html(this._formatValue(this.value));
+ this.$el.empty().text(this._formatValue(this.value));
},
//--------------------------------------------------------------------------
@@ -1779,14 +1854,17 @@ var FieldRadio = FieldSelection.extend({
* @override
*/
_renderEdit: function () {
- this.$("input").prop("checked", false);
- var key;
+ var value;
if (this.field.type === 'many2one') {
- key = this.value && this.value.data.id;
+ value = this.value && this.value.data.id;
} else {
- key = this.value;
+ value = this.value;
}
- this.$('input[data-value="' + key + '"]').prop('checked', true);
+ var index = _.findIndex(this.values, function (option) {
+ return option[0] === value;
+ });
+ this.$("input").prop("checked", false);
+ this.$('input[data-index="' + index + '"]').prop('checked', true);
},
//--------------------------------------------------------------------------
@@ -1798,14 +1876,12 @@ var FieldRadio = FieldSelection.extend({
* @param {MouseEvent} event
*/
_onInputClick: function (event) {
- var res_id = $(event.target).data('value');
+ var index = $(event.target).data('index');
+ var value = this.values[index];
if (this.field.type === 'many2one') {
- var value = _.find(this.values, function (val) {
- return val[0] === res_id;
- });
- this._setValue({id: res_id, display_name: value[1]});
+ this._setValue({id: value[0], display_name: value[1]});
} else {
- this._setValue(res_id);
+ this._setValue(value[0]);
}
},
});
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 1e0187b348e..53e5be8ff6e 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -18,6 +18,8 @@ var _t = core._t;
var BasicController = AbstractController.extend(FieldManagerMixin, {
custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, {
+ discard_changes: '_onDiscardChanges',
+ mutexify: '_onMutexify',
reload: '_onReload',
sidebar_data_asked: '_onSidebarDataAsked',
translate: '_onTranslate',
@@ -247,9 +249,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
var self = this;
var def = $.Deferred();
var reload = function () {
- if (!self.isDestroyed()) {
- self.reload();
- }
+ return self.isDestroyed() ? $.when() : self.reload();
};
record = record || this.model.get(this.handle);
var recordID = record.data.id;
@@ -258,13 +258,17 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
context: record.getContext({additionalContext: attrs.context}),
}),
model: record.model,
- record_id: recordID,
+ res_ids: [recordID],
on_closed: function (reason) {
if (!_.isObject(reason)) {
- reload();
+ reload(reason);
}
},
- on_fail: reload,
+ on_fail: function (reason) {
+ reload().always(function() {
+ def.reject(reason);
+ })
+ },
on_success: def.resolve.bind(def),
});
return this.alive(def);
@@ -305,6 +309,26 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
doIt();
}
},
+ /**
+ * Disables buttons so that they can't be clicked anymore.
+ *
+ * @private
+ */
+ _disableButtons: function () {
+ if (this.$buttons) {
+ this.$buttons.find('button').attr('disabled', true);
+ }
+ },
+ /**
+ * Enables buttons so they can be clicked again.
+ *
+ * @private
+ */
+ _enableButtons: function () {
+ if (this.$buttons) {
+ this.$buttons.find('button').removeAttr('disabled');
+ }
+ },
/**
* Returns the new sidebar env
*
@@ -385,18 +409,19 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
// Note: it is the model's job to do nothing if there is nothing to save
if (this.canBeSaved(recordID)) {
var self = this;
- var def = this.model.save(recordID, { // Save then leave edit mode
+ var saveDef = this.model.save(recordID, { // Save then leave edit mode
reload: options.reload,
savePoint: options.savePoint,
});
if (!options.stayInEdit) {
- def = def.then(function (fieldNames) {
- return self._confirmSave(recordID).then(function () {
+ saveDef = saveDef.then(function (fieldNames) {
+ var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly');
+ return def.then(function () {
return fieldNames;
});
});
}
- return def;
+ return saveDef;
} else {
return $.Deferred().reject(); // Cannot be saved
}
@@ -456,6 +481,31 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
// Handlers
//--------------------------------------------------------------------------
+ /**
+ * Called when a list element asks to discard the changes made to one of
+ * its rows. It can happen with a x2many (if we are in a form view) or with
+ * a list view.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDiscardChanges: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ var recordID = ev.data.recordID;
+ this.discardChanges(recordID)
+ .done(function () {
+ if (self.model.isNew(recordID)) {
+ self._abandonRecord(recordID);
+ }
+ // TODO this will tell the renderer to rerender the widget that
+ // asked for the discard but will unfortunately lose the click
+ // made on another row if any
+ self._confirmChange(self.handle, [ev.data.fieldName], ev)
+ .always(ev.data.onSuccess);
+ })
+ .fail(ev.data.onFailure);
+ },
/**
* Forces to save directly the changes if the controller is in readonly,
* because in that case the changes come from widgets that are editable even
@@ -470,6 +520,14 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
FieldManagerMixin._onFieldChanged.apply(this, arguments);
},
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} ev.data.action the function to execute in the mutex
+ */
+ _onMutexify: function (ev) {
+ this.mutex.exec(ev.data.action);
+ },
/**
* When a reload event triggers up, we need to reload the full view.
* For example, after a form view dialog saved some data.
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 5e5b36472f5..742e59a61c5 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -27,7 +27,6 @@ odoo.define('web.BasicModel', function (require) {
* fieldsInfo: {Object},
* getContext: {function},
* getDomain: {function},
- * getEvalContext: {function},
* getFieldNames: {function},
* groupedBy: {string[]},
* id: {integer},
@@ -228,7 +227,7 @@ var BasicModel = AbstractModel.extend({
options = options || {};
var element = this.localData[id];
var isNew = this.isNew(id);
- var rollback = options.rollback || isNew;
+ var rollback = 'rollback' in options ? options.rollback : isNew;
this._visitChildren(element, function (elem) {
if (rollback && elem._savePoint) {
if (elem._savePoint instanceof Array) {
@@ -284,8 +283,6 @@ var BasicModel = AbstractModel.extend({
* @param {boolean} [options.env=false] if true, will only return res_id
* (if record) or res_ids (if list)
* @param {boolean} [options.raw=false] if true, will not follow relations
- * @param {boolean} [options.noUnsetNumeric=false] if true, will set numeric
- * values to 0 if not set
* @returns {Object}
*/
get: function (id, options) {
@@ -308,7 +305,6 @@ var BasicModel = AbstractModel.extend({
return env;
}
-
if (element.type === 'record') {
var data = _.extend({}, element.data, element._changes);
@@ -321,13 +317,6 @@ var BasicModel = AbstractModel.extend({
if (!field) {
continue;
}
- if (options.noUnsetNumeric) {
- if (field.type === 'float' ||
- field.type === 'integer' ||
- field.type === 'monetary') {
- data[fieldName] = data[fieldName] || 0;
- }
- }
// get relational datapoint
if (field.type === 'many2one') {
@@ -356,11 +345,11 @@ var BasicModel = AbstractModel.extend({
count: element.count,
data: data,
domain: element.domain.slice(0),
+ evalModifiers: element.evalModifiers,
fields: element.fields,
fieldsInfo: element.fieldsInfo,
getContext: element.getContext,
getDomain: element.getDomain,
- getEvalContext: element.getEvalContext,
getFieldNames: element.getFieldNames,
id: element.id,
limit: element.limit,
@@ -410,7 +399,6 @@ var BasicModel = AbstractModel.extend({
fields: element.fields,
getContext: element.getContext,
getDomain: element.getDomain,
- getEvalContext: element.getEvalContext,
getFieldNames: element.getFieldNames,
groupedBy: element.groupedBy,
id: element.id,
@@ -615,10 +603,12 @@ var BasicModel = AbstractModel.extend({
*
* @param {string} record_id
* @param {Object} changes a map field => new value
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {string[]} list of changed fields
*/
- notifyChanges: function (record_id, changes) {
- return this.mutex.exec(this._applyChange.bind(this, record_id, changes));
+ notifyChanges: function (record_id, changes, viewType) {
+ return this.mutex.exec(this._applyChange.bind(this, record_id, changes, viewType));
},
/**
* Reload all data for a given resource
@@ -644,7 +634,7 @@ var BasicModel = AbstractModel.extend({
return this._makeDefaultRecord(element.model, params);
}
if (!options.keepChanges) {
- this.discardChanges(id);
+ this.discardChanges(id, {rollback: false});
}
}
@@ -788,7 +778,7 @@ var BasicModel = AbstractModel.extend({
_.extend(record.data, _changes);
def.resolve(changedFields);
}
- });
+ }).fail(def.reject.bind(def));
} else {
def.resolve(changedFields);
}
@@ -902,9 +892,11 @@ var BasicModel = AbstractModel.extend({
*
* @param {string} recordID
* @param {Object} changes
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred}
*/
- _applyChange: function (recordID, changes) {
+ _applyChange: function (recordID, changes, viewType) {
var self = this;
var record = this.localData[recordID];
var field;
@@ -916,7 +908,7 @@ var BasicModel = AbstractModel.extend({
for (var fieldName in changes) {
field = record.fields[fieldName];
if (field.type === 'one2many' || field.type === 'many2many') {
- defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName]));
+ defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], viewType));
} else if (field.type === 'many2one') {
defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName]));
} else {
@@ -937,7 +929,7 @@ var BasicModel = AbstractModel.extend({
}
var onchangeDef;
if (onChangeFields.length) {
- onchangeDef = self._performOnChange(record, onChangeFields).then(function (result) {
+ onchangeDef = self._performOnChange(record, onChangeFields, viewType).then(function (result) {
delete record._warning;
return _.keys(changes).concat(Object.keys(result && result.value || {}));
});
@@ -976,6 +968,17 @@ var BasicModel = AbstractModel.extend({
record._changes[fieldName] = false;
return $.when();
}
+
+ // here, we check that the many2one really changed. If the res_id is the
+ // same, we do not need to do any extra work. It can happen when the
+ // user edited a manyone (with the small form view button) with an
+ // onchange. In that case, the onchange is triggered, but the actual
+ // value did not change.
+ var relatedID = (record._changes && record._changes[fieldName]) || record.data[fieldName];
+ var relatedRecord = this.localData[relatedID];
+ if (relatedRecord && (data.id === this.localData[relatedID].res_id)) {
+ return $.when();
+ }
var rel_data = _.pick(data, 'id', 'display_name');
var def;
if (rel_data.display_name === undefined) {
@@ -1093,14 +1096,8 @@ var BasicModel = AbstractModel.extend({
});
defs.push(def);
}
- } else if (field.type === 'date') {
- // process data: convert into a moment instance
- record._changes[name] = fieldUtils.parse.date(val);
- } else if (field.type === 'datetime') {
- // process datetime: convert into a moment instance
- record._changes[name] = fieldUtils.parse.datetime(val);
} else {
- record._changes[name] = val;
+ record._changes[name] = self._parseServerValue(field, val);
}
});
return $.when.apply($, defs);
@@ -1115,13 +1112,23 @@ var BasicModel = AbstractModel.extend({
* @param {string} fieldName
* @param {Object} command A command object. It should have a 'operation'
* key. For example, it looks like {operation: ADD, id: 'partner_1'}
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred}
*/
- _applyX2ManyChange: function (record, fieldName, command) {
+ _applyX2ManyChange: function (record, fieldName, command, viewType) {
+ if (command.operation === 'TRIGGER_ONCHANGE') {
+ // the purpose of this operation is to trigger an onchange RPC, so
+ // there is no need to apply any change on the record (the changes
+ // have probably been already applied and saved, usecase: many2many
+ // edition in a dialog)
+ return $.when();
+ }
+
var self = this;
var list = this.localData[record._changes[fieldName] || record.data[fieldName]];
var field = record.fields[fieldName];
- var fieldInfo = record.fieldsInfo[record.viewType][fieldName];
+ var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName];
var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode];
var rec;
var defs = [];
@@ -1144,6 +1151,7 @@ var BasicModel = AbstractModel.extend({
newRecord.fieldsInfo = list.fieldsInfo;
newRecord.viewType = list.viewType;
list._changes.push(newRecord.id);
+ this._sortList(list);
break;
case 'ADD_M2M':
// force to use link command instead of create command
@@ -1163,6 +1171,7 @@ var BasicModel = AbstractModel.extend({
});
list_records[d.id] = rec;
list._changes.push(rec.id);
+ self._sortList(list);
});
// read list's records as we only have their ids and optionally their display_name
// (we can't use function readUngroupedList because those records are only in the
@@ -1243,22 +1252,65 @@ var BasicModel = AbstractModel.extend({
* @see _performOnChange
*
* @param {Object} record resource object of type 'record'
- * @returns {Object} an onchange spec
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @returns {Object|false} an onchange spec, or false if no onchange should
+ * be applied
*/
- _buildOnchangeSpecs: function (record) {
- // TODO: replace this function by some generic tree function in utils
+ _buildOnchangeSpecs: function (record, viewType) {
+ var hasOnchange = false;
var specs = {};
- _.each(record.getFieldNames(), function (name) {
+ var fieldsInfo = record.fieldsInfo[viewType || record.viewType];
+
+ _.each(Object.keys(fieldsInfo), function (name) {
var field = record.fields[name];
- var fieldInfo = record.fieldsInfo[record.viewType][name];
+ var fieldInfo = fieldsInfo[name];
specs[name] = (field.onChange) || "";
+ if (field.onChange) {
+ hasOnchange = true;
+ }
_.each(fieldInfo.views, function (view) {
_.each(view.fieldsInfo[view.type], function (field, subname) {
- specs[name + '.' + subname] = (view.fields[subname].onChange) || "";
+ var onChange = view.fields[subname].onChange;
+ specs[name + '.' + subname] = onChange || "";
+ if (onChange) {
+ hasOnchange = true;
+ }
});
});
});
- return specs;
+ return hasOnchange ? specs : false;
+ },
+ /**
+ * Evaluate modifiers
+ *
+ * @private
+ * @param {Object} element a valid element object, which will serve as eval
+ * context.
+ * @param {Object} modifiers
+ * @returns {Object}
+ */
+ _evalModifiers: function (element, modifiers) {
+ var result = {};
+ var self = this;
+ var evalContext;
+ function evalModifier (mod) {
+ if (mod === undefined || mod === false || mod === true) {
+ return !!mod;
+ }
+ evalContext = evalContext || self._getEvalContext(element);
+ return new Domain(mod, evalContext).compute(evalContext);
+ }
+ if ('invisible' in modifiers) {
+ result.invisible = evalModifier(modifiers.invisible);
+ }
+ if ('readonly' in modifiers) {
+ result.readonly = evalModifier(modifiers.readonly);
+ }
+ if ('required' in modifiers) {
+ result.required = evalModifier(modifiers.required);
+ }
+ return result;
},
/**
* Fetch all name_gets for the many2ones in a group
@@ -1899,8 +1951,8 @@ var BasicModel = AbstractModel.extend({
// generate update commands for records that have been
// updated (it may happen with editable lists)
_.each(relData, function (relRecord) {
- if (!_.isEmpty(relRecord._changes)) {
- var changes = self._generateChanges(relRecord);
+ var changes = self._generateChanges(relRecord);
+ if (!_.isEmpty(changes)) {
var command = x2ManyCommands.update(relRecord.res_id, changes);
commands[fieldName].push(command);
}
@@ -1918,8 +1970,8 @@ var BasicModel = AbstractModel.extend({
if (_.contains(keptIds, relIds[i])) {
// this is an id that already existed
relRecord = _.findWhere(relData, {res_id: relIds[i]});
- if (!_.isEmpty(relRecord._changes)) {
- changes = this._generateChanges(relRecord);
+ changes = this._generateChanges(relRecord);
+ if (!_.isEmpty(changes)) {
command = x2ManyCommands.update(relRecord.res_id, changes);
didChange = true;
} else {
@@ -2046,19 +2098,24 @@ var BasicModel = AbstractModel.extend({
* @returns {Object}
*/
_getEvalContext: function (element) {
- var evalContext = this.get(element.id, {raw: true, noUnsetNumeric: true}).data;
- evalContext.active_model = element.model;
- evalContext.id = evalContext.id || false;
- evalContext.active_id = evalContext.id;
- evalContext.active_ids = evalContext.id ? [evalContext.id] : [];
+ var evalContext = element.type === 'record' ? this._getRecordEvalContext(element) : {};
+
if (element.parentID) {
- var parent = this.get(element.parentID, {raw: true});
- if (parent.type === 'list' && this.localData[element.parentID].parentID) {
- parent = this.get(this.localData[element.parentID].parentID, {raw: true});
+ var parent = this.localData[element.parentID];
+ if (parent.type === 'list' && parent.parentID) {
+ parent = this.localData[parent.parentID];
+ }
+ if (parent.type === 'record') {
+ evalContext.parent = this._getRecordEvalContext(parent);
}
- _.extend(evalContext, {parent: parent.data});
}
- return _.extend({}, session.user_context, element.context, evalContext);
+ return _.extend({
+ active_id: evalContext.id || false,
+ active_ids: evalContext.id ? [evalContext.id] : [],
+ active_model: element.model,
+ current_date: moment().format('YYYY-MM-DD'),
+ id: evalContext.id || false,
+ }, session.user_context, element.context, evalContext);
},
/**
* Returns the list of field names of the given element according to its
@@ -2071,6 +2128,67 @@ var BasicModel = AbstractModel.extend({
var fieldsInfo = element.fieldsInfo;
return Object.keys(fieldsInfo && fieldsInfo[element.viewType] || {});
},
+ /**
+ * Evaluate the record evaluation context. This method is supposed to be
+ * called by _getEvalContext. It basically only generates a dictionary of
+ * current values for the record, with commands for x2manys fields.
+ *
+ * @param {Object} record an element of type 'record'
+ * @returns Object
+ */
+ _getRecordEvalContext: function (record) {
+ var self = this;
+ var relDataPoint;
+ var context = _.extend({}, record.data, record._changes);
+ for (var fieldName in context) {
+ var field = record.fields[fieldName];
+ if (context[fieldName] === null) {
+ context[fieldName] = false;
+ }
+ if (!field) {
+ continue;
+ }
+ if (field.type === 'float' ||
+ field.type === 'integer' ||
+ field.type === 'monetary') {
+ context[fieldName] = context[fieldName] || 0;
+ continue;
+ }
+ if (field.type === 'date' || field.type === 'datetime') {
+ if (context[fieldName]) {
+ context[fieldName] = JSON.parse(JSON.stringify(context[fieldName]));
+ }
+ continue;
+ }
+ if (field.type === 'many2one') {
+ relDataPoint = this.localData[context[fieldName]];
+ context[fieldName] = relDataPoint ? relDataPoint.res_id : false;
+ continue;
+ }
+ if (field.type === 'one2many' || field.type === 'many2many') {
+ relDataPoint = this.localData[context[fieldName]];
+ var relData = relDataPoint._changes || relDataPoint.data;
+ var ids = _.map(relData, function (id) {
+ return self.localData[id].res_id;
+ });
+
+ ids.toJSON = function () {
+ return _.map(relData, function (id) {
+ var resID = self.localData[id].res_id;
+ if (typeof resID === 'string') {
+ var changes = self._generateChanges(self.localData[id]);
+ return x2ManyCommands.create(changes);
+ } else {
+ return x2ManyCommands.link_to(resID);
+ }
+ });
+ };
+ context[fieldName] = ids;
+ }
+
+ }
+ return context;
+ },
/**
* Returns true iff value is considered to be set for the given field's type.
*
@@ -2104,11 +2222,17 @@ var BasicModel = AbstractModel.extend({
_isX2ManyValid: function (id) {
var self = this;
var isValid = true;
- var element = this.get(id, {raw: true});
- _.each(element.getFieldNames(), function (fieldName) {
- var field = element.fields[fieldName];
- _.each(element.data, function (rec) {
- if (field.required && !self._isFieldSet(rec.data[fieldName], field.type)) {
+ var element = this.localData[id];
+ _.each(element._changes || element.data, function (recordID) {
+ var recordData = self.get(recordID, {raw: true}).data;
+ var record = self.localData[recordID];
+ _.each(element.getFieldNames(), function (fieldName) {
+ var field = element.fields[fieldName];
+ var fieldInfo = element.fieldsInfo[element.viewType][fieldName];
+ var rawModifiers = JSON.parse(fieldInfo.modifiers || "{}");
+ var modifiers = self._evalModifiers(record, rawModifiers);
+ var required = 'required' in modifiers ? modifiers.required : field.required;
+ if (required && !self._isFieldSet(recordData[fieldName], field.type)) {
isValid = false;
}
});
@@ -2196,9 +2320,9 @@ var BasicModel = AbstractModel.extend({
viewType: params.viewType,
};
+ dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint);
dataPoint.getContext = this._getContext.bind(this, dataPoint);
dataPoint.getDomain = this._getDomain.bind(this, dataPoint);
- dataPoint.getEvalContext = this._getEvalContext.bind(this, dataPoint);
dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint);
this.localData[dataPoint.id] = dataPoint;
@@ -2300,60 +2424,48 @@ var BasicModel = AbstractModel.extend({
record._changes[name] = x2manyList.id;
var many2ones = {};
var r;
+ var isCommandList = result[name].length && _.isArray(result[name][0]);
+ if (!isCommandList) {
+ result[name] = [[6, false, result[name]]];
+ }
_.each(result[name], function (value) {
- if (_.isArray(value)) {
- // value is a command
- if (value[0] === 0) {
- // CREATE
- r = self._makeDataPoint({
- modelName: x2manyList.model,
- context: x2manyList.context,
- fieldsInfo: fieldsInfo,
- fields: fields,
- viewType: viewType,
- });
- x2manyList._changes = x2manyList._changes || [];
- x2manyList._changes.push(r.id);
-
- // this is necessary so the fields are initialized
- _.each(r.getFieldNames(), function (fieldName) {
- r.data[fieldName] = null;
- });
-
- r._changes = _.defaults(value[2], r.data);
- for (var name in r._changes) {
- var isFieldInView = name in r.fields;
- if (isFieldInView && r.fields[name].type === 'many2one') {
- var rec = self._makeDataPoint({
- context: r.context,
- modelName: r.fields[name].relation,
- data: {id: r._changes[name]}
- });
- r._changes[name] = rec.id;
- many2ones[name] = true;
- }
- }
- }
- if (value[0] === 6) {
- // REPLACE_WITH
- x2manyList.res_ids = value[2];
- x2manyList.count = x2manyList.res_ids.length;
- defs.push(self._readUngroupedList(x2manyList));
- }
- } else {
- // value is an id
+ // value is a command
+ if (value[0] === 0) {
+ // CREATE
r = self._makeDataPoint({
modelName: x2manyList.model,
context: x2manyList.context,
fieldsInfo: fieldsInfo,
fields: fields,
- res_id: value,
viewType: viewType,
});
- if (!x2manyList._changes) {
- x2manyList._changes = [];
- }
+ x2manyList._changes = x2manyList._changes || [];
x2manyList._changes.push(r.id);
+
+ // this is necessary so the fields are initialized
+ _.each(r.getFieldNames(), function (fieldName) {
+ r.data[fieldName] = null;
+ });
+
+ r._changes = _.defaults(value[2], r.data);
+ for (var name in r._changes) {
+ var isFieldInView = name in r.fields;
+ if (isFieldInView && r.fields[name].type === 'many2one') {
+ var rec = self._makeDataPoint({
+ context: r.context,
+ modelName: r.fields[name].relation,
+ data: {id: r._changes[name]}
+ });
+ r._changes[name] = rec.id;
+ many2ones[name] = true;
+ }
+ }
+ }
+ if (value[0] === 6) {
+ // REPLACE_WITH
+ x2manyList.res_ids = value[2];
+ x2manyList.count = x2manyList.res_ids.length;
+ defs.push(self._readUngroupedList(x2manyList));
}
});
@@ -2361,35 +2473,17 @@ var BasicModel = AbstractModel.extend({
_.each(_.keys(many2ones), function (name) {
defs.push(self._fetchNameGets(x2manyList, name));
});
- } else if (field.type === 'date') {
- // process date: convert into a moment instance
- record._changes[name] = fieldUtils.parse.date(result[name], field, {isUTC: true});
- } else if (field.type === 'datetime') {
- // process datetime: convert into a moment instance
- record._changes[name] = fieldUtils.parse.datetime(result[name], field, {isUTC: true});
} else {
- record._changes[name] = result[name];
+ record._changes[name] = self._parseServerValue(field, result[name]);
}
});
return $.when.apply($, defs)
.then(function () {
- var shouldApplyOnchange = false;
- var field;
- for (var field_name in record.data) {
- field = record.fields[field_name];
- if (field.onChange) {
- shouldApplyOnchange = true;
+ return self._performOnChange(record, fields_key).then(function () {
+ if (record._warning) {
+ return $.Deferred().reject();
}
- }
- if (shouldApplyOnchange) {
- return self._performOnChange(record, fields_key).then(function () {
- if (record._warning) {
- return $.Deferred().reject();
- }
- });
- } else {
- return $.when();
- }
+ });
})
.then(function () {
return self._fetchRelationalData(record);
@@ -2438,27 +2532,52 @@ var BasicModel = AbstractModel.extend({
// no value for the many2one
record[fieldName] = false;
}
- } else if (field.type === 'date') {
- // process data: convert into a moment instance
- record[fieldName] = fieldUtils.parse.date(val, field, {isUTC: true});
- } else if (field.type === 'datetime') {
- // process datetime: convert into a moment instance
- record[fieldName] = fieldUtils.parse.datetime(val, field, {isUTC: true});
+ } else {
+ record[fieldName] = self._parseServerValue(field, val);
}
});
},
+ /**
+ * Processes date(time) and selection field values sent by the server.
+ * Converts data(time) values to moment instances.
+ * Converts false values of selection fields to 0 if 0 is a valid key,
+ * because the server doesn't make a distinction between false and 0, and
+ * always sends false when value is 0.
+ *
+ * @param {Object} field the field description
+ * @param {*} value
+ * @returns {*} the processed value
+ */
+ _parseServerValue: function (field, value) {
+ if (field.type === 'date' || field.type === 'datetime') {
+ // process date(time): convert into a moment instance
+ value = fieldUtils.parse[field.type](value, field, {isUTC: true});
+ } else if (field.type === 'selection' && value === false) {
+ // process selection: convert false to 0, if 0 is a valid key
+ var hasKey0 = _.find(field.selection, function (option) {
+ return option[0] === 0;
+ });
+ value = hasKey0 ? 0 : value;
+ }
+ return value;
+ },
/**
* This method is quite important: it is supposed to perform the /onchange
* rpc and apply the result.
*
* @param {Object} record
* @param {string[]} fields changed fields
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
* @returns {Deferred} The returned deferred can fail, in which case the
* fail value will be the warning message received from the server
*/
- _performOnChange: function (record, fields) {
+ _performOnChange: function (record, fields, viewType) {
var self = this;
- var onchange_spec = this._buildOnchangeSpecs(record);
+ var onchangeSpec = this._buildOnchangeSpecs(record, viewType);
+ if (!onchangeSpec) {
+ return $.when();
+ }
var idList = record.data.id ? [record.data.id] : [];
var options = {
full: true,
@@ -2474,7 +2593,7 @@ var BasicModel = AbstractModel.extend({
return self._rpc({
model: record.model,
method: 'onchange',
- args: [idList, currentData, fields, onchange_spec, context],
+ args: [idList, currentData, fields, onchangeSpec, context],
})
.then(function (result) {
if (!record._changes) {
@@ -2580,7 +2699,7 @@ var BasicModel = AbstractModel.extend({
var choice = _.find(list.fields[rawGroupBy].selection, function (c) {
return c[0] === value;
});
- value = choice[1];
+ value = choice ? choice[1] : false;
}
var newGroup = self._makeDataPoint({
modelName: list.model,
@@ -2595,6 +2714,7 @@ var BasicModel = AbstractModel.extend({
orderedBy: list.orderedBy,
limit: list.limit,
openGroupByDefault: list.openGroupByDefault,
+ parentID: list.id,
type: 'list',
viewType: list.viewType,
});
@@ -2787,9 +2907,12 @@ var BasicModel = AbstractModel.extend({
if (list.orderedBy.length) {
// sort records according to ordered_by[0]
var order = list.orderedBy[0];
- list.data.sort(function (r1, r2) {
- var data1 = self.localData[r1].data;
- var data2 = self.localData[r2].data;
+ var data = list._changes || list.data;
+ data.sort(function (id1, id2) {
+ var r1 = self.localData[id1];
+ var r2 = self.localData[id2];
+ var data1 = r1._changes || r1.data;
+ var data2 = r2._changes || r2.data;
if (data1[order.name] < data2[order.name]) {
return order.asc ? -1 : 1;
}
diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js
index 907ea7ddc07..8bbf9ae45a4 100644
--- a/addons/web/static/src/js/views/basic/basic_renderer.js
+++ b/addons/web/static/src/js/views/basic/basic_renderer.js
@@ -91,7 +91,7 @@ var BasicRenderer = AbstractRenderer.extend({
var record = state.id === id ? state : _.findWhere(state.data, {id: id});
if (!record) {
- return this._render();
+ return this._render().then(_.constant([]));
}
var defs = [];
@@ -268,22 +268,6 @@ var BasicRenderer = AbstractRenderer.extend({
var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record);
return widget.isValid() && (widget.isSet() || !modifiers.required);
},
- /**
- * Updates the modifiers evaluation associated to a given modifiers data and
- * a given record. This only updates the modifiers values. To see associated
- * DOM updates: @see _updateAllModifiers @see _applyModifiers.
- *
- * @private
- * @param {Object} modifiersData
- * @param {Object} record
- */
- _computeModifiers: function (modifiersData, record) {
- var evalContext = record.evalContext;
- modifiersData.evaluatedModifiers[record.id]
- = _.mapObject(modifiersData.modifiers, function (modifier) {
- return new Domain(modifier, evalContext).compute(evalContext);
- });
- },
/**
* Destroys a given widget associated to the given record and removes it
* from internal referencing.
@@ -342,7 +326,6 @@ var BasicRenderer = AbstractRenderer.extend({
* record. This allows nodes that will produce an AbstractField instance
* to have their modifiers registered before this field creation as we
* need the readonly modifier to be able to instantiate the AbstractField.
- * (@see _computeModifiers).
*
* - On additional registrations, if the node was already registered but the
* record is different, we evaluate the modifiers for this record and
@@ -399,7 +382,7 @@ var BasicRenderer = AbstractRenderer.extend({
// Evaluate if necessary
if (!modifiersData.evaluatedModifiers[record.id]) {
- this._computeModifiers(modifiersData, record);
+ modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
}
// Element might not be given yet (a second call to the function can
@@ -569,7 +552,6 @@ var BasicRenderer = AbstractRenderer.extend({
* 2) Updates the rendering of the view elements associated to the given
* record to match the new modifiers.
*
- * @see _computeModifiers
* @see _applyModifiers
*
* @private
@@ -582,7 +564,7 @@ var BasicRenderer = AbstractRenderer.extend({
var defs = [];
this.defs = defs; // Potentially filled by widget rerendering
_.each(this.allModifiersData, function (modifiersData) {
- self._computeModifiers(modifiersData, record);
+ modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers);
self._applyModifiers(modifiersData, record);
});
delete this.defs;
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 e38b7b4584e..736416dea27 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -67,21 +67,36 @@ var BasicView = AbstractView.extend({
var viewType = this.viewType;
var viewFields = Object.keys(record.fieldsInfo[viewType]);
var fieldNames = _.difference(viewFields, Object.keys(record.data));
+ var fieldsInfo = record.fieldsInfo[viewType];
+
// Suppose that in a form view, there is an x2many list view with
- // an x2many field F of the related record, and that F is also
- // displayed in the x2many form view (e.g. as a list or a kanban).
- // In this case, F is represented in record.data
- // (as it is known by the x2many list view), but its related fields
- // (those displayed in the list or kanban) still need to be fetched.
+ // a field F, and that F is also displayed in the x2many form view.
+ // In this case, F is represented in record.data (as it is known by
+ // the x2many list view), but the loaded information may not suffice
+ // in the form view (e.g. if field is a many2many list in the form
+ // view, or if it is displayed by a widget requiring specialData).
// So when this happens, F is added to the list of fieldNames to fetch.
_.each(viewFields, function (name) {
if (!_.contains(fieldNames, name)) {
var fieldType = record.fields[name].type;
+ var fieldInfo = fieldsInfo[name];
+
+ // SpecialData case: field requires specialData that haven't
+ // been fetched yet.
+ if (fieldInfo.Widget) {
+ var requiresSpecialData = fieldInfo.Widget.prototype.specialData;
+ if (requiresSpecialData && !(name in record.specialData)) {
+ fieldNames.push(name);
+ return;
+ }
+ }
+
+ // X2Many case: field is an x2many displayed as a list or
+ // kanban view, but the related fields haven't been loaded yet.
if ((fieldType === 'one2many' || fieldType === 'many2many')) {
if (!('fieldsInfo' in record.data[name])) {
fieldNames.push(name);
} else {
- var fieldInfo = record.fieldsInfo[viewType][name];
var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {};
var fieldViewTypes = Object.keys(fieldViews);
var recordViewTypes = Object.keys(record.data[name].fieldsInfo);
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 64eae1f746a..939e44446a8 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -16,7 +16,7 @@ var scales = [
];
function dateToServer (date) {
- return date.clone().utc().format('YYYY-MM-DD HH:mm:ss');
+ return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss');
}
return AbstractModel.extend({
diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js
index ceedbb2df6e..9578e568558 100644
--- a/addons/web/static/src/js/views/field_manager_mixin.js
+++ b/addons/web/static/src/js/views/field_manager_mixin.js
@@ -43,7 +43,7 @@ var FieldManagerMixin = {
*/
_applyChanges: function (dataPointID, changes, event) {
var self = this;
- return this.model.notifyChanges(dataPointID, changes)
+ return this.model.notifyChanges(dataPointID, changes, event.data.viewType)
.then(function (result) {
if (event.data.force_save) {
return self.model.save(dataPointID).then(function () {
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 4239edbb199..ce449f2dcc9 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -15,7 +15,6 @@ var FormController = BasicController.extend({
open_one2many_record: '_onOpenOne2ManyRecord',
bounce_edit: '_onBounceEdit',
button_clicked: '_onButtonClicked',
- discard_x2m_changes: '_onDiscardX2MChanges',
open_record: '_onOpenRecord',
toggle_column_order: '_onToggleColumnOrder',
}),
@@ -51,9 +50,11 @@ var FormController = BasicController.extend({
* This method switches the form view in edit mode, with a new record.
*
* @todo make record creation a basic controller feature
+ * @param {string} [parentID] if given, the parentID will be used as parent
+ * for the new record.
* @returns {Deferred}
*/
- createRecord: function () {
+ createRecord: function (parentID) {
var self = this;
var record = this.model.get(this.handle, {raw: true});
return this.model.load({
@@ -61,6 +62,7 @@ var FormController = BasicController.extend({
fields: record.fields,
fieldsInfo: record.fieldsInfo,
modelName: this.modelName,
+ parentID: parentID,
res_ids: record.res_ids,
type: 'record',
viewType: 'form',
@@ -230,6 +232,26 @@ var FormController = BasicController.extend({
return this.renderer.confirmChange(record, record.id, [fieldsChanged]);
}
},
+ /**
+ * Override to disable buttons in the renderer.
+ *
+ * @override
+ * @private
+ */
+ _disableButtons: function () {
+ this._super.apply(this, arguments);
+ this.renderer.disableButtons();
+ },
+ /**
+ * Override to enable buttons in the renderer.
+ *
+ * @override
+ * @private
+ */
+ _enableButtons: function () {
+ this._super.apply(this, arguments);
+ this.renderer.enableButtons();
+ },
/**
* Hook method, called when record(s) has been deleted.
*
@@ -325,6 +347,8 @@ var FormController = BasicController.extend({
var self = this;
var def;
+ this._disableButtons();
+
var attrs = event.data.attrs;
if (attrs.confirm) {
var d = $.Deferred();
@@ -349,15 +373,14 @@ var FormController = BasicController.extend({
return self._callButtonAction(attrs, record);
});
}
- def.then(function () {
- self.reload();
- });
if (event.data.show_wow) {
def.then(function () {
self.show_wow();
});
}
+
+ def.always(this._enableButtons.bind(this));
},
/**
* Called when the user wants to create a new record -> @see createRecord
@@ -376,32 +399,6 @@ var FormController = BasicController.extend({
_onDiscard: function () {
this.discardChanges();
},
- /**
- * Called when a x2m asks to discard the changes made to one of its row.
- *
- * @todo find a better way to handle this... this could also be used outside
- * of form views
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onDiscardX2MChanges: function (ev) {
- var self = this;
- ev.stopPropagation();
- var recordID = ev.data.recordID;
- this.discardChanges(recordID)
- .done(function () {
- if (self.model.isNew(recordID)) {
- self._abandonRecord(recordID);
- }
- // TODO this will tell the renderer to rerender the widget that
- // asked for the discard but will unfortunately lose the click
- // made on another row if any
- self._confirmChange(self.handle, [ev.target.name], ev)
- .always(ev.data.onSuccess);
- })
- .fail(ev.data.onFailure);
- },
/**
* Called when the user clicks on 'Duplicate Record' in the sidebar
*
diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js
index a2c0d2129ab..ddd77ee5dc6 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -84,9 +84,26 @@ var FormRenderer = BasicRenderer.extend({
_.each(resetWidgets, function (widget) {
self._setIDForLabel(widget, self.idsForLabels[widget.name]);
});
+ if (self.$('.o_field_invalid').length) {
+ self.canBeSaved(self.state.id);
+ }
return resetWidgets;
});
},
+ /**
+ * Disable stat buttons so that they can't be clicked anymore
+ *
+ */
+ disableButtons: function() {
+ this.$('.oe_button_box button').attr('disabled', true);
+ },
+ /**
+ * Enable stat buttons so they can be clicked again
+ *
+ */
+ enableButtons: function() {
+ this.$('.oe_button_box button').removeAttr('disabled');
+ },
/**
* returns the active tab pages for each notebook
*
@@ -679,10 +696,12 @@ var FormRenderer = BasicRenderer.extend({
},
});
});
- return $(' ')
+ var $notebook = $(' ')
.data('name', node.attrs.name || '_default_')
- .append($headers)
- .append($pages);
+ .append($headers, $pages);
+ this._registerModifiers(node, this.state, $notebook);
+ this._handleAttributes($notebook, node);
+ return $notebook;
},
/**
* @private
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 54e078ab368..b5c60fe9ab3 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -131,7 +131,8 @@ var KanbanController = BasicController.extend({
_updateButtons: function () {
if (this.$buttons) {
var data = this.model.get(this.handle, {raw: true});
- var createMuted = data.count === 0 && this.createColumnEnabled;
+ var grouped = data.groupedBy.length;
+ var createMuted = grouped && data.data.length === 0 && this.createColumnEnabled;
this.$buttons.find('.o-kanban-button-new')
.toggleClass('btn-primary', !createMuted)
.toggleClass('btn-default', createMuted);
@@ -260,7 +261,7 @@ var KanbanController = BasicController.extend({
this.trigger_up('execute_action', {
action_data: attrs,
model: record.model,
- record_id: record.res_id,
+ res_ids: [record.res_id],
on_closed: function () {
self.model.reload(record.id).then(function (db_id) {
var data = self.model.get(db_id);
@@ -328,6 +329,7 @@ var KanbanController = BasicController.extend({
return self.model
.addRecordToGroup(columnState.id, records[0])
.then(function (db_id) {
+ self._updateEnv();
column.addRecord(self.model.get(db_id), {position: 'before'});
});
}
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 f3d4d5b3d91..c606776ccc0 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -31,12 +31,20 @@ var KanbanModel = BasicModel.extend({
viewType: group.viewType,
});
group.data.unshift(new_record.id);
+ group.res_ids.unshift(resId);
group.count++;
- return this._fetchRecord(new_record)
- // .then(this._fetch_relational_data.bind(this))
- .then(function (result) {
- return result.id;
- });
+
+ // 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++;
+
+ return this._fetchRecord(new_record).then(function (result) {
+ return result.id;
+ });
},
/**
* Creates a new group from a name (performs a name_create).
@@ -78,7 +86,6 @@ var KanbanModel = BasicModel.extend({
// newGroup.is_open = true;
parent.data.push(newGroup.id);
- parent.count++;
return newGroup.id;
});
},
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 21e29780d09..7c83022cd60 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -64,7 +64,10 @@ var ListController = BasicController.extend({
return $.when();
}
}
- return this._super(recordID);
+ var self = this;
+ return this._super(recordID).then(function () {
+ self._updateButtons('readonly');
+ });
},
/**
* Calculate the active domain of the list view. This should be done only
@@ -89,7 +92,8 @@ var ListController = BasicController.extend({
contexts: [userContext].concat(searchData.contexts),
group_by_seq: searchData.groupbys || []
});
- return $.when(self.dataset.domain.concat(results.domain || []));
+ var record = self.model.get(self.handle, {raw: true});
+ return $.when(record.getDomain().concat(results.domain || []));
} else {
return $.Deferred().resolve();
}
@@ -190,19 +194,25 @@ var ListController = BasicController.extend({
}
},
/**
- * Add a record to the list
+ * Adds a record to the list.
+ * Disables the buttons to prevent concurrent record creation or edition.
*
* @todo make record creation a basic controller feature
* @private
*/
_addRecord: function () {
var self = this;
- this.model.addDefaultRecord(this.handle, {position: this.editable}).then(function (recordID) {
+ this._disableButtons();
+ return this.renderer.unselectRow().then(function () {
+ return self.model.addDefaultRecord(self.handle, {
+ position: self.editable,
+ });
+ }).then(function (recordID) {
self._toggleNoContentHelper(false);
var state = self.model.get(self.handle);
self.renderer.updateState(state, {});
self.renderer.editRecord(recordID);
- });
+ }).always(this._enableButtons.bind(this));
},
/**
* Archive the current selection
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index a857fee7103..a037c12a96e 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -83,6 +83,26 @@ ListRenderer.include({
}
return this._super(recordID);
},
+ /**
+ * We need to override the confirmChange method from BasicRenderer to
+ * reevaluate the row decorations. Since they depends on the current value
+ * of the row, they might have changed between each edit.
+ *
+ * @override
+ */
+ confirmChange: function (state, id) {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (widgets) {
+ if (widgets.length) {
+ var rowIndex = _.findIndex(state.data, function (r) {
+ return r.id === id;
+ });
+ var $row = self.$('.o_data_row:nth(' + rowIndex + ')');
+ self._setDecorationClasses(state.data[rowIndex], $row);
+ }
+ return widgets;
+ });
+ },
/**
* Edit a given record in the list
*
@@ -113,13 +133,15 @@ ListRenderer.include({
*/
removeLine: function (state, recordID) {
var rowIndex = _.findIndex(this.state.data, {id: recordID});
+ this.state = state;
+ if (rowIndex === -1) {
+ return;
+ }
if (rowIndex === this.currentRow) {
this.currentRow = null;
}
var $row = this.$('.o_data_row:nth(' + rowIndex + ')');
$row.remove();
-
- this.state = state;
},
/**
* Updates the already rendered row associated to the given recordID so that
@@ -202,6 +224,44 @@ ListRenderer.include({
return $.when.apply($, defs);
},
+ /**
+ * This method is called whenever we click/move outside of a row that was
+ * in edit mode. This is the moment we save all accumulated changes on that
+ * row, if needed (@see BasicController.saveRecord).
+ *
+ * Note that we have to disable the focusable elements (inputs, ...) to
+ * prevent subsequent editions. These edits would be lost, because the list
+ * view only saves records when unselecting a row.
+ *
+ * @returns {Deferred} The deferred resolves if the row was unselected (and
+ * possibly removed). If may be rejected, when the row is dirty and the
+ * user refuses to discard its changes.
+ */
+ unselectRow: function () {
+ // Protect against calling this method when no row is selected
+ if (this.currentRow === null) {
+ return $.when();
+ }
+
+ var record = this.state.data[this.currentRow];
+ var recordWidgets = this.allFieldWidgets[record.id];
+ toggleWidgets(true);
+
+ var def = $.Deferred();
+ this.trigger_up('save_line', {
+ recordID: record.id,
+ onSuccess: def.resolve.bind(def),
+ onFailure: def.reject.bind(def),
+ });
+ return def.fail(toggleWidgets.bind(null, false));
+
+ function toggleWidgets(disabled) {
+ _.each(recordWidgets, function (widget) {
+ var $el = widget.getFocusableElement();
+ $el.prop('disabled', disabled);
+ });
+ }
+ },
//--------------------------------------------------------------------------
// Private
@@ -241,7 +301,7 @@ ListRenderer.include({
if (this.currentRow > 0) {
this._selectCell(this.currentRow - 1, this.columns.length - 1);
} else {
- this._unselectRow().then(this.trigger_up.bind(this, 'add_record'));
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record'));
}
},
/**
@@ -251,10 +311,16 @@ ListRenderer.include({
* @private
*/
_moveToNextLine: function () {
+ var record = this.state.data[this.currentRow];
+ var fieldNames = this.canBeSaved(record.id);
+ if (fieldNames.length) {
+ return;
+ }
+
if (this.currentRow < this.state.data.length - 1) {
this._selectCell(this.currentRow + 1, 0);
} else {
- this._unselectRow().then(this.trigger_up.bind(this, 'add_record'));
+ this.unselectRow().then(this.trigger_up.bind(this, 'add_record'));
}
},
/**
@@ -399,7 +465,7 @@ ListRenderer.include({
// To select a row, the currently selected one must be unselected first
var self = this;
- return this._unselectRow().then(function () {
+ return this.unselectRow().then(function () {
// Notify the controller we want to make a record editable
var record = self.state.data[rowIndex];
var def = $.Deferred();
@@ -410,44 +476,6 @@ ListRenderer.include({
return def;
});
},
- /**
- * This method is called whenever we click/move outside of a row that was
- * in edit mode. This is the moment we save all accumulated changes on that
- * row, if needed (@see BasicController.saveRecord).
- *
- * Note that we have to disable the focusable elements (inputs, ...) to
- * prevent subsequent editions. These edits would be lost, because the list
- * view only saves records when unselecting a row.
- *
- * @returns {Deferred} The deferred resolves if the row was unselected (and
- * possibly removed). If may be rejected, when the row is dirty and the
- * user refuses to discard its changes.
- */
- _unselectRow: function () {
- // Protect against calling this method when no row is selected
- if (this.currentRow === null) {
- return $.when();
- }
-
- var record = this.state.data[this.currentRow];
- var recordWidgets = this.allFieldWidgets[record.id];
- toggleWidgets(true);
-
- var def = $.Deferred();
- this.trigger_up('save_line', {
- recordID: record.id,
- onSuccess: def.resolve.bind(def),
- onFailure: def.reject.bind(def),
- });
- return def.fail(toggleWidgets.bind(null, false));
-
- function toggleWidgets(disabled) {
- _.each(recordWidgets, function (widget) {
- var $el = widget.getFocusableElement();
- $el.prop('disabled', disabled);
- });
- }
- },
//--------------------------------------------------------------------------
// Handlers
@@ -469,7 +497,7 @@ ListRenderer.include({
// but we do want to unselect current row
var self = this;
- this._unselectRow().then(function () {
+ this.unselectRow().then(function () {
self.trigger_up('add_record'); // TODO write a test, the deferred was not considered
});
},
@@ -495,7 +523,7 @@ ListRenderer.include({
* We need to manually unselect row, because noone else would do it
*/
_onEmptyRowClick: function () {
- this._unselectRow();
+ this.unselectRow();
},
/**
* Clicking on a footer should unselect (and save) the currently selected
@@ -503,7 +531,7 @@ ListRenderer.include({
* and _onWindowClicked ignore those clicks.
*/
_onFooterClick: function () {
- this._unselectRow();
+ this.unselectRow();
},
/**
* Handles the keyboard navigation according to events triggered by field
@@ -568,6 +596,11 @@ ListRenderer.include({
case 'next_line':
this._moveToNextLine();
break;
+ case 'cancel':
+ this.trigger_up('discard_changes', {
+ recordID: ev.target.dataPointID,
+ });
+ break;
}
},
/**
@@ -644,7 +677,7 @@ ListRenderer.include({
return;
}
- this._unselectRow();
+ this.unselectRow();
},
});
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 21028b99e02..e589c0d430a 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -6,7 +6,6 @@ var config = require('web.config');
var core = require('web.core');
var field_utils = require('web.field_utils');
var Pager = require('web.Pager');
-var session = require('web.session');
var utils = require('web.utils');
var _t = core._t;
@@ -146,32 +145,6 @@ var ListRenderer = BasicRenderer.extend({
};
}
},
- /**
- * Each line can be decorated according to a few simple rules. The arch
- * description of the list may have one of the decoration-X attribute with
- * a domain as value. Then, for each record, we check if the domain matches
- * the record, and add the text-X css class to the element. This method is
- * concerned with the computation of the list of css classes for a given
- * record.
- *
- * @private
- * @param {Object} record a basic model record
- * @returns {string[]} a list of css classes
- */
- _computeDecorationClassNames: function (record) {
- var data = JSON.parse(JSON.stringify(record.data));
- var context = _.extend({}, data, {
- uid: session.uid,
- current_date: moment().format('YYYY-MM-DD')
- // TODO: time, datetime, relativedelta
- });
- return _.chain(this.rowDecorations)
- .pick(function (expr) {
- return py.PY_isTrue(py.evaluate(expr, context));
- }).map(function (expr, decoration) {
- return decoration.replace('decoration', 'text');
- }).value();
- },
/**
* return the number of visible columns. Note that this number depends on
* the state of the renderer. For example, in editable mode, it could be
@@ -201,7 +174,9 @@ var ListRenderer = BasicRenderer.extend({
var field = self.state.fields[column.attrs.name];
var value = aggregateValues[column.attrs.name].value;
var help = aggregateValues[column.attrs.name].help;
- var formattedValue = field_utils.format[field.type](value, field, {});
+ var formattedValue = field_utils.format[field.type](value, field, {
+ escape: true,
+ });
$cell.addClass('o_list_number').attr('title', help).html(formattedValue);
}
return $cell;
@@ -276,7 +251,11 @@ var ListRenderer = BasicRenderer.extend({
var name = node.attrs.name;
var field = this.state.fields[name];
var value = record.data[name];
- var formattedValue = field_utils.format[field.type](value, field, { data: record.data });
+ var formattedValue = field_utils.format[field.type](value, field, {
+ data: record.data,
+ escape: true,
+ isPassword: 'password' in node.attrs,
+ });
return $td.html(formattedValue);
},
/**
@@ -539,17 +518,14 @@ var ListRenderer = BasicRenderer.extend({
return self._renderBodyCell(record, node, index, {mode: 'readonly'});
});
delete this.defs;
- var className = 'o_data_row';
- var decorations = this._computeDecorationClassNames(record);
- if (decorations.length) {
- className += (' ' + decorations.join(' '));
- }
- var $tr = $(' |
', {class: className})
+
+ var $tr = $(' |
', {class: 'o_data_row'})
.data('id', record.id)
.append($cells);
if (this.hasSelectors) {
$tr.prepend(this._renderSelector('td'));
}
+ this._setDecorationClasses(record, $tr);
return $tr;
},
/**
@@ -620,6 +596,24 @@ var ListRenderer = BasicRenderer.extend({
}
return this._super();
},
+ /**
+ * Each line can be decorated according to a few simple rules. The arch
+ * description of the list may have one of the decoration-X attribute with
+ * a domain as value. Then, for each record, we check if the domain matches
+ * the record, and add the text-X css class to the element. This method is
+ * concerned with the computation of the list of css classes for a given
+ * record.
+ *
+ * @private
+ * @param {Object} record a basic model record
+ * @param {jQueryElement} $tr a jquery element (the row to add decoration)
+ */
+ _setDecorationClasses: function (record, $tr) {
+ _.each(this.rowDecorations, function (expr, decoration) {
+ var cssClass = decoration.replace('decoration', 'text');
+ $tr.toggleClass(cssClass, py.PY_isTrue(py.evaluate(expr, record.evalContext)));
+ });
+ },
/**
* Whenever we change the state of the selected rows, we need to call this
* method to keep the this.selection variable in sync, and also to recompute
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index f132704a475..fb175aa1934 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -74,8 +74,9 @@ var PivotController = AbstractController.extend({
//--------------------------------------------------------------------------
/**
- * We save the current measure and group bys, so we can restore the view
- * when we save the current state in the search view.
+ * Returns the current measures and groupbys, so we can restore the view
+ * when we save the current state in the search view, or when we add it to
+ * the dashboard.
*
* @override method from AbstractController
* @returns {Object}
@@ -84,8 +85,8 @@ var PivotController = AbstractController.extend({
var state = this.model.get();
return {
pivot_measures: state.measures,
- pivot_column_groupby: state.data.main_col.groupbys,
- pivot_row_groupby: state.data.main_row.groupbys,
+ pivot_column_groupby: state.colGroupBys,
+ pivot_row_groupby: state.rowGroupBys,
};
},
/**
@@ -126,6 +127,7 @@ var PivotController = AbstractController.extend({
return;
}
framework.blockUI();
+ table.title = this.title;
session.get_file({
url: '/web/pivot/export_xls',
data: {data: JSON.stringify(table)},
@@ -218,8 +220,9 @@ var PivotController = AbstractController.extend({
this.update({}, {reload: false});
}
if ($target.hasClass('o_pivot_expand_button')) {
- this.model.expandAll();
- this.update({reload: false});
+ this.model
+ .expandAll()
+ .then(this.update.bind(this, {}, {reload: false}));
}
if ($target.parents('.o_pivot_measures_list').length) {
var parent = $target.parent();
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index ab4a2eb0314..6bbadd56ed8 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -127,20 +127,21 @@ var PivotModel = AbstractModel.extend({
});
},
/**
+ * Export the current pivot view in a simple JS object.
+ *
* @returns {Object}
*/
exportData: function () {
- var record = this.data;
- var measureNbr = record.measures.length;
- var headers = this.renderer._computeHeaders();
+ var measureNbr = this.data.measures.length;
+ var headers = this._computeHeaders();
var measureRow = measureNbr > 1 ? _.last(headers) : [];
- var rows = this.renderer._computeRows();
+ var rows = this._computeRows();
var i, j, value;
headers[0].splice(0,1);
// process measureRow
for (i = 0; i < measureRow.length; i++) {
- measureRow[i].measure = this.measures[measureRow[i].measure].string;
+ measureRow[i].measure = this.fields[measureRow[i].measure].string;
}
// process all rows
for (i =0, j, value; i < rows.length; i++) {
@@ -148,7 +149,7 @@ var PivotModel = AbstractModel.extend({
value = rows[i].values[j];
rows[i].values[j] = {
is_bold: (i === 0) ||
- ((record.data.main_col.width > 1) &&
+ ((this.data.main_col.width > 1) &&
(j >= rows[i].values.length - measureNbr)),
value: (value === undefined) ? "" : value,
};
@@ -159,7 +160,6 @@ var PivotModel = AbstractModel.extend({
measure_row: measureRow,
rows: rows,
nbr_measures: measureNbr,
- title: this.title,
};
},
/**
@@ -221,17 +221,14 @@ var PivotModel = AbstractModel.extend({
load: function (params) {
this.initialDomain = params.domain;
this.initialRowGroupBys = params.rowGroupBys;
- this.initialColGroupBys = params.colGroupBys;
- this.initialMeasures = params.measures;
this.fields = params.fields;
this.modelName = params.modelName;
- var groupedBy = params.groupedBy.length ? params.groupedBy : this.initialRowGroupBys;
this.data = {
domain: params.domain,
context: params.context,
- groupedBy: groupedBy,
- colGroupBys: params.colGroupBys || this.initialColGroupBys,
- measures: this.initialMeasures,
+ groupedBy: params.rowGroupBys,
+ colGroupBys: params.colGroupBys,
+ measures: params.measures,
sorted_column: {},
};
return this._loadData();
@@ -514,7 +511,7 @@ var PivotModel = AbstractModel.extend({
_loadData: function () {
var self = this;
var groupBys = [];
- var rowGroupBys = this.data.groupedBy;
+ var rowGroupBys = this.data.groupedBy.length ? this.data.groupedBy : this.initialRowGroupBys;
var colGroupBys = this.data.colGroupBys;
var fields = [].concat(rowGroupBys, colGroupBys, this.data.measures);
@@ -591,7 +588,7 @@ var PivotModel = AbstractModel.extend({
});
var index = 0;
- var rowGroupBys = this.data.groupedBy;
+ var rowGroupBys = this.data.groupedBy.length ? this.data.groupedBy : this.initialRowGroupBys;
var colGroupBys = this.data.colGroupBys;
var datapt, row, col, attrs, cell_value;
var main_row_header, main_col_header;
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index 83bd32de830..d90c0d21780 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -87,9 +87,9 @@ var PivotView = AbstractView.extend({
activeMeasures.push('__count');
}
- this.loadParams.measures = activeMeasures;
- this.loadParams.colGroupBys = colGroupBys;
- this.loadParams.rowGroupBys = rowGroupBys;
+ this.loadParams.measures = params.context.pivot_measures || activeMeasures;
+ this.loadParams.colGroupBys = params.context.pivot_column_groupby || colGroupBys;
+ this.loadParams.rowGroupBys = params.context.pivot_row_groupby || rowGroupBys;
this.loadParams.fields = fields;
this.controllerParams.title = params.title || arch.attrs.string || _t("Untitled");
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index 7e20cd74d04..b84d60d5b7c 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -133,7 +133,7 @@ var FormViewDialog = ViewDialog.extend({
text: _t("Save & New"),
classes: "btn-primary",
click: function () {
- this._save().then(self.form_view.createRecord.bind(self.form_view));
+ this._save().then(self.form_view.createRecord.bind(self.form_view, self.parentID));
},
});
}
@@ -375,11 +375,9 @@ var SelectCreateDialog = ViewDialog.extend({
});
},
_process_search_data: function (domains, contexts, groupbys) {
- var user_context = this.getSession().user_context;
- contexts = [user_context].concat(contexts);
var results = pyeval.eval_domains_and_contexts({
- domains: domains || [],
- contexts: contexts || [],
+ domains: [this.domain].concat(domains),
+ contexts: [this.context].concat(contexts),
group_by_seq: groupbys || []
});
return {
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index 13ab8fd0892..c11551ca2cc 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -295,7 +295,10 @@ var DataExport = Dialog.extend({
}
var self = this;
- return this.exports.read_slice(['name'], {
+ return this._rpc({
+ model: 'ir.exports',
+ method: 'search_read',
+ fields: ['name'],
domain: [['resource', '=', this.record.model]]
}).then(function (export_list) {
if (!export_list.length) {
diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less
index 3b191a0837a..34ce34e6ad8 100644
--- a/addons/web/static/src/less/form_view.less
+++ b/addons/web/static/src/less/form_view.less
@@ -435,6 +435,7 @@
.o_field_widget {
text-align: right;
+ .o-justify-content(flex-end);
width: 100%;
}
}
diff --git a/addons/web/static/src/less/layout_background.less b/addons/web/static/src/less/layout_background.less
index 7ff90a68921..6750705c35b 100644
--- a/addons/web/static/src/less/layout_background.less
+++ b/addons/web/static/src/less/layout_background.less
@@ -34,7 +34,7 @@
background-size: cover;
background-position: bottom center;
background-repeat: no-repeat;
- min-height: 920px;
+ min-height: 900px;
thead tr th, h2 {
color: @odoo-brand-primary;
}
diff --git a/addons/web/static/src/less/list_view.less b/addons/web/static/src/less/list_view.less
index eaf0f1bfba8..a09b8e7ac0c 100644
--- a/addons/web/static/src/less/list_view.less
+++ b/addons/web/static/src/less/list_view.less
@@ -91,17 +91,16 @@
}
// Values styling
- .o_data_cell {
- &.o_list_number {
- text-align: right;
- white-space: nowrap;
- }
- &.o_list_text {
- white-space: pre-wrap;
- }
- &.o_invisible_modifier {
- display: initial!important; // Override invisible to keep the cells aligned
- }
+ .o_list_number {
+ text-align: right;
+ white-space: nowrap;
+ }
+ .o_list_text {
+ white-space: pre-wrap;
+ }
+
+ .o_data_cell.o_invisible_modifier {
+ display: initial!important; // Override invisible to keep the cells aligned
}
//--------------------------------------------------------------------------
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 50e59713562..394abe18c7c 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -604,7 +604,7 @@
@@ -730,7 +730,8 @@
@@ -1180,11 +1181,12 @@
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index 7b9fbd061eb..07d13fbe93a 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -1,6 +1,7 @@
odoo.define('web.basic_fields_tests', function (require) {
"use strict";
+var basicFields = require('web.basic_fields');
var concurrency = require('web.concurrency');
var core = require('web.core');
var FormView = require('web.FormView');
@@ -10,6 +11,7 @@ var session = require('web.session');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
+var DebouncedField = basicFields.DebouncedField;
var _t = core._t;
QUnit.module('fields', {}, function () {
@@ -120,6 +122,66 @@ QUnit.module('basic_fields', {
}
}, function () {
+ QUnit.module('DebouncedField');
+
+ QUnit.test('debounced fields do not trigger call _setValue once destroyed', function (assert) {
+ var done = assert.async();
+ assert.expect(4);
+
+ var def = $.Deferred();
+ var _doAction = DebouncedField.prototype._doAction;
+ DebouncedField.prototype._doAction = function () {
+ _doAction.apply(this, arguments);
+ def.resolve();
+ };
+ var _setValue = DebouncedField.prototype._setValue;
+ DebouncedField.prototype._setValue = function () {
+ assert.step('_setValue');
+ _setValue.apply(this, arguments);
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ fieldDebounce: 3,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ // change the value
+ form.$('input').val('new value').trigger('input');
+ assert.verifySteps([], "_setValue shouldn't have been called yet");
+
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+ assert.verifySteps(['_setValue'], "_setValue should have been called once");
+
+ // destroy the form view
+ def = $.Deferred();
+ form.destroy();
+
+ // wait for the debounced callback to be called
+ def.then(function () {
+ assert.verifySteps(['_setValue'],
+ "_setValue should not have been called after widget destruction");
+
+ DebouncedField.prototype._doAction = _doAction;
+ DebouncedField.prototype._setValue = _setValue;
+ done();
+ });
+
+ });
+
QUnit.module('FieldBoolean');
QUnit.test('boolean field in form view', function (assert) {
@@ -710,6 +772,33 @@ QUnit.module('basic_fields', {
_t.database.multi_lang = multiLang;
});
+ QUnit.test('char field does not allow html injections', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('input').val('').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_field_widget').text(), '',
+ 'the value should have been properly escaped');
+
+ form.destroy();
+ });
QUnit.module('UrlWidget');
@@ -1742,6 +1831,37 @@ QUnit.module('basic_fields', {
form.destroy();
});
+ QUnit.test('monetary field with monetary field given in options', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.qux.type = "monetary";
+ this.data.partner.fields.company_currency_id = {
+ string: "Company Currency", type: "many2one", relation: "currency",
+ };
+ this.data.partner.records[4].company_currency_id = 2;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ res_id: 5,
+ session: {
+ currencies: _.indexBy(this.data.currency.records, 'id'),
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_monetary').html(), "9.10 €",
+ "field monetary should be formatted with correct currency");
+
+ form.destroy();
+ });
+
QUnit.test('should keep the focus when being edited in x2many lists', function (assert) {
assert.expect(6);
@@ -2166,6 +2286,42 @@ QUnit.module('basic_fields', {
list.destroy();
});
+ QUnit.test('phone field does not allow html injections', function (assert) {
+ assert.expect(1);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ config: {
+ device: {
+ size_class: 0,
+ SIZES: { XS: 0, SM: 1, MD: 2, LG: 3 },
+ }
+ },
+ });
+
+ var val = '';
+ form.$('input').val(val).trigger('input');
+
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+ assert.strictEqual(form.$('.o_field_widget').text().split('\u00AD').join(''), val,
+ "value should have been correctly escaped");
+
+ form.destroy();
+ });
QUnit.module('PriorityWidget');
diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js
index bfd60000035..e00d45ef91f 100644
--- a/addons/web/static/tests/fields/field_utils_tests.js
+++ b/addons/web/static/tests/fields/field_utils_tests.js
@@ -101,7 +101,7 @@ QUnit.test('format one2many', function(assert) {
});
QUnit.test('parse float', function(assert) {
- assert.expect(6);
+ assert.expect(7);
assert.strictEqual(fieldUtils.parse.float(""), 0);
assert.strictEqual(fieldUtils.parse.float("0"), 0);
@@ -109,6 +109,16 @@ QUnit.test('parse float', function(assert) {
assert.strictEqual(fieldUtils.parse.float("-100.00"), -100);
assert.strictEqual(fieldUtils.parse.float("1,000.00"), 1000);
assert.strictEqual(fieldUtils.parse.float("1,000,000.00"), 1000000);
+
+ var originalParameters = $.extend(true, {}, core._t.database.parameters);
+ _.extend(core._t.database.parameters, {
+ grouping: [3, 0],
+ decimal_point: ',',
+ thousands_sep: '.'
+ });
+ assert.strictEqual(fieldUtils.parse.float('1.234,567'), 1234.567);
+
+ core._t.database.parameters = originalParameters;
});
QUnit.test('parse monetary', function(assert) {
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 44b41219d52..73aa28730e2 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1,8 +1,10 @@
odoo.define('web.relational_fields_tests', function (require) {
"use strict";
+var concurrency = require('web.concurrency');
var FormView = require('web.FormView');
var ListView = require('web.ListView');
+var relationalFields = require('web.relational_fields');
var testUtils = require('web.test_utils');
var createView = testUtils.createView;
@@ -20,7 +22,7 @@ QUnit.module('relational_fields', {
int_field: {string: "int_field", type: "integer", sortable: true},
qux: {string: "Qux", type: "float", digits: [16,1] },
p: {string: "one2many field", type: "one2many", relation: 'partner', relation_field: 'trululu'},
- turtles: {string: "one2many turtle field", type: "one2many", relation: 'turtle'},
+ turtles: {string: "one2many turtle field", type: "one2many", relation: 'turtle', relation_field: 'turtle_trululu'},
trululu: {string: "Trululu", type: "many2one", relation: 'partner'},
timmy: { string: "pokemon", type: "many2many", relation: 'partner_type'},
product_id: {string: "Product", type: "many2one", relation: 'product'},
@@ -125,7 +127,7 @@ QUnit.module('relational_fields', {
},
user: {
fields: {
- name: {string: "Name", type: "char"}
+ name: {string: "Name", type: "char"},
},
records: [{
id: 17,
@@ -197,6 +199,56 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('onchanges on many2ones trigger when editing record in form view', function (assert) {
+ assert.expect(9);
+
+ this.data.partner.onchanges.user_id = function () {};
+ this.data.user.fields.other_field = {string: "Other Field", type: "char"};
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ archs: {
+ 'user,false,form': '',
+ },
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'get_formview_id') {
+ return $.when(false);
+ }
+ if (args.method === 'onchange') {
+ assert.strictEqual(args.args[1].user_id, 17,
+ "onchange is triggered with correct user_id");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // open the many2one in form view and change something
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_external_button').click();
+ $('.modal-body input[name="other_field"]').val('wood').trigger('input');
+
+ // save the modal and make sure an onchange is triggered
+ $('.modal .modal-footer .btn-primary').first().click();
+ assert.verifySteps(['read', 'get_formview_id', 'read', 'write', 'onchange', 'read']);
+
+ // save the main record, and check that no extra rpcs are done (record
+ // is not dirty, only a related record was modified)
+ form.$buttons.find('.o_form_button_save').click();
+ assert.verifySteps(['read', 'get_formview_id', 'read', 'write', 'onchange', 'read']);
+ form.destroy();
+ });
+
+
QUnit.test('many2one readonly fields with option "no_open"', function (assert) {
assert.expect(1);
@@ -579,6 +631,191 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('pressing enter in a m2o in an editable list', function (assert) {
+ assert.expect(9);
+ var done = assert.async();
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ });
+
+ list.$('td.o_data_cell:first').click();
+ assert.strictEqual(list.$('.o_selected_row').length, 1,
+ "should have a row in edit mode");
+
+ // we now write 'a' and press enter to check that the selection is
+ // working, and prevent the navigation
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ concurrency.delay(0).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ var $dropdown = $input.autocomplete('widget');
+ assert.ok($dropdown.is(':visible'), "autocomplete dropdown should be visible");
+
+ // we now trigger ENTER to select first choice
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ assert.strictEqual($input[0], document.activeElement,
+ "input should still be focused");
+
+ // we now trigger again ENTER to make sure we can move to next line
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+
+ assert.notOk(document.contains($input[0]),
+ "input should no longer be in dom");
+ assert.ok(list.$('tr.o_data_row:eq(1)').hasClass('o_selected_row'),
+ "second row should now be selected");
+
+ // we now write again 'a' in the cell to select xpad. We will now
+ // test with the tab key
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ return concurrency.delay(0);
+ }).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ var $dropdown = $input.autocomplete('widget');
+ assert.ok($dropdown.is(':visible'), "autocomplete dropdown should be visible");
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.TAB,
+ keyCode: $.ui.keyCode.TAB,
+ }));
+ assert.strictEqual($input[0], document.activeElement,
+ "input should still be focused");
+
+ // we now trigger again ENTER to make sure we can move to next line
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.TAB,
+ keyCode: $.ui.keyCode.TAB,
+ }));
+
+ assert.notOk(document.contains($input[0]),
+ "input should no longer be in dom");
+ assert.ok(list.$('tr.o_data_row:eq(2)').hasClass('o_selected_row'),
+ "third row should now be selected");
+ list.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+
+ QUnit.test('many2one in editable list + onchange, with enter [REQUIRE FOCUS]', function (assert) {
+ assert.expect(6);
+ var done = assert.async();
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges.product_id = function (obj) {
+ obj.int_field = obj.product_id || 0;
+ };
+
+ var def = $.Deferred();
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return def.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ list.$('td.o_data_cell:first').click();
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ concurrency.delay(0).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ def.resolve();
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ assert.strictEqual($('div.modal').length, 0, "should not have any modal in DOM");
+ assert.verifySteps(['name_search', 'onchange', 'write', 'read']);
+ list.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+
+ QUnit.test('many2one in editable list + onchange, with enter, part 2 [REQUIRE FOCUS]', function (assert) {
+ // this is the same test as the previous one, but the onchange is just
+ // resolved slightly later
+ assert.expect(6);
+ var done = assert.async();
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ this.data.partner.onchanges.product_id = function (obj) {
+ obj.int_field = obj.product_id || 0;
+ };
+
+ var def = $.Deferred();
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ if (args.method) {
+ assert.step(args.method);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return def.then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ list.$('td.o_data_cell:first').click();
+ list.$('td.o_data_cell input:first').val('a').trigger('input');
+ concurrency.delay(0).then(function () {
+ var $input = list.$('td.o_data_cell input:first');
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keyup', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ $input.trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ def.resolve();
+ assert.strictEqual($('div.modal').length, 0, "should not have any modal in DOM");
+ assert.verifySteps(['name_search', 'onchange', 'write', 'read']);
+ list.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ done();
+ });
+ });
+
QUnit.module('FieldOne2Many');
QUnit.test('one2many basic properties', function (assert) {
@@ -1336,12 +1573,14 @@ QUnit.module('relational_fields', {
QUnit.test('one2many list (editable): edition, part 4', function (assert) {
assert.expect(3);
+ var i = 0;
this.data.turtle.onchanges = {
turtle_trululu: function (obj) {
- if (obj.turtle_trululu) {
+ if (i) {
obj.turtle_description = "Some Description";
}
+ i++;
},
};
@@ -1481,6 +1720,44 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('pressing enter in a o2m with a required empty m2o', function (assert) {
+ assert.expect(4);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // edit mode, then click on Add an item, then click elsewhere
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_foo"]').trigger($.Event('keydown', {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }));
+ assert.ok(form.$('input[name="turtle_foo"]').hasClass('o_field_invalid'),
+ "input should be marked invalid");
+ assert.verifySteps(['read', 'default_get']);
+ form.destroy();
+ });
+
QUnit.test('editing a o2m, with required field and onchange', function (assert) {
assert.expect(12);
@@ -1537,6 +1814,74 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('editable o2m, pressing ESC discard current changes', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "there should be one data row");
+
+ form.$('input[name="turtle_foo"]').trigger({type: 'keydown', which: $.ui.keyCode.ESCAPE});
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "data row should have been discarded");
+ assert.verifySteps(['read', 'default_get']);
+ form.destroy();
+ });
+
+ QUnit.test('editable o2m with required field, pressing ESC discard current changes', function (assert) {
+ assert.expect(5);
+
+ this.data.turtle.fields.turtle_foo.required = true;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 2,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "there should be one data row");
+
+ form.$('input[name="turtle_foo"]').trigger({type: 'keydown', which: $.ui.keyCode.ESCAPE});
+ assert.strictEqual(form.$('tr.o_data_row').length, 0,
+ "data row should have been discarded");
+ assert.verifySteps(['read', 'default_get']);
+ form.destroy();
+ });
+
QUnit.test('onchange in a one2many', function (assert) {
assert.expect(1);
@@ -2036,6 +2381,45 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many field with context', function (assert) {
+ assert.expect(2);
+
+ var counter = 0;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'default_get') {
+ var expected = counter === 0 ?
+ [[4, 2, false]] :
+ [[4, 2, false], [0, false, {display_name: false, turtle_foo: 'hammer'}]];
+ assert.deepEqual(args.kwargs.context.turtles, expected,
+ "should have properly evaluated turtles key in context");
+ counter++;
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_foo"]').val('hammer').trigger('input');
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.destroy();
+ });
+
QUnit.test('one2many list edition, some basic functionality', function (assert) {
assert.expect(3);
@@ -2334,25 +2718,26 @@ QUnit.module('relational_fields', {
});
QUnit.test('parent data is properly sent on an onchange rpc, new record', function (assert) {
- assert.expect(1);
+ assert.expect(6);
- this.data.partner.onchanges = {bar: function () {}};
+ this.data.turtle.onchanges = {turtle_bar: function () {}};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '',
mockRPC: function (route, args) {
- if (args.method === 'onchange') {
+ assert.step(args.method);
+ if (args.method === 'onchange' && args.model === 'turtle') {
var fieldValues = args.args[1];
- assert.strictEqual(fieldValues.trululu.foo, "My little Foo Value",
+ assert.strictEqual(fieldValues.turtle_trululu.foo, "My little Foo Value",
"should have properly sent the parent foo value");
}
return this._super.apply(this, arguments);
@@ -2361,6 +2746,7 @@ QUnit.module('relational_fields', {
form.$buttons.find('.o_form_button_edit').click();
form.$('tbody td.o_field_x2many_list_row_add a').click();
+ assert.verifySteps(['default_get', 'onchange', 'default_get', 'onchange']);
form.destroy();
});
@@ -2417,7 +2803,7 @@ QUnit.module('relational_fields', {
res_id: 1,
intercepts: {
execute_action: function (event) {
- assert.strictEqual(event.data.record_id, 2,
+ assert.deepEqual(event.data.res_ids, [2],
'should call with correct id');
assert.strictEqual(event.data.model, 'partner',
'should call with correct model');
@@ -2461,7 +2847,7 @@ QUnit.module('relational_fields', {
res_id: 1,
intercepts: {
execute_action: function (event) {
- assert.strictEqual(event.data.record_id, 2,
+ assert.deepEqual(event.data.res_ids, [2],
'should call with correct id');
assert.strictEqual(event.data.model, 'partner',
'should call with correct model');
@@ -2478,7 +2864,7 @@ QUnit.module('relational_fields', {
form.destroy();
});
- QUnit.test('one2many kanban with edit type action and domain widget (widget wich use SpecialData)', function (assert) {
+ QUnit.test('one2many kanban with edit type action and domain widget (widget using SpecialData)', function (assert) {
assert.expect(1);
this.data.turtle.fields.model_name = {string: "Domain Condition Model", type: "char"};
@@ -2500,6 +2886,8 @@ QUnit.module('relational_fields', {
'' +
' ' +
' ' +
+ // field without Widget in the list
+ ' ' +
'' +
'' +
'' +
@@ -2507,6 +2895,7 @@ QUnit.module('relational_fields', {
'' +
'' +
@@ -2714,6 +3103,63 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many with x2many in form view (but not in list view)', function (assert) {
+ assert.expect(1);
+
+ // avoid error when saving the edited related record (because the
+ // related x2m field is unknown in the inline list view)
+ // also ensure that the changes are correctly saved
+
+ this.data.turtle.fields.o2m = {string: "o2m", type: "one2many", relation: 'user'};
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ archs: {
+ "turtle,false,form": '',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles, [[1, 2, {
+ partner_ids: [[6, false, [2, 4, 1]]],
+ }]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_data_row:first').click(); // edit first record
+
+ var $input = $('.modal .o_field_many2manytags input');
+ $input.click(); // opens the dropdown
+ $input.autocomplete('widget').find('li').click(); // add 'first record'
+
+ // add a many2many tag and save
+ $('.modal .o_field_x2many_list_row_add a').click();
+ $('.modal .o_field_widget[name=name]').val('test').trigger('input');
+ $('.modal .modal-footer .btn-primary').click(); // save
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.destroy();
+ });
+
QUnit.test('one2many (who contains display_name) with tree view and without form view', function (assert) {
assert.expect(1);
@@ -3103,6 +3549,201 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('one2many list editable: \'required\' modifiers is properly working', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.int_field = obj.turtles.length;
+ },
+ };
+
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should start with value 10");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should still be 10 (no onchange, because line is not valid)");
+
+ // fill turtle_foo field
+ form.$('.o_field_widget[name="turtle_foo"]').val("some text").trigger('input');
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
+ "int_field should be 1 (onchange triggered, because line is now valid)");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable: \'required\' modifiers is properly working, part 2', function (assert) {
+ assert.expect(3);
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.int_field = obj.turtles.length;
+ },
+ };
+
+ this.data.partner.records[0].turtles = [];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should start with value 10");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "10",
+ "int_field should still be 10 (no onchange, because line is not valid)");
+
+ // fill turtle_int field
+ form.$('.o_field_widget[name="turtle_int"]').val("1").trigger('input');
+
+ assert.strictEqual(form.$('.o_field_widget[name="int_field"]').val(), "1",
+ "int_field should be 1 (onchange triggered, because line is now valid)");
+
+ form.destroy();
+ });
+
+ QUnit.test('one2many list editable: add new line before onchange returns', function (assert) {
+ // If the user adds a new row (with a required field with onchange), selects
+ // a value for that field, then adds another row before the onchange returns,
+ // the editable list must wait for the onchange to return before trying to
+ // unselect the first row, otherwise it will be detected as invalid.
+ assert.expect(7);
+
+ this.data.turtle.onchanges = {
+ turtle_trululu: function () {},
+ };
+
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // add a first line but hold the onchange back
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should have created the first row immediately");
+ def = $.Deferred();
+ form.$('.o_field_many2one input').click();
+ form.$('.o_field_many2one input').autocomplete('widget').find('a').first().click();
+
+ // try to add a second line and check that it is correctly waiting
+ // for the onchange to return
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal').length, 0, "no modal should be displayed");
+ assert.strictEqual($('.o_field_invalid').length, 0,
+ "no field should be marked as invalid");
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "should wait for the onchange to create the second row");
+ assert.ok(form.$('.o_data_row').hasClass('o_selected_row'),
+ "first row should still be in edition");
+
+ // resolve the onchange def
+ def.resolve();
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "second row should now have been created");
+ assert.notOk(form.$('.o_data_row:first').hasClass('o_selected_row'),
+ "first row should no more be in edition");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable list: multiple clicks on Add an item do not create invalid rows', function (assert) {
+ assert.expect(3);
+
+ this.data.turtle.onchanges = {
+ turtle_trululu: function () {},
+ };
+
+ var def;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // click twice to add a new line
+ def = $.Deferred();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 0,
+ "no row should have been created yet (waiting for the onchange)");
+
+ // resolve the onchange def
+ def.resolve();
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "only one row should have been created");
+ assert.ok(form.$('.o_data_row:first').hasClass('o_selected_row'),
+ "the created row should be in edition");
+
+ form.destroy();
+ });
+
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
@@ -3579,6 +4220,31 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('many2many list: list of id as default value', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.fields.turtles.default = [2, 3];
+ this.data.partner.fields.turtles.type = "many2many";
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ });
+
+ assert.strictEqual(form.$('td.o_data_cell').text(), "blipkawa",
+ "should have loaded default data");
+
+ form.destroy();
+ });
+
QUnit.test('many2many list with x2many: add a record', function (assert) {
assert.expect(18);
@@ -3652,6 +4318,91 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('many2many with a domain', function (assert) {
+ // The domain specified on the field should not be replaced by the potential
+ // domain the user writes in the dialog, they should rather be concatenated
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ res_id: 1,
+ archs: {
+ 'partner_type,false,list': '' +
+ '' +
+ '',
+ 'partner_type,false,search': '' +
+ '' +
+ '',
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal .o_data_row').length, 1,
+ "should contain only one row (gold)");
+
+ $('.modal .o_searchview_input').trigger({type: 'keypress', which: 115}); // s
+ $('.modal .o_searchview_input').trigger({type: 'keydown', which: 13}); // enter
+
+ assert.strictEqual($('.modal .o_data_row').length, 0, "should contain no row");
+
+ form.destroy();
+ });
+
+ QUnit.test('many2many list with onchange and edition of a record', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.fields.turtles.type = "many2many";
+ this.data.partner.onchanges.turtles = function () {};
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'',
+ res_id: 1,
+ archs: {
+ 'turtle,false,form': '',
+ },
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('td.o_data_cell:first').click();
+
+ $('.modal-body input[type="checkbox"]').click();
+ $('.modal .modal-footer .btn-primary').first().click();
+
+ // there is nothing left to save -> should not do a 'write' RPC
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps([
+ 'read', // read initial record (on partner)
+ 'read', // read many2many turtles
+ 'read', // read missing field when opening record in modal form view
+ 'write', // when saving the modal
+ 'onchange', // onchange should be triggered on partner
+ 'read', // reload many2many
+ ]);
+
+ form.destroy();
+ });
+
QUnit.module('FieldStatus');
QUnit.test('static statusbar widget on many2one field', function (assert) {
@@ -3979,6 +4730,65 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('unset selection field with 0 as key', function (assert) {
+ // The server doesn't make a distinction between false value (the field
+ // is unset), and selection 0, as in that case the value it returns is
+ // false. So the client must convert false to value 0 if it exists.
+ assert.expect(2);
+
+ this.data.partner.fields.selection = {
+ type: "selection",
+ selection: [[0, "Value O"], [1, "Value 1"]],
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_field_widget').text(), 'Value O',
+ "the displayed value should be 'Value O'");
+ assert.notOk(form.$('.o_field_widget').hasClass('o_field_empty'),
+ "should not have class o_field_empty");
+
+ form.destroy();
+ });
+
+ QUnit.test('unset selection field with string keys', function (assert) {
+ // The server doesn't make a distinction between false value (the field
+ // is unset), and selection 0, as in that case the value it returns is
+ // false. So the client must convert false to value 0 if it exists. In
+ // this test, it doesn't exist as keys are strings.
+ assert.expect(2);
+
+ this.data.partner.fields.selection = {
+ type: "selection",
+ selection: [['0', "Value O"], ['1', "Value 1"]],
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('.o_field_widget').text(), '',
+ "there should be no displayed value");
+ assert.ok(form.$('.o_field_widget').hasClass('o_field_empty'),
+ "should have class o_field_empty");
+
+ form.destroy();
+ });
+
QUnit.module('FieldMany2ManyTags');
QUnit.test('fieldmany2many tags: rendering and edition', function (assert) {
@@ -4317,7 +5127,6 @@ QUnit.module('relational_fields', {
form.destroy();
});
-
QUnit.test('fieldradio widget on a selection in a new record', function (assert) {
assert.expect(4);
@@ -4345,6 +5154,55 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('fieldradio widget with numerical keys encoded as strings', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.selection = {
+ type: 'selection',
+ selection: [['0', "Red"], ['1', "Black"]],
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.strictEqual(args.args[1].selection, '1',
+ "should write correct value");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ assert.strictEqual(form.$('.o_field_widget').text(), '',
+ "field should be unset");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_radio_input:checked').length, 0,
+ "no value should be checked");
+
+ form.$("input.o_radio_input:nth(1)").click(); // click on 2nd option
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_field_widget').text(), 'Black',
+ "value should be 'Black'");
+
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.strictEqual(form.$('.o_radio_input[data-index=1]:checked').length, 1,
+ "'Black' should be checked");
+
+ form.destroy();
+ });
+
QUnit.module('FieldMany2ManyCheckBoxes');
QUnit.test('widget many2many_checkboxes', function (assert) {
@@ -4377,7 +5235,7 @@ QUnit.module('relational_fields', {
assert.notOk(form.$('div.o_field_widget div.o_checkbox input').prop('disabled'),
"the checkboxes should not be disabled");
- // add a m2m value
+ // add a m2m value by clicking on input
form.$('div.o_field_widget div.o_checkbox input').eq(1).click();
form.$buttons.find('.o_form_button_save').click();
assert.deepEqual(this.data.partner.records[0].timmy, [12, 14],
@@ -4385,9 +5243,9 @@ QUnit.module('relational_fields', {
assert.strictEqual(form.$('input:checked').length, 2,
"both checkboxes should be checked");
- // remove a m2m value
+ // remove a m2m value by clinking on label
form.$buttons.find('.o_form_button_edit').click();
- form.$('div.o_field_widget div.o_checkbox input').eq(0).click();
+ form.$('div.o_field_widget div.o_checkbox + label').eq(0).click();
form.$buttons.find('.o_form_button_save').click();
assert.deepEqual(this.data.partner.records[0].timmy, [14],
"should have removed the first element to the many2many");
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index e7ee9bf07d2..1f4160a3e41 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -598,8 +598,11 @@ var MockServer = Class.extend({
var self = this;
var fields = this.data[model].fields;
var aggregatedFields = _.clone(kwargs.fields);
- var groupByField = kwargs.groupby[0];
- var result = [];
+ var groupBy = [];
+ if (kwargs.groupby.length) {
+ groupBy = kwargs.lazy ? [kwargs.groupby[0]] : kwargs.groupby;
+ }
+ var records = this._getRecords(model, kwargs.domain);
// if no fields have been given, the server picks all stored fields
if (aggregatedFields.length === 0) {
@@ -611,7 +614,6 @@ var MockServer = Class.extend({
return name in self.data[model].fields;
});
-
function aggregateFields(group, records) {
var type;
for (var i = 0; i < aggregatedFields.length; i++) {
@@ -624,77 +626,80 @@ var MockServer = Class.extend({
}
}
}
-
- var records = this._getRecords(model, kwargs.domain);
- if (groupByField) {
- var originalGroupByField = groupByField;
- var groupByFieldDescr = fields[groupByField.split(':')[0]];
- var groupByFunction, formatValue;
- if (groupByFieldDescr.type === 'date') {
-
- var aggregateFunction = groupByField.split(':')[1] || 'month';
-
- groupByField = groupByField.split(':')[0];
- groupByFunction = function (obj) {
- if (aggregateFunction === 'day') {
- return moment(obj[groupByField]).format('YYYY-MM-DD');
- } else {
- return moment(obj[groupByField]).format('MMMM YYYY');
- }
- };
- formatValue = function (val) {
- if (aggregateFunction === 'day') {
- return moment(val).format('YYYY-MM-DD');
- } else {
- return moment(val).format('MMMM YYYY');
- }
- };
+ function formatValue(groupByField, val) {
+ var fieldName = groupByField.split(':')[0];
+ var aggregateFunction = groupByField.split(':')[1] || 'month';
+ if (fields[fieldName].type === 'date') {
+ if (aggregateFunction === 'day') {
+ return moment(val).format('YYYY-MM-DD');
+ } else {
+ return moment(val).format('MMMM YYYY');
+ }
} else {
- groupByFunction = function (obj) {
- return obj[groupByField];
- };
- formatValue = function (val) {
- return val instanceof Array ? val[0] : (val || false);
- };
+ return val instanceof Array ? val[0] : (val || false);
}
- _.each(_.groupBy(records, groupByFunction), function (g, val) {
- val = formatValue(g[0][groupByField]);
- var group = {
- __domain: [[
- groupByField, "=",
- val instanceof Array ? val[0] : (val || false)
- ]].concat(kwargs.domain || []),
- };
- var field = self.data[model].fields[groupByField];
+ }
+ function groupByFunction(record) {
+ var value = '';
+ _.each(groupBy, function (groupByField) {
+ value = (value ? value + ',' : value) + groupByField + '#';
+ var fieldName = groupByField.split(':')[0];
+ if (fields[fieldName].type === 'date') {
+ var aggregateFunction = groupByField.split(':')[1] || 'month';
+ if (aggregateFunction === 'day') {
+ value += moment(record[fieldName]).format('YYYY-MM-DD');
+ } else {
+ value += moment(record[fieldName]).format('MMMM YYYY');
+ }
+ } else {
+ value += record[groupByField];
+ }
+ });
+ return value;
+ }
+
+ if (!groupBy.length) {
+ var group = { __count: records.length };
+ aggregateFields(group, records);
+ return [group];
+ }
+
+ var groups = _.groupBy(records, groupByFunction);
+ var result = _.map(groups, function (group) {
+ var res = {
+ __domain: kwargs.domain || [],
+ };
+ _.each(groupBy, function (groupByField) {
+ var fieldName = groupByField.split(':')[0];
+ var val = formatValue(groupByField, group[0][fieldName]);
+ var field = self.data[model].fields[fieldName];
if (field.type === 'many2one' && !_.isArray(val)) {
var related_record = _.findWhere(self.data[field.relation].records, {
id: val
});
if (related_record) {
- group[originalGroupByField] = [val, related_record.display_name];
+ res[groupByField] = [val, related_record.display_name];
} else {
- group[originalGroupByField] = false;
+ res[groupByField] = false;
}
} else {
- group[originalGroupByField] = val;
+ res[groupByField] = val;
}
-
- // compute count key to match dumb server logic...
- var countKey;
- if (kwargs.lazy) {
- countKey = groupByField + "_count";
- } else {
- countKey = "__count";
- }
- group[countKey] = g.length;
- aggregateFields(group, g);
- result.push($.extend(true, {}, group));
+ res.__domain = [[fieldName, "=", val]].concat(res.__domain);
});
- } else {
- var group = { __count: records.length };
- aggregateFields(group, records);
- result.push(group);
- }
+
+ // compute count key to match dumb server logic...
+ var countKey;
+ if (kwargs.lazy) {
+ countKey = groupBy[0].split(':')[0] + "_count";
+ } else {
+ countKey = "__count";
+ }
+ res[countKey] = group.length;
+ aggregateFields(res, group);
+
+ return res;
+ });
return result;
},
/**
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index 180d38668c7..cc3834e188e 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -707,7 +707,7 @@ QUnit.module('Views', {
});
QUnit.test('internal state of groups remains when reloading', function (assert) {
- assert.expect(9);
+ assert.expect(10);
this.params.fieldNames = ['foo'];
this.params.domain = [];
@@ -739,6 +739,9 @@ QUnit.module('Views', {
var record = model.get(resultID);
assert.strictEqual(record.data.length, 2, "should have 2 groups");
var groupID = record.data[0].id;
+ assert.strictEqual(model.localData[groupID].parentID, resultID,
+ "parentID should be correctly set on groups");
+
model.toggleGroup(groupID);
record = model.get(resultID);
@@ -772,6 +775,43 @@ QUnit.module('Views', {
model.destroy();
});
+ QUnit.test('read group when grouped by a selection field', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.selection = {
+ type: 'selection',
+ selection: [['a', 'A'], ['b', 'B']],
+ };
+ this.data.partner.records[0].selection = 'a';
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+ var params = {
+ modelName: 'partner',
+ fields: this.data.partner.fields,
+ fieldNames: ['foo'],
+ groupedBy: ['selection'],
+ };
+
+ model.load(params).then(function (resultID) {
+ var dataPoint = model.get(resultID);
+ assert.strictEqual(dataPoint.data.length, 2, "should have two groups");
+
+ var groupFalse = _.findWhere(dataPoint.data, {value: false});
+ assert.ok(groupFalse, "should have a group for value false");
+ assert.deepEqual(groupFalse.domain, [['selection', '=', false]],
+ "group's domain should be correct");
+
+ var groupA = _.findWhere(dataPoint.data, {value: 'A'});
+ assert.ok(groupA, "should have a group for value 'a'");
+ assert.deepEqual(groupA.domain, [['selection', '=', 'a']],
+ "group's domain should be correct");
+ });
+ model.destroy();
+ });
+
QUnit.test('create record, then save', function (assert) {
assert.expect(5);
@@ -1371,4 +1411,71 @@ QUnit.module('Views', {
model.destroy();
});
+
+ QUnit.test('changes are discarded when reloading from a new record', function (assert) {
+ // practical use case: click on 'Create' to open a form view in edit
+ // mode (new record), click on 'Discard', then open an existing record
+ assert.expect(2);
+
+ this.data.partner.fields.foo.default = 'default';
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ // load a new record (default_get)
+ var params = _.extend(this.params, {
+ res_id: undefined,
+ type: 'record',
+ fieldNames: ['foo'],
+ });
+ model.load(params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'default',
+ "should be the default value");
+
+ // reload with id 2
+ model.reload(record.id, {currentId: 2}).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.foo, 'gnap',
+ "should be the value of record 2");
+ });
+ });
+
+ model.destroy();
+ });
+
+ QUnit.test('has a proper evaluation context', function (assert) {
+ assert.expect(1);
+
+ this.params.fieldNames = Object.keys(this.data.partner.fields);
+ this.params.res_id = 1;
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ });
+
+ model.load(this.params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.deepEqual(record.evalContext, {
+ active_id: 1,
+ active_ids: [1],
+ active_model: "partner",
+ bar: 1,
+ category: [12],
+ current_date: moment().format('YYYY-MM-DD'),
+ date: "2017-01-25",
+ display_name: "first partner",
+ foo: "blip",
+ id: 1,
+ product_id: 37,
+ product_ids: [],
+ qux: false,
+ total: 0
+ }, "should use the proper eval context");
+ });
+ model.destroy();
+ });
+
});});
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index ec41f21bfc1..45dbc1dd3b3 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -298,6 +298,33 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('properly handle modifiers and attributes on notebook tags', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+
+ assert.ok(form.$('.o_notebook').hasClass('o_invisible_modifier'),
+ 'the notebook should handle modifiers (invisible)');
+ assert.ok(form.$('.o_notebook').hasClass('new_class'),
+ 'the notebook should handle attributes');
+ form.destroy();
+ });
+
QUnit.test('invisible attrs on first notebook page', function (assert) {
assert.expect(6);
@@ -849,8 +876,9 @@ QUnit.module('Views', {
testUtils.intercept(form, 'execute_action', function (event) {
assert.strictEqual(event.data.action_data.name, "post",
"should trigger execute_action with correct method name");
- assert.strictEqual(event.data.record_id, 2, "should have correct id in event data");
+ assert.deepEqual(event.data.res_ids, [2], "should have correct id in event data");
event.data.on_success();
+ event.data.on_closed();
});
rpcCount = 0;
form.$('.o_form_statusbar button.p').click();
@@ -862,7 +890,7 @@ QUnit.module('Views', {
});
form.$('.o_form_statusbar button.s').click();
- assert.strictEqual(rpcCount, 2, "should have done 1 rpcs to reload");
+ assert.strictEqual(rpcCount, 2, "should have done 2 rpcs to reload");
form.destroy();
});
@@ -901,9 +929,10 @@ QUnit.module('Views', {
testUtils.intercept(form, 'execute_action', function (event) {
assert.step('execute_action');
- assert.strictEqual(event.data.record_id, resID,
+ assert.deepEqual(event.data.res_ids, [resID],
"execute action should be done on correct record id");
event.data.on_success();
+ event.data.on_closed();
});
form.$('.o_form_statusbar button.p').click();
@@ -950,9 +979,10 @@ QUnit.module('Views', {
testUtils.intercept(form, 'execute_action', function (event) {
assert.step('execute_action');
- assert.strictEqual(event.data.record_id, resID,
+ assert.deepEqual(event.data.res_ids, [resID],
"execute action should be done on correct record id");
event.data.on_success();
+ event.data.on_closed();
});
form.$('.o_form_statusbar button.p').click();
@@ -1159,6 +1189,38 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('default record with a one2many and an onchange on sub field', function (assert) {
+ assert.expect(4);
+
+ this.data.partner.onchanges.foo = function () {};
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'onchange') {
+ assert.deepEqual(args.args[3], {
+ p: '',
+ 'p.display_name': '',
+ 'p.foo': '1'
+ }, "onchangeSpec should be correct (with sub fields)");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ assert.verifySteps(['default_get', 'onchange']);
+ form.destroy();
+ });
+
QUnit.test('sidebar is hidden when switching to edit mode', function (assert) {
assert.expect(3);
@@ -2248,7 +2310,6 @@ QUnit.module('Views', {
"input should be tagged as invalid");
form.$('input').val("tralala").trigger('input');
- form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('.o_field_invalid').length, 0,
"nothing should be marked as invalid");
@@ -4157,8 +4218,9 @@ QUnit.module('Views', {
});
QUnit.test('default_order on x2many embedded view', function (assert) {
- assert.expect(1);
+ assert.expect(8);
+ this.data.partner.fields.display_name.sortable = true;
this.data.partner.records[0].p = [1, 4];
var form = createView({
@@ -4175,11 +4237,46 @@ QUnit.module('Views', {
'' +
'' +
'',
+ archs: {
+ 'partner,false,form':
+ '',
+ },
res_id: 1,
});
assert.ok(form.$('.o_field_one2many tbody tr:first td:contains(yop)').length,
"record 1 should be first");
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal').length, 1,
+ "FormViewDialog should be opened");
+ $('.modal input[name="foo"]').val('xop').trigger("input");
+ $('.modal .modal-footer button:eq(1)').click(); // Save & new
+ $('.modal input[name="foo"]').val('zop').trigger("input");
+ $('.modal .modal-footer button:first').click(); // Save & close
+
+ // client-side sort
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zop)').length,
+ "record zop should be first");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').length,
+ "record yop should be second");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
+ "record xop should be third");
+
+ // server-side sort
+ form.$buttons.find('.o_form_button_save').click();
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(0) td:contains(zop)').length,
+ "record zop should be first");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(1) td:contains(yop)').length,
+ "record yop should be second");
+ assert.ok(form.$('.o_field_one2many tbody tr:eq(2) td:contains(xop)').length,
+ "record xop should be third");
form.destroy();
});
@@ -4457,5 +4554,305 @@ QUnit.module('Views', {
_t.database.multi_lang = multi_lang;
});
+
+ QUnit.test('buttons are disabled until action is resolved', function (assert) {
+ assert.expect(3);
+
+ var def = $.Deferred();
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ return def.then(function() {
+ event.data.on_success();
+ });
+ }
+ },
+ });
+
+ assert.notOk(form.$('.oe_button_box button').attr('disabled'),
+ "stat buttons should be enabled");
+
+ form.$('.oe_button_box button').click();
+
+ // The unresolved deferred lets us check the state of the buttons
+ assert.ok(form.$('.oe_button_box button').attr('disabled'),
+ "stat buttons should be disabled");
+
+ def.resolve();
+
+ assert.notOk(form.$('.oe_button_box button').attr('disabled'),
+ "stat buttons should be enabled");
+
+ form.destroy();
+ });
+
+ QUnit.test('buttons are disabled until action is resolved (in dialogs)', function (assert) {
+ assert.expect(3);
+
+ var def = $.Deferred();
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ archs: {
+ 'partner,false,form': '',
+ },
+ res_id: 1,
+ intercepts: {
+ execute_action: function (event) {
+ return def.then(function() {
+ event.data.on_success();
+ });
+ }
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ return $.when(false);
+ }
+ return this._super.apply(this, arguments);
+ },
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_external_button').click();
+
+ assert.notOk($('.modal .oe_button_box button').attr('disabled'),
+ "stat buttons should be enabled");
+
+ $('.modal .oe_button_box button').click();
+
+ assert.ok($('.modal .oe_button_box button').attr('disabled'),
+ "stat buttons should be disabled");
+
+ def.resolve();
+
+ assert.notOk($('.modal .oe_button_box button').attr('disabled'),
+ "stat buttons should be enabled");
+
+ form.destroy();
+ });
+
+ QUnit.test('multiple clicks on save should reload only once', function (assert) {
+ assert.expect(4);
+
+ var def = $.Deferred();
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ assert.step(args.method);
+ if (args.method === "write") {
+ return def.then(function () {
+ return result;
+ });
+ } else {
+ return result;
+ }
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('input[name="foo"]').val("test").trigger("input");
+ form.$buttons.find('.o_form_button_save').click();
+ form.$buttons.find('.o_form_button_save').click();
+
+ def.resolve();
+
+ assert.verifySteps([
+ 'read', // initial read to render the view
+ 'write', // write on save
+ 'read' // read on reload
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('form view is not broken if save operation fails', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write' && args.args[1].foo === 'incorrect value') {
+ return $.Deferred().reject();
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('input[name="foo"]').val("incorrect value").trigger("input");
+ form.$buttons.find('.o_form_button_save').click();
+
+ form.$('input[name="foo"]').val("correct value").trigger("input");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.verifySteps([
+ 'read', // initial read to render the view
+ 'write', // write on save (it fails, does not trigger a read)
+ 'write', // write on save (it works)
+ 'read' // read on reload
+ ]);
+
+ form.destroy();
+ });
+
+ QUnit.test('support password attribute', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+
+ assert.strictEqual(form.$('span[name="foo"]').text(), '***',
+ "password should be displayed with stars");
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('input[name="foo"]').val(), '***',
+ "password should be displayed with stars");
+ assert.strictEqual(form.$('input[name="foo"]').prop('type'), 'password',
+ "input should be of type password");
+ form.destroy();
+ });
+
+ QUnit.test('support autocomplete attribute', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.strictEqual(form.$('input[name="foo"]').val(), '***',
+ "password should be displayed with stars");
+ assert.strictEqual(form.$('input[name="display_name"]').prop('autocomplete'), 'coucou',
+ "attribute autocomplete should be set");
+ assert.strictEqual(form.$('input[name="foo"]').prop('autocomplete'), 'new-password',
+ "attribute autocomplete should be set to 'new-password' on password input");
+ form.destroy();
+ });
+
+ QUnit.test('context is correctly passed after save & new in FormViewDialog', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ res_id: 4,
+ data: this.data,
+ arch: '',
+ archs: {
+ 'product,false,form':
+ '',
+ 'product,false,list': ''
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_search') {
+ assert.strictEqual(args.kwargs.context.color, 4,
+ "should use the correct context");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal').length, 1,
+ "One FormViewDialog should be opened");
+ // set a value on the m2o
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ $('.modal .o_field_many2one input').click();
+ $dropdown.find('li:first()').click();
+
+ $('.modal .modal-footer button:eq(1)').click(); // Save & new
+ $('.modal .o_field_many2one input').click();
+ $('.modal .modal-footer button:first').click(); // Save & close
+ form.destroy();
+ });
+
});
});
diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js
index 7c64b7dcf6a..eeb07c17358 100644
--- a/addons/web/static/tests/views/kanban_model_tests.js
+++ b/addons/web/static/tests/views/kanban_model_tests.js
@@ -62,7 +62,7 @@ QUnit.module('Views', {
QUnit.module('KanbanModel');
QUnit.test('load grouped + add a new group', function (assert) {
- assert.expect(20);
+ assert.expect(22);
var calledRoutes = {};
var model = createModel({
@@ -87,7 +87,8 @@ QUnit.module('Views', {
// various checks on the load result
var state = model.get(resultID);
assert.ok(_.isEqual(state.groupedBy, ['product_id']), 'should be grouped by "product_id"');
- assert.strictEqual(state.count, 2, 'should have found 2 groups');
+ assert.strictEqual(state.data.length, 2, 'should have found 2 groups');
+ assert.strictEqual(state.count, 2, 'both groups contain one record');
var xphoneGroup = _.findWhere(state.data, {res_id: 37});
assert.strictEqual(xphoneGroup.model, 'partner', 'group should have correct model');
assert.ok(xphoneGroup, 'should have a group for res_id 37');
@@ -102,7 +103,8 @@ QUnit.module('Views', {
// add a new group
model.createGroup('xpod', resultID);
state = model.get(resultID);
- assert.strictEqual(state.count, 3, 'should now have 3 groups');
+ assert.strictEqual(state.data.length, 3, 'should now have 3 groups');
+ assert.strictEqual(state.count, 2, 'there are still 2 records');
var xpodGroup = _.findWhere(state.data, {value: 'xpod'});
assert.strictEqual(xpodGroup.model, 'partner', 'new group should have correct model');
assert.ok(xpodGroup, 'should have an "xpod" group');
@@ -246,7 +248,48 @@ QUnit.module('Views', {
model.destroy();
done();
});
+ });
+ QUnit.test('add record to group', function (assert) {
+ assert.expect(8);
+
+ var self = this;
+ var model = createModel({
+ Model: KanbanModel,
+ data: this.data,
+ });
+ var params = _.extend(this.params, {
+ groupedBy: ['product_id'],
+ fieldNames: ['foo'],
+ });
+
+ model.load(params).then(function (stateID) {
+ self.data.partner.records.push({id: 3, foo: 'new record', product_id: 37});
+
+ var state = model.get(stateID);
+ assert.deepEqual(state.res_ids, [1, 2],
+ "state should have the correct res_ids");
+ assert.strictEqual(state.count, 2,
+ "state should have the correct count");
+ assert.strictEqual(state.data[0].count, 1,
+ "first group should contain one record");
+
+ return model.addRecordToGroup(state.data[0].id, 3).then(function () {
+ var state = model.get(stateID);
+ assert.deepEqual(state.res_ids, [3, 1, 2],
+ "state should have the correct res_ids");
+ assert.strictEqual(state.count, 3,
+ "state should have the correct count");
+ assert.deepEqual(state.data[0].res_ids, [3, 1],
+ "new record's id should have been added to the res_ids");
+ assert.strictEqual(state.data[0].count, 2,
+ "first group should now contain two records");
+ assert.strictEqual(state.data[0].data[0].data.foo, 'new record',
+ "new record should have been fetched");
+ });
+ });
+
+ model.destroy();
});
});
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index 7f853138265..b2b84ff00d5 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -323,8 +323,9 @@ QUnit.module('Views', {
});
QUnit.test('quick create in grouped mode', function (assert) {
- assert.expect(4);
+ assert.expect(6);
+ var nbRecords = 4;
var kanban = createView({
View: KanbanView,
model: 'partner',
@@ -335,6 +336,12 @@ QUnit.module('Views', {
' ' +
'',
groupBy: ['bar'],
+ intercepts: {
+ env_updated: function (event) {
+ assert.strictEqual(event.data.ids.length, nbRecords,
+ "should update the env with the records ids");
+ },
+ }
});
// click to add an element and cancel the quick creation
@@ -351,6 +358,7 @@ QUnit.module('Views', {
kanban.$('.o_kanban_header .o_kanban_quick_add i').first().click();
$quickCreate = kanban.$('.o_kanban_quick_create');
$quickCreate.find('input').val('new partner');
+ nbRecords = 5;
$quickCreate.find('button.o_kanban_add').click();
assert.strictEqual(this.data.partner.records.length, 5,
@@ -682,7 +690,7 @@ QUnit.module('Views', {
assert.strictEqual(kanban.$('.o_kanban_group:last span:contains(new column)').length, 1,
"the last column should be the newly created one");
kanban.destroy();
- }),
+ });
QUnit.test('delete a column in grouped on m2o', function (assert) {
assert.expect(26);
@@ -758,7 +766,7 @@ QUnit.module('Views', {
assert.strictEqual(kanban.renderer.widgets.length, 2,
"the old widgets should have been correctly deleted");
kanban.destroy();
- }),
+ });
QUnit.test('create a column, delete it and create another one', function (assert) {
assert.expect(5);
@@ -797,7 +805,7 @@ QUnit.module('Views', {
assert.strictEqual(kanban.$('.o_kanban_group:last span:contains(new column 2)').length, 1,
"the last column should be the newly created one");
kanban.destroy();
- }),
+ });
QUnit.test('edit a column in grouped on m2o', function (assert) {
assert.expect(12);
diff --git a/addons/web/static/tests/views/list_benchmarks.js b/addons/web/static/tests/views/list_benchmarks.js
index 7b4e1f7e0c7..4dadbcb47bd 100644
--- a/addons/web/static/tests/views/list_benchmarks.js
+++ b/addons/web/static/tests/views/list_benchmarks.js
@@ -23,7 +23,7 @@ QUnit.module('List View', {
},
};
this.arch = null;
- this.run = function (assert, done) {
+ this.run = function (assert, done, cb) {
var data = this.data;
var arch = this.arch;
new Benchmark.Suite({})
@@ -34,6 +34,9 @@ QUnit.module('List View', {
data: data,
arch: arch,
});
+ if (cb) {
+ cb(list);
+ }
list.destroy();
})
.on('cycle', function(event) {
@@ -82,7 +85,7 @@ QUnit.module('List View', {
this.run(assert, done);
});
- QUnit.test('simple readonly list with 200 rows 4 fields', function (assert) {
+ QUnit.test('editable list with 200 rows 4 fields', function (assert) {
var done = assert.async();
assert.expect(1);
@@ -94,8 +97,16 @@ QUnit.module('List View', {
bar: i % 2 === 0,
});
}
- this.arch = '';
- this.run(assert, done);
+ this.arch = '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+ this.run(assert, done, function (list) {
+ list.$buttons.find('.o_list_button_add').click();
+ list.$buttons.find('.o_list_button_discard').click();
+ });
});
});
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index a74192731ec..0418158465b 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -843,6 +843,30 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('support row decoration (with unset numeric values)', function (assert) {
+ assert.expect(2);
+
+ this.data.foo.records = [];
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '' +
+ '' +
+ '',
+ });
+
+ list.$buttons.find('.o_list_button_add').click();
+
+ assert.strictEqual(list.$('tr.o_data_row.text-danger').length, 0,
+ "the data row should not have .text-danger decoration (int_field is unset)");
+ list.$('input[name="int_field"]').val('-3').trigger('input');
+ assert.strictEqual(list.$('tr.o_data_row.text-danger').length, 1,
+ "the data row should have .text-danger decoration (int_field is negative)");
+ list.destroy();
+ });
+
QUnit.test('support row decoration with date', function (assert) {
assert.expect(3);
@@ -1087,7 +1111,7 @@ QUnit.module('Views', {
},
intercepts: {
execute_action: function (event) {
- assert.strictEqual(event.data.record_id, 1,
+ assert.deepEqual(event.data.res_ids, [1],
'should call with correct id');
assert.strictEqual(event.data.model, 'foo',
'should call with correct model');
@@ -1432,7 +1456,7 @@ QUnit.module('Views', {
});
QUnit.test('edition: create new line, then discard', function (assert) {
- assert.expect(2);
+ assert.expect(8);
var list = createView({
View: ListView,
@@ -1443,10 +1467,22 @@ QUnit.module('Views', {
assert.strictEqual(list.$('tr.o_data_row').length, 4,
"should have 4 records");
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 1,
+ "create button should be visible");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0,
+ "discard button should be hidden");
list.$buttons.find('.o_list_button_add').click();
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 0,
+ "create button should be hidden");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 1,
+ "discard button should be visible");
list.$buttons.find('.o_list_button_discard').click();
assert.strictEqual(list.$('tr.o_data_row').length, 4,
"should still have 4 records");
+ assert.strictEqual(list.$buttons.find('.o_list_button_add:visible').length, 1,
+ "create button should be visible again");
+ assert.strictEqual(list.$buttons.find('.o_list_button_discard:visible').length, 0,
+ "discard button should be hidden again");
list.destroy();
});
@@ -2166,6 +2202,103 @@ QUnit.module('Views', {
list.destroy();
});
+
+ QUnit.test('field values are escaped', function (assert) {
+ assert.expect(1);
+ var value = '';
+
+ this.data.foo.records[0].foo = value;
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '',
+ });
+
+ assert.strictEqual(list.$('.o_data_cell:first').text(), value,
+ "value should have been escaped");
+
+ list.destroy();
+ });
+
+ QUnit.test('pressing ESC discard the current line changes', function (assert) {
+ assert.expect(3);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '',
+ });
+
+ list.$buttons.find('.o_list_button_add').click();
+
+ list.$('input[name="foo"]').trigger({type: 'keydown', which: $.ui.keyCode.ESCAPE});
+ assert.strictEqual(list.$('tr.o_data_row').length, 4,
+ "should have 4 data row in list");
+ assert.strictEqual(list.$('tr.o_data_row.o_selected_row').length, 0,
+ "no rows should be selected");
+ assert.ok(!list.$buttons.find('.o_list_button_save').is(':visible'),
+ "should not have a visible save button");
+ list.destroy();
+ });
+
+ QUnit.test('field with password attribute', function (assert) {
+ assert.expect(2);
+
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '',
+ });
+
+ assert.strictEqual(list.$('td.o_data_cell:eq(0)').text(), '***',
+ "should display string as password");
+ assert.strictEqual(list.$('td.o_data_cell:eq(1)').text(), '****',
+ "should display string as password");
+
+ list.destroy();
+ });
+
+ QUnit.test('multiple clicks on Add do not create invalid rows', function (assert) {
+ assert.expect(2);
+
+ this.data.foo.onchanges = {
+ m2o: function () {},
+ };
+
+ var def = $.Deferred();
+ var list = createView({
+ View: ListView,
+ model: 'foo',
+ data: this.data,
+ arch: '',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'onchange') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ assert.strictEqual(list.$('.o_data_row').length, 4,
+ "should contain 4 records");
+
+ // click on Add twice, and delay the onchange
+ list.$buttons.find('.o_list_button_add').click();
+ list.$buttons.find('.o_list_button_add').click();
+
+ def.resolve();
+
+ assert.strictEqual(list.$('.o_data_row').length, 5,
+ "only one record should have been created");
+
+ list.destroy();
+ });
+
});
});
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index df7d4b9f7d8..d74e29204d7 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -551,11 +551,9 @@ QUnit.module('Views', {
});
QUnit.test('can expand all rows', function (assert) {
- // FIXME: This test depends on an incomplete behavior of the mock server
- // and will need to be updated when read_group can group on
- // multiple field levels.
- assert.expect(4);
+ assert.expect(7);
+ var nbReadGroups = 0;
var pivot = createView({
View: PivotView,
model: "partner",
@@ -564,29 +562,191 @@ QUnit.module('Views', {
'' +
'' +
'',
+ mockRPC: function (route, args) {
+ if (args.method === 'read_group') {
+ nbReadGroups++;
+ }
+ return this._super.apply(this, arguments);
+ },
});
+ assert.strictEqual(nbReadGroups, 2, "should have done 2 read_group RPCS");
assert.strictEqual(pivot.$('td.o_pivot_cell_value').text(), "321220",
- "should have proper values in cells (total, result 1, result 2");
+ "should have proper values in cells (total, result 1, result 2)");
// expand on date:days, product
- pivot.update({groupBy: ['date:days', 'product']});
+ nbReadGroups = 0;
+ pivot.update({groupBy: ['date:days', 'product_id']});
- assert.strictEqual(pivot.$('tbody tr').length, 7,
- "should have 7 rows (total + 2 for each category)");
+ assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS");
+ assert.strictEqual(pivot.$('tbody tr').length, 8,
+ "should have 7 rows (total + 3 for December and 2 for October and April)");
// collapse the last two rows
pivot.$('.o_pivot_header_cell_opened').last().click();
pivot.$('.o_pivot_header_cell_opened').last().click();
- assert.strictEqual(pivot.$('tbody tr').length, 5,
- "should have 5 rows now");
+ assert.strictEqual(pivot.$('tbody tr').length, 6,
+ "should have 6 rows now");
// expand all
- $('.o_pivot_expand_button').click();
+ nbReadGroups = 0;
+ pivot.$buttons.find('.o_pivot_expand_button').click();
- assert.strictEqual(pivot.$('tbody tr').length, 7,
- "should have 7 rows again");
+ assert.strictEqual(nbReadGroups, 3, "should have done 3 read_group RPCS");
+ assert.strictEqual(pivot.$('tbody tr').length, 8,
+ "should have 8 rows again");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('expand all with a delay', function (assert) {
+ assert.expect(3);
+
+ var def;
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'read_group') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // expand on date:days, product
+ pivot.update({groupBy: ['date:days', 'product_id']});
+
+ assert.strictEqual(pivot.$('tbody tr').length, 8,
+ "should have 7 rows (total + 3 for December and 2 for October and April)");
+
+ // collapse the last two rows
+ pivot.$('.o_pivot_header_cell_opened').last().click();
+ pivot.$('.o_pivot_header_cell_opened').last().click();
+
+ assert.strictEqual(pivot.$('tbody tr').length, 6,
+ "should have 6 rows now");
+
+ // expand all
+ def = $.Deferred();
+ pivot.$buttons.find('.o_pivot_expand_button').click();
+ def.resolve();
+
+ assert.strictEqual(pivot.$('tbody tr').length, 8,
+ "should have 8 rows again");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('can download a file', function (assert) {
+ assert.expect(1);
+
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '',
+ session: {
+ get_file: function (args) {
+ assert.strictEqual(args.url, '/web/pivot/export_xls',
+ "should call get_file with correct parameters");
+ args.complete();
+ },
+ },
+ });
+
+ $('.o_pivot_download').click();
+ pivot.destroy();
+ });
+
+ QUnit.test('getContext correctly returns measures and groupbys', function (assert) {
+ assert.expect(3);
+
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '',
+ });
+
+ assert.deepEqual(pivot.getContext(), {
+ pivot_column_groupby: ['date:day'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ }, "context should be correct");
+
+ // expand header on field customer
+ pivot.$('thead .o_pivot_header_cell_closed:nth(1)').click();
+ pivot.$('ul.o_pivot_field_menu > li[data-field="customer"] a').click();
+ assert.deepEqual(pivot.getContext(), {
+ pivot_column_groupby: ['date:day', 'customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: [],
+ }, "context should be correct");
+
+ // expand row on field product_id
+ pivot.$('tbody .o_pivot_header_cell_closed').first().click();
+ pivot.$('ul.o_pivot_field_menu > li[data-field="product_id"] a').click();
+ assert.deepEqual(pivot.getContext(), {
+ pivot_column_groupby: ['date:day', 'customer'],
+ pivot_measures: ['foo'],
+ pivot_row_groupby: ['product_id'],
+ }, "context should be correct");
+
+ pivot.destroy();
+ });
+
+ QUnit.test('correctly uses pivot_ keys from the context', function (assert) {
+ assert.expect(7);
+
+ this.data.partner.fields.amount = {string: "Amount", type: "float"};
+
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '' +
+ '' +
+ '' +
+ '',
+ viewOptions: {
+ context: {
+ pivot_measures: ['foo'],
+ pivot_column_groupby: ['customer'],
+ pivot_row_groupby: ['product_id'],
+ },
+ },
+ });
+
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_opened').length, 1,
+ "column: should have one opened header");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(First)').length, 1,
+ "column: should display one closed header with 'First'");
+ assert.strictEqual(pivot.$('thead .o_pivot_header_cell_closed:contains(Second)').length, 1,
+ "column: should display one closed header with 'Second'");
+
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_opened').length, 1,
+ "row: should have one opened header");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xphone)').length, 1,
+ "row: should display one closed header with 'xphone'");
+ assert.strictEqual(pivot.$('tbody .o_pivot_header_cell_closed:contains(xpad)').length, 1,
+ "row: should display one closed header with 'xpad'");
+
+ assert.strictEqual(pivot.$('tbody tr:first td:nth(3)').text(), '32',
+ "selected measure should be foo, with total 32");
pivot.destroy();
});
diff --git a/addons/web/static/tests/widgets/data_export_tests.js b/addons/web/static/tests/widgets/data_export_tests.js
new file mode 100644
index 00000000000..83169a64e1f
--- /dev/null
+++ b/addons/web/static/tests/widgets/data_export_tests.js
@@ -0,0 +1,132 @@
+odoo.define('web.data_export_tests', function (require) {
+"use strict";
+
+var framework = require('web.framework');
+var ListView = require('web.ListView');
+var testUtils = require('web.test_utils');
+
+var createView = testUtils.createView;
+
+QUnit.module('widgets', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ foo: {string: "Foo", type: "char"},
+ },
+ records: [
+ {
+ id: 1,
+ foo: "yop",
+ },
+ ]
+ },
+ 'ir.exports': {
+ fields: {
+ name: {string: "Name", type: "char"},
+ },
+ records: [],
+ },
+ };
+ }
+}, function () {
+
+ QUnit.module('Data Export');
+
+
+ QUnit.test('exporting all data in list view', function (assert) {
+ assert.expect(7);
+
+ var blockUI = framework.blockUI;
+ var unblockUI = framework.unblockUI;
+ framework.blockUI = function () {
+ assert.step('block UI');
+ };
+ framework.unblockUI = function () {
+ assert.step('unblock UI');
+ };
+
+ var list = createView({
+ View: ListView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ 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,
+ }, {
+ children: false,
+ field_type: 'text',
+ id: "note",
+ relation_field: null,
+ required: false,
+ string: 'Description',
+ value: "note",
+ }
+ ]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ get_file: function (params) {
+ assert.step(params.url);
+ params.complete();
+ },
+ },
+ });
+
+ list.getParent().searchview = {
+ build_search_data: function () {
+ assert.step('build_search_data');
+ return {
+ contexts: [],
+ domains: [],
+ groupbys: [],
+ };
+ },
+ };
+ 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");
+ assert.strictEqual($('span.o_tree_column:contains(Activities)').length, 1,
+ "the Activities field should be in the list of exportable fields");
+
+ // select the field Description, click on add, then export and close
+ $('.modal span:contains(Description)').click();
+ $('.modal .o_add_field').click();
+ $('.modal span:contains(Export To File)').click();
+ $('.modal span:contains(Close)').click();
+
+ list.destroy();
+ framework.blockUI = blockUI;
+ framework.unblockUI = unblockUI;
+ assert.verifySteps([
+ 'build_search_data',
+ 'block UI',
+ '/web/export/csv',
+ 'unblock UI',
+ ]);
+ });
+
+});
+
+});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index dee21aa3129..f15504ea140 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -458,6 +458,7 @@
+
diff --git a/addons/web_editor/static/src/js/backend.js b/addons/web_editor/static/src/js/backend.js
index 00faf80c70f..c12ab41b4bb 100644
--- a/addons/web_editor/static/src/js/backend.js
+++ b/addons/web_editor/static/src/js/backend.js
@@ -3,6 +3,7 @@ odoo.define('web_editor.backend', function (require) {
var AbstractField = require('web.AbstractField');
var basic_fields = require('web.basic_fields');
+var config = require('web.config');
var core = require('web.core');
var session = require('web.session');
var field_registry = require('web.field_registry');
@@ -23,11 +24,27 @@ var QWeb = core.qweb;
*/
var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({
className: 'oe_form_field oe_form_field_html_text',
+ supportedFieldTypes: ['html'],
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
+ /**
+ * Summernote doesn't notify for changes done in code mode. We override
+ * commitChanges to manually switch back to normal mode before committing
+ * changes, so that the widget is aware of the changes done in code mode.
+ *
+ * @override
+ */
+ commitChanges: function () {
+ // switch to WYSIWYG mode if currently in code mode to get all changes
+ if (config.debug && this.mode === 'edit') {
+ var layoutInfo = this.$textarea.data('layoutInfo');
+ $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false);
+ }
+ this._super.apply(this, arguments);
+ },
/**
* @override
*/
@@ -60,7 +77,7 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({
* @returns {Object} the summernote configuration
*/
_getSummernoteConfig: function () {
- var config = {
+ var summernoteConfig = {
focus: false,
height: 180,
toolbar: [
@@ -79,10 +96,10 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend({
lang: "odoo",
onChange: this._doDebouncedAction.bind(this),
};
- if (this.getSession().debug) {
- config.toolbar.splice(7, 0, ['view', ['codeview']]);
+ if (config.debug) {
+ summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]);
}
- return config;
+ return summernoteConfig;
},
/**
* @override
diff --git a/addons/web_editor/static/src/js/rte.summernote.js b/addons/web_editor/static/src/js/rte.summernote.js
index 60c096603d8..4ab1e7bed06 100644
--- a/addons/web_editor/static/src/js/rte.summernote.js
+++ b/addons/web_editor/static/src/js/rte.summernote.js
@@ -511,7 +511,12 @@ function prettify_html(html) {
level--;
}
- result += token.trim().replace(/\s+/, ' ');
+ // don't trim inline content (which could change appearance)
+ if (!inline) {
+ token = token.trim();
+ }
+
+ result += token.replace(/\s+/, ' ');
if (inline_level > level) {
result += '\n';
diff --git a/addons/web_editor/static/src/js/snippets.editor.js b/addons/web_editor/static/src/js/snippets.editor.js
index 20a96a91e84..42f2103f95f 100644
--- a/addons/web_editor/static/src/js/snippets.editor.js
+++ b/addons/web_editor/static/src/js/snippets.editor.js
@@ -107,7 +107,7 @@ data.Class = Widget.extend({
return $from.closest(selector, parentNode);
},
all: function ($from) {
- return $from ? $from.find(selector) : $(selector);
+ return $from ? cssFind($from, selector) : $(selector);
},
is: function ($from) {
return $from.is(selector);
@@ -130,15 +130,31 @@ data.Class = Widget.extend({
});
},
all: is_children ? function ($from) {
- return ($from || self.$editable).find(selector);
+ return cssFind($from || self.$editable, selector);
} : function ($from) {
- return $from ? $from.find(selector) : self.$editable.filter(selector).add(self.$editable.find(selector));
+ $from = $from || self.$editable;
+ return $from.filter(selector).add(cssFind($from, selector));
},
is: function ($from) {
return $from.is(selector);
}
};
}
+
+ /**
+ * jQuery find function behavior is:
+ * $('A').find('A B') <=> $('A A B')
+ * The searches behavior to find options' DOM needs to be
+ * $('A').find('A B') <=> $('A B')
+ * This is what this function does.
+ *
+ * @param {jQuery} $from - the jQuery element(s) from which to search
+ * @param {string} selector - the CSS selector to match
+ * @returns {jQuery}
+ */
+ function cssFind($from, selector) {
+ return $from.find('*').filter(selector);
+ }
},
fetch_snippet_templates: function () {
diff --git a/addons/web_editor/static/src/js/transcoder.js b/addons/web_editor/static/src/js/transcoder.js
index 05068972532..6b42e028b3a 100644
--- a/addons/web_editor/static/src/js/transcoder.js
+++ b/addons/web_editor/static/src/js/transcoder.js
@@ -121,6 +121,16 @@ var getMatchedCSSRules = function (a) {
}
});
+ // text-decoration rule is decomposed in -line, -color and -style. This is
+ // however not supported by many browser/mail clients and the editor does
+ // not allow to change -color and -style rule anyway
+ if (style['text-decoration-line']) {
+ style['text-decoration'] = style['text-decoration-line'];
+ delete style['text-decoration-line'];
+ delete style['text-decoration-color'];
+ delete style['text-decoration-style'];
+ }
+
return style;
};
diff --git a/addons/web_editor/static/tests/web_editor_tests.js b/addons/web_editor/static/tests/web_editor_tests.js
index 6a18566af2b..a053ad99043 100644
--- a/addons/web_editor/static/tests/web_editor_tests.js
+++ b/addons/web_editor/static/tests/web_editor_tests.js
@@ -121,4 +121,31 @@ QUnit.test('field html_frame widget', function (assert) {
form.destroy();
});
+QUnit.test('field htmlsimple does not crash when commitChanges is called in mode=readonly', function (assert) {
+ assert.expect(1);
+
+ var form = testUtils.createView({
+ View: FormView,
+ model: 'mass.mailing',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ intercepts: {
+ execute_action: function () {
+ assert.step('execute_action');
+ }
+ },
+ });
+
+ form.$('button:contains(Do it)').click();
+ form.destroy();
+});
+
});
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index a97cebe76d5..0c0497ef843 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -175,8 +175,8 @@ class Http(models.AbstractModel):
nearest_lang = not func and cls.get_nearest_lang(path[1])
url_lang = nearest_lang and path[1]
preferred_lang = ((cook_lang if cook_lang in langs else False)
- or (not is_a_bot and cls.get_nearest_lang(request.lang))
- or request.website.default_lang_code)
+ or request.website.default_lang_code
+ or (not is_a_bot and cls.get_nearest_lang(request.lang)))
request.lang = context['lang'] = nearest_lang or preferred_lang
# if lang in url but not the displayed or default language --> change or remove
diff --git a/addons/website/static/src/js/website.seo.js b/addons/website/static/src/js/website.seo.js
index fc3ec4c4e29..219728aa552 100644
--- a/addons/website/static/src/js/website.seo.js
+++ b/addons/website/static/src/js/website.seo.js
@@ -555,8 +555,9 @@ var Configurator = Dialog.extend({
website.TopBar.include({
start: function () {
+ var self = this;
this.$el.on('click', 'a[data-action=promote-current-page]', function () {
- new Configurator(this).open();
+ new Configurator(self).open();
});
return this._super();
}
diff --git a/addons/website/static/src/less/website.snippets.less b/addons/website/static/src/less/website.snippets.less
index 5f96bbac0e6..cde2501e605 100644
--- a/addons/website/static/src/less/website.snippets.less
+++ b/addons/website/static/src/less/website.snippets.less
@@ -391,6 +391,7 @@ div.carousel {
// Parallax Theme
.parallax {
position: relative;
+ z-index: 0;
.oe_structure {
> :first-child {
diff --git a/addons/website/static/src/less/website.ui.less b/addons/website/static/src/less/website.ui.less
index 8f4732024b4..b515ad40096 100644
--- a/addons/website/static/src/less/website.ui.less
+++ b/addons/website/static/src/less/website.ui.less
@@ -47,4 +47,10 @@ body.o_connected_user {
font-size: 20px;
}
}
+ @media only screen and (max-width: @screen-xs-max) {
+ #oe_applications {
+ position: inherit;
+ z-index: 1002;
+ }
+ }
}
diff --git a/addons/website_blog/static/src/js/contentshare.js b/addons/website_blog/static/src/js/contentshare.js
index 47cd88fc4a7..cfc02470f7f 100644
--- a/addons/website_blog/static/src/js/contentshare.js
+++ b/addons/website_blog/static/src/js/contentshare.js
@@ -22,9 +22,12 @@
popover_content += '';
}
if($('.blog_title, .blog_content').hasClass('js_tweet')){
- var current_url = window.location.href;
- var tweet_selected_text = this.getSelection('string').substring(0,option.maxLength-(current_url.length+option.author_name.length+7));
- var text = encodeURIComponent(_.str.sprintf('\"%s\" --@%s %s', tweet_selected_text, option.author_name, current_url));
+ var tweet = '"%s" - %s';
+ var baseLength = tweet.replace(/%s/g, '').length;
+ // Shorten the selected text to match the tweet max length
+ // Note: all (non-localhost) urls in a tweet have 23 characters https://support.twitter.com/articles/78124
+ var selectedText = this.getSelection('string').substring(0, option.maxLength - baseLength - 23);
+ var text = encodeURIComponent(_.str.sprintf(tweet, selectedText, window.location.href));
popover_content += '';
}
return popover_content;
diff --git a/addons/website_blog/static/src/js/website_blog.js b/addons/website_blog/static/src/js/website_blog.js
index 5c2d82f7646..7985e0ebecc 100644
--- a/addons/website_blog/static/src/js/website_blog.js
+++ b/addons/website_blog/static/src/js/website_blog.js
@@ -58,7 +58,7 @@ function shareArticle(event){
$(document).ready(function() {
if ($('.website_blog').length) {
- $(".js_tweet, .js_comment").share({'author_name': $('#blog_author').text()});
+ $(".js_tweet, .js_comment").share({});
$('.cover_footer').on('click', page_transist);
$('a[href^="#blog_content"]').on('click', animate);
$('.o_twitter, .o_facebook, .o_linkedin, .o_google, .o_twitter_complete, .o_facebook_complete, .o_linkedin_complete, .o_google_complete').on('click', shareArticle);
diff --git a/addons/website_customer/controllers/main.py b/addons/website_customer/controllers/main.py
index 4946c5b25de..d6b10b37dc1 100644
--- a/addons/website_customer/controllers/main.py
+++ b/addons/website_customer/controllers/main.py
@@ -15,47 +15,63 @@ class WebsiteCustomer(http.Controller):
@http.route([
'/customers',
'/customers/page/',
- '/customers/country/',
- '/customers/country/-',
- '/customers/country//page/',
- '/customers/country/-/page/',
- '/customers/tag/',
- '/customers/tag//page/',
- '/customers/tag//country/',
- '/customers/tag//country/-',
- '/customers/tag//country//page/',
- '/customers/tag//country/-/page/',
+ '/customers/country/',
+ '/customers/country//page/',
+ '/customers/industry/',
+ '/customers/industry//page/',
+ '/customers/industry//country/',
+ '/customers/industry//country//page/',
], type='http', auth="public", website=True)
- def customers(self, country_id=0, page=0, country_name='', tag_id=0, **post):
- Country = request.env['res.country']
+ def customers(self, country=None, industry=None, page=0, **post):
Tag = request.env['res.partner.tag']
Partner = request.env['res.partner']
- partner_name = post.get('search', '')
+ search_value = post.get('search')
domain = [('website_published', '=', True), ('assigned_partner_id', '!=', False)]
- if partner_name:
+ if search_value:
domain += [
- '|',
- ('name', 'ilike', post.get("search")),
- ('website_description', 'ilike', post.get("search"))
+ '|', '|',
+ ('name', 'ilike', search_value),
+ ('website_description', 'ilike', search_value),
+ ('industry_id.name', 'ilike', search_value),
]
+ tag_id = post.get('tag_id')
if tag_id:
tag_id = unslug(tag_id)[1] or 0
domain += [('website_tag_ids', 'in', tag_id)]
+ # group by industry, based on customers found with the search(domain)
+ industries = Partner.sudo().read_group(domain, ["id", "industry_id"], groupby="industry_id", orderby="industry_id")
+ partners_count = Partner.sudo().search_count(domain)
+
+ if industry:
+ domain.append(('industry_id', '=', industry.id))
+ if industry.id not in (x['industry_id'][0] for x in industries if x['industry_id']):
+ if industry.exists():
+ industries.append({
+ 'industry_id_count': 0,
+ 'industry_id': (industry.id, industry.name)
+ })
+
+ industries.sort(key=lambda d: (d.get('industry_id') or (0, ''))[1])
+
+ industries.insert(0, {
+ 'industry_id_count': partners_count,
+ 'industry_id': (0, _("All Sectors of Activity"))
+ })
+
# group by country, based on customers found with the search(domain)
countries = Partner.sudo().read_group(domain, ["id", "country_id"], groupby="country_id", orderby="country_id")
country_count = Partner.sudo().search_count(domain)
- if country_id:
- domain += [('country_id', '=', country_id)]
- curr_country = Country.browse(country_id)
- if country_id not in (x['country_id'][0] for x in countries if x['country_id']):
- if curr_country.exists():
+ if country:
+ domain += [('country_id', '=', country.id)]
+ if country.id not in (x['country_id'][0] for x in countries if x['country_id']):
+ if country.exists():
countries.append({
'country_id_count': 0,
- 'country_id': (curr_country.id, curr_country.name)
+ 'country_id': (country.id, country.name)
})
countries.sort(key=lambda d: d['country_id'] and d['country_id'][1])
@@ -69,8 +85,10 @@ class WebsiteCustomer(http.Controller):
# pager
url = '/customers'
- if country_id:
- url += '/country/%s' % country_id
+ if industry:
+ url += '/industry/%s' % industry.id
+ if country:
+ url += '/country/%s' % country.id
pager = request.website.pager(
url=url, total=partner_count, page=page, step=self._references_per_page,
scope=7, url_args=post
@@ -85,8 +103,11 @@ class WebsiteCustomer(http.Controller):
values = {
'countries': countries,
- 'current_country_id': country_id or 0,
- 'current_country': curr_country if country_id else False,
+ 'current_country_id': country.id if country else 0,
+ 'current_country': country or False,
+ 'industries': industries,
+ 'current_industry_id': industry.id if industry else 0,
+ 'current_industry': industry or False,
'partners': partners,
'google_map_partner_ids': google_map_partner_ids,
'pager': pager,
diff --git a/addons/website_customer/views/website_customer_templates.xml b/addons/website_customer/views/website_customer_templates.xml
index d4c743cdcd5..4380180277d 100644
--- a/addons/website_customer/views/website_customer_templates.xml
+++ b/addons/website_customer/views/website_customer_templates.xml
@@ -50,6 +50,9 @@
+
+
+
@@ -91,15 +94,32 @@
+
+
+ References by Sectors of Activity
+
+
+
-
+
References by Country
-
-
+
@@ -107,20 +127,20 @@
-
-
+
+
References by Tag
-
+
All
+ t-attf-href="/customers/#{ current_industry_id and 'industry/%s/' % slug(current_industry) or '' }#{ current_country_id and 'country/%s' % slug(current_country) or '' }?tag_id=#{slug(o_tag)}"/>
@@ -160,6 +180,14 @@
+
+
+
+
+
+
+
+
@@ -201,6 +229,9 @@
+
+
+
diff --git a/addons/website_form/static/src/js/website_form.js b/addons/website_form/static/src/js/website_form.js
index eeecbea9389..e95f37ef335 100644
--- a/addons/website_form/static/src/js/website_form.js
+++ b/addons/website_form/static/src/js/website_form.js
@@ -167,13 +167,15 @@ odoo.define('website_form.animation', function (require) {
// Special cases for dates and datetimes
} else if ($(input).hasClass('o_website_form_date')) {
- return !self.is_datetime_valid(input.value, 'date');
+ if (!self.is_datetime_valid(input.value, 'date')) {
+ return true;
+ }
} else if ($(input).hasClass('o_website_form_datetime')) {
- return !self.is_datetime_valid(input.value, 'datetime');
-
- } else {
- return !input.checkValidity();
+ if (!self.is_datetime_valid(input.value, 'datetime')) {
+ return true;
+ }
}
+ return !input.checkValidity();
})
// Update field color if invalid or erroneous
diff --git a/addons/website_mail/controllers/main.py b/addons/website_mail/controllers/main.py
index c6fc592e3d8..546def6890a 100644
--- a/addons/website_mail/controllers/main.py
+++ b/addons/website_mail/controllers/main.py
@@ -34,6 +34,7 @@ def _message_post_helper(res_model='', res_id=None, message='', token='', token_
else:
if not author_id:
raise NotFound()
+ kw.pop('csrf_token', None)
return record.with_context(mail_create_nosubscribe=nosubscribe).message_post(body=message,
message_type=kw.pop('message_type', "comment"),
subtype=kw.pop('subtype', "mt_comment"),
diff --git a/addons/website_portal/wizard/portal_wizard.py b/addons/website_portal/wizard/portal_wizard.py
index 2da830ef9d6..4bc731c3ff8 100644
--- a/addons/website_portal/wizard/portal_wizard.py
+++ b/addons/website_portal/wizard/portal_wizard.py
@@ -127,7 +127,7 @@ class PortalWizardUser(models.TransientModel):
for wizard_user in self.sudo().with_context(active_test=False):
group_portal = wizard_user.wizard_id.portal_id
if not group_portal.is_portal:
- raise UserError('Not a portal: ' + group_portal.name)
+ raise UserError(_('Group %s is not a portal') % group_portal.name)
user = wizard_user.partner_id.user_ids[0] if wizard_user.partner_id.user_ids else None
# update partner email, if a new one was introduced
if wizard_user.partner_id.email != wizard_user.email:
diff --git a/addons/website_portal_sale/controllers/main.py b/addons/website_portal_sale/controllers/main.py
index 42d95d458b1..86a56b0509b 100644
--- a/addons/website_portal_sale/controllers/main.py
+++ b/addons/website_portal_sale/controllers/main.py
@@ -238,10 +238,10 @@ class website_account(website_account):
partner = request.env['res.users'].browse(request.uid).partner_id
invoices = request.env['account.invoice'].sudo().search_count([('partner_id', '=', partner.id), ('state', 'not in', ['draft', 'cancel'])])
if invoices:
- if (data.get('vat', partner.vat) or False) != partner.vat:
+ if 'vat' in partner and (data['vat'] or False) != (partner.vat or False):
error['vat'] = 'error'
error_message.append(_('Changing VAT number is not allowed once invoices have been issued for your account. Please contact us directly for this operation.'))
- if data.get('name', partner.name) != partner.name:
+ if 'name' in data and (data['name'] or False) != (partner.name or False):
error['name'] = 'error'
error_message.append(_('Changing your name is not allowed once invoices have been issued for your account. Please contact us directly for this operation.'))
return error, error_message
diff --git a/addons/website_quote/models/sale_order.py b/addons/website_quote/models/sale_order.py
index c7d09e139c8..f441087de33 100644
--- a/addons/website_quote/models/sale_order.py
+++ b/addons/website_quote/models/sale_order.py
@@ -73,6 +73,13 @@ class SaleOrder(models.Model):
(2, 'Immediate after website order validation and save a token'),
], 'Payment', help="Require immediate payment by the customer when validating the order from the website quote")
+ @api.multi
+ def copy(self, default=None):
+ if self.template_id and self.template_id.number_of_days > 0:
+ default = dict(default or {})
+ default['validity_date'] = fields.Date.to_string(datetime.now() + timedelta(self.template_id.number_of_days))
+ return super(SaleOrder, self).copy(default=default)
+
@api.one
def _compute_amount_undiscounted(self):
total = 0.0
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index c43e3353671..8af8e148a9e 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -322,7 +322,8 @@ class WebsiteSale(http.Controller):
@http.route(['/shop/change_pricelist/'], type='http', auth="public", website=True)
def pricelist_change(self, pl_id, **post):
- if request.website.is_pricelist_available(pl_id.id):
+ if (pl_id.selectable or pl_id == request.env.user.partner_id.property_product_pricelist) \
+ and request.website.is_pricelist_available(pl_id.id):
request.session['website_sale_current_pl'] = pl_id.id
request.website.sale_get_order(force_pricelist=pl_id.id)
return request.redirect(request.httprequest.referrer or '/shop')
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
index 0a379588071..fe22160267f 100644
--- a/addons/website_sale/models/product.py
+++ b/addons/website_sale/models/product.py
@@ -21,7 +21,7 @@ class ProductPricelist(models.Model):
return self.env['website'].search([], limit=1)
website_id = fields.Many2one('website', string="website", default=_default_website)
- code = fields.Char(string='E-commerce Promotional Code')
+ code = fields.Char(string='E-commerce Promotional Code', groups="base.group_user")
selectable = fields.Boolean(help="Allow the end user to choose this price list")
def clear_cache(self):
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index 355af951f61..a933dd3574d 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -61,7 +61,7 @@ class SaleOrder(models.Model):
if order.pricelist_id and order.partner_id:
order_line = order._cart_find_product_line(product.id)
if order_line:
- pu = self.env['account.tax']._fix_tax_included_price(pu, product.taxes_id, order_line.tax_id)
+ pu = self.env['account.tax']._fix_tax_included_price(pu, product.taxes_id, order_line[0].tax_id)
return {
'product_id': product_id,
@@ -218,7 +218,7 @@ class Website(models.Model):
if not pricelists: # no pricelist for this country, or no GeoIP
pricelists |= all_pl.filtered(lambda pl: not show_visible or pl.selectable or pl.id in (current_pl, order_pl))
else:
- pricelists |= all_pl.filtered(lambda pl: not show_visible and pl.code)
+ pricelists |= all_pl.filtered(lambda pl: not show_visible and pl.sudo().code)
# This method is cached, must not return records! See also #8795
return pricelists.ids
@@ -387,6 +387,9 @@ class Website(models.Model):
partner.write({'last_website_so_id': sale_order.id})
if sale_order:
+ # case when user emptied the cart
+ if not request.session.get('sale_order_id'):
+ request.session['sale_order_id'] = sale_order.id
# check for change of pricelist with a coupon
pricelist_id = pricelist_id or partner.property_product_pricelist.id
@@ -425,7 +428,7 @@ class Website(models.Model):
update_pricelist = True
if code and code != sale_order.pricelist_id.code:
- code_pricelist = self.env['product.pricelist'].search([('code', '=', code)], limit=1)
+ code_pricelist = self.env['product.pricelist'].sudo().search([('code', '=', code)], limit=1)
if code_pricelist:
pricelist_id = code_pricelist.id
update_pricelist = True
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 80958f3fb70..8bf5fdf3ff0 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -605,7 +605,7 @@
-
+
trans.src) gives the original value back
value0 = field.translate(lambda term: None, record[fname])
value1 = field.translate({trans.src: trans.value}.get, value0)
+ # don't check the reverse if no translation happened
+ if value0 == value1:
+ continue
value2 = field.translate({trans.value: trans.src}.get, value1)
if value2 != value0:
raise ValidationError(_("Translation is not valid:\n%s") % trans.value)
diff --git a/odoo/addons/base/ir/ir_ui_view.py b/odoo/addons/base/ir/ir_ui_view.py
index 3e674b02a48..e8846f420f2 100644
--- a/odoo/addons/base/ir/ir_ui_view.py
+++ b/odoo/addons/base/ir/ir_ui_view.py
@@ -459,7 +459,8 @@ actual arch.
# cannot currently use relationships that are
# not required. The root cause is the INNER JOIN
# used to implement it.
- views = self.search(conditions + [('model_ids.module', 'in', tuple(self.pool._init_modules))])
+ modules = tuple(self.pool._init_modules) + (self._context.get('install_mode_data', {}).get('module'),)
+ views = self.search(conditions + [('model_ids.module', 'in', modules)])
views = self.search(conditions + [('id', 'in', list(self._context.get('check_view_ids') or (0,)) + views.ids)])
else:
views = self.search(conditions)
diff --git a/odoo/addons/base/res/res.country.state.csv b/odoo/addons/base/res/res.country.state.csv
index 877834a77ca..c48ed49141d 100644
--- a/odoo/addons/base/res/res.country.state.csv
+++ b/odoo/addons/base/res/res.country.state.csv
@@ -436,7 +436,7 @@ state_es_cu,es,"Cuenca","CU"
state_es_gi,es,"Girona (Gerona)","GI"
state_es_gr,es,"Granada","GR"
state_es_gu,es,"Guadalajara","GU"
-state_es_ss,es,"Guipuzkoa (Guipúzcoa)","SS"
+state_es_ss,es,"Gipuzkoa (Guipúzcoa)","SS"
state_es_h,es,"Huelva","H"
state_es_hu,es,"Huesca","HU"
state_es_j,es,"Jaén","J"
diff --git a/odoo/addons/base/res/res_partner.py b/odoo/addons/base/res/res_partner.py
index 10db1ddfe67..b0815dba6f2 100644
--- a/odoo/addons/base/res/res_partner.py
+++ b/odoo/addons/base/res/res_partner.py
@@ -198,6 +198,7 @@ class Partner(models.Model):
mobile = fields.Char()
is_company = fields.Boolean(string='Is a Company', default=False,
help="Check if the contact is a company, otherwise it is a person")
+ industry_id = fields.Many2one('res.partner.industry', 'Sector of Activity')
# company_type is only an interface field, do not use it in business logic
company_type = fields.Selection(string='Company Type',
selection=[('person', 'Individual'), ('company', 'Company')],
@@ -805,3 +806,13 @@ class Partner(models.Model):
'country_id.address_format', 'country_id.code', 'country_id.name',
'company_name', 'state_id.code', 'state_id.name',
]
+
+
+class ResPartnerIndustry(models.Model):
+ _description = 'Sector of Activity'
+ _name = "res.partner.industry"
+ _order = "name"
+
+ name = fields.Char('Name', translate=True)
+ full_name = fields.Char('Full Name', translate=True)
+ active = fields.Boolean('Active', default=True)
diff --git a/odoo/addons/base/res/res_partner_data.xml b/odoo/addons/base/res/res_partner_data.xml
index 2d2872230b9..6d8e832924d 100644
--- a/odoo/addons/base/res/res_partner_data.xml
+++ b/odoo/addons/base/res/res_partner_data.xml
@@ -34,5 +34,111 @@
Partner
res.partner
+
+
+ Agriculture
+ A AGRICULTURE, FORESTRY AND FISHING
+
+
+
+ Mining
+ B MINING AND QUARRYING
+
+
+
+ Manufacturing
+ C MANUFACTURING
+
+
+
+ Energy supply
+ D ELECTRICITY,GAS,STEAM AND AIR CONDITIONING SUPPLY
+
+
+
+ Water supply
+ E WATER SUPPLY;SEWERAGE,WASTE MANAGEMENT AND REMEDIATION ACTIVITIES
+
+
+
+ Construction
+ F CONSTRUCTION
+
+
+
+ Wholesale/Retail
+ G WHOLESALE AND RETAIL TRADE;REPAIR OF MOTOR VEHICLES AND MOTORCYCLES
+
+
+
+ Transportation
+ H TRANSPORTATION AND STORAGE
+
+
+
+ Food
+ I ACCOMMODATION AND FOOD SERVICE ACTIVITIES
+
+
+
+ IT/Communication
+ J INFORMATION AND COMMUNICATION
+
+
+
+ Finance/Insurance
+ K FINANCIAL AND INSURANCE ACTIVITIES
+
+
+
+ Real Estrate
+ L REAL ESTATE ACTIVITIES
+
+
+
+ Scientific
+ M PROFESSIONAL, SCIENTIFIC AND TECHNICAL ACTIVITIES
+
+
+
+ Administrative
+ N ADMINISTRATIVE AND SUPPORT SERVICE ACTIVITIES
+
+
+
+ Public Administration
+ O PUBLIC ADMINISTRATION AND DEFENCE;COMPULSORY SOCIAL SECURITY
+
+
+
+ Education
+ P EDUCATION
+
+
+
+ Health/Social
+ Q HUMAN HEALTH AND SOCIAL WORK ACTIVITIES
+
+
+
+ Entertainment
+ R ARTS, ENTERTAINMENT AND RECREATION
+
+
+
+ Other Services
+ S OTHER SERVICE ACTIVITIES
+
+
+
+ Households
+ T ACTIVITIES OF HOUSEHOLDS AS EMPLOYERS;UNDIFFERENTIATED GOODS- AND SERVICES-PRODUCING ACTIVITIES OF HOUSEHOLDS FOR OWN USE
+
+
+
+ Extraterritorial
+ U ACTIVITIES OF EXTRA TERRITORIAL ORGANISATIONS AND BODIES
+
+
diff --git a/odoo/addons/base/res/res_partner_view.xml b/odoo/addons/base/res/res_partner_view.xml
index 8102f1c55ea..5aaac327003 100644
--- a/odoo/addons/base/res/res_partner_view.xml
+++ b/odoo/addons/base/res/res_partner_view.xml
@@ -313,6 +313,7 @@
+
@@ -557,6 +558,46 @@
-
+
+
+ Sector of Activity
+ res.partner.industry
+
+
+
+
+
+ Sector of Activity
+ res.partner.industry
+
+
+
+
+
+
+
+
+
+
+ Sector of Activity
+ ir.actions.act_window
+ res.partner.industry
+ form
+ tree,form
+
+
+ Click to create a new sector of activity.
+
+
+ Manage sectors of activity to better classify partners for tracking and analysis purposes.
+
+
+
diff --git a/odoo/addons/base/security/ir.model.access.csv b/odoo/addons/base/security/ir.model.access.csv
index 842b6bb5fcc..e68f18d34b5 100644
--- a/odoo/addons/base/security/ir.model.access.csv
+++ b/odoo/addons/base/security/ir.model.access.csv
@@ -57,6 +57,8 @@
"access_res_partner_bank_group_partner_manager","res_partner_bank group_partner_manager","model_res_partner_bank","group_partner_manager",1,1,1,1
"access_res_partner_category_group_user","res_partner_category group_user","model_res_partner_category","group_user",1,0,0,0
"access_res_partner_category_group_partner_manager","res_partner_category group_partner_manager","model_res_partner_category","group_partner_manager",1,1,1,1
+"access_res_partner_industry_group_user","res_partner_industry group_user","model_res_partner_industry","group_user",1,0,0,0
+"access_res_partner_industry_group_system","res_partner_industry group_system","model_res_partner_industry","group_system",1,1,1,1
"access_res_partner_title_group_user","res_partner_title group_user","model_res_partner_title","group_partner_manager",1,1,1,1
"access_res_partner_title_group_partner_manager","res_partner_title group_partner_manager","model_res_partner_title",,1,0,0,0
"access_res_request_link_group_system","res_request_link group_system","model_res_request_link","group_system",1,1,1,1
diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py
index 1cd833c9df6..de607798a82 100644
--- a/odoo/addons/base/tests/test_translate.py
+++ b/odoo/addons/base/tests/test_translate.py
@@ -205,10 +205,16 @@ class TranslationToolsTestCase(unittest.TestCase):
self.assertEquals(result, expect)
def test_translate_html(self):
+ """ Test html_translate(). """
+ source = """A B C """
+ result = html_translate(lambda term: term, source)
+ self.assertEquals(result, source)
+
+ def test_translate_html_i(self):
""" Test xml_translate() and html_translate() with elements. """
- source = """"""
+ source = """A B """
result = xml_translate(lambda term: term, source)
- self.assertEquals(result, """""")
+ self.assertEquals(result, """A B """)
result = html_translate(lambda term: term, source)
self.assertEquals(result, source)
diff --git a/odoo/models.py b/odoo/models.py
index 7e2f65c9312..c911162ae32 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -3807,7 +3807,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
del vals['source'] # remove source to avoid triggering _set_src
del vals['module'] # duplicated vals is not linked to any module
vals['res_id'] = target_id
- if vals['lang'] == old.env.lang:
+ if vals['lang'] == old.env.lang and field.translate is True:
# 'source' to force the call to _set_src
# 'value' needed if value is changed in copy(), want to see the new_value
vals['source'] = old_wo_lang[name]
diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py
index b2996af6ae3..50395a28625 100644
--- a/odoo/tools/translate.py
+++ b/odoo/tools/translate.py
@@ -17,7 +17,7 @@ from datetime import datetime
from os.path import join
from babel.messages import extract
-from lxml import etree
+from lxml import etree, html
import odoo
from . import config, pycompat
@@ -152,8 +152,13 @@ avoid_pattern = re.compile(r"\s*]*>(.*)[^<]*>", re.DOTALL | re.MULTILINE | re.UNICODE)
-def translate_xml_node(node, callback, method, parser=None):
- """ Return the translation of the given XML/HTML node. """
+def translate_xml_node(node, callback, parse, serialize):
+ """ Return the translation of the given XML/HTML node.
+
+ :param callback: callback(text) returns translated text or None
+ :param parse: parse(text) returns a node (text is unicode)
+ :param serialize: serialize(node) returns unicode text
+ """
def nonspace(text):
return bool(text) and not text.isspace()
@@ -181,7 +186,7 @@ def translate_xml_node(node, callback, method, parser=None):
def translate_content(node):
""" Return ``node`` with its content translated inline. """
# serialize the node that contains the stuff to translate
- text = etree.tostring(node, method=method, encoding='utf8').decode('utf8')
+ text = serialize(node)
# retrieve the node's content and translate it
match = node_pattern.match(text)
trans = translate_text(match.group(1))
@@ -189,7 +194,7 @@ def translate_xml_node(node, callback, method, parser=None):
# replace the content, and convert it back to an XML node
text = text[:match.start(1)] + trans + text[match.end(1):]
try:
- node = etree.fromstring(encode(text), parser=parser)
+ node = parse(text)
except etree.ParseError:
# fallback: escape the translation as text
node = etree.Element(node.tag, node.attrib, node.nsmap)
@@ -274,6 +279,21 @@ def translate_xml_node(node, callback, method, parser=None):
return node
+def parse_xml(text):
+ return etree.fromstring(encode(text))
+
+def serialize_xml(node):
+ return etree.tostring(node, method='xml', encoding='utf8').decode('utf8')
+
+_HTML_PARSER = etree.HTMLParser(encoding='utf8')
+
+def parse_html(text):
+ return html.fragment_fromstring(encode(text), parser=_HTML_PARSER)
+
+def serialize_html(node):
+ return etree.tostring(node, method='html', encoding='utf8').decode('utf8')
+
+
def xml_translate(callback, value):
""" Translate an XML value (string), using `callback` for translating text
appearing in `value`.
@@ -282,17 +302,15 @@ def xml_translate(callback, value):
return value
try:
- root = etree.fromstring(encode(value))
- result = translate_xml_node(root, callback, 'xml')
- return etree.tostring(result, method='xml', encoding='utf8').decode('utf8')
+ root = parse_xml(value)
+ result = translate_xml_node(root, callback, parse_xml, serialize_xml)
+ return serialize_xml(result)
except etree.ParseError:
# fallback for translated terms: use an HTML parser and wrap the term
- wrapped = "%s " % encode(value)
- root = etree.fromstring(wrapped, etree.HTMLParser(encoding='utf-8'))
- # root is html > body > div; translate the div only
- result = translate_xml_node(root[0][0], callback, 'xml')
+ root = parse_html("%s " % value)
+ result = translate_xml_node(root, callback, parse_xml, serialize_xml)
# remove tags and from result
- return etree.tostring(result, method='xml', encoding='utf8').decode('utf8')[5:-6]
+ return serialize_xml(result)[5:-6]
def html_translate(callback, value):
""" Translate an HTML value (string), using `callback` for translating text
@@ -302,14 +320,11 @@ def html_translate(callback, value):
return value
try:
- parser = etree.HTMLParser(encoding='utf-8')
# value may be some HTML fragment, wrap it into a div
- wrapped = "%s " % encode(value)
- root = etree.fromstring(wrapped, parser)
- # root is html > body > div; translate the div only
- result = translate_xml_node(root[0][0], callback, 'html', parser)
+ root = parse_html("%s " % value)
+ result = translate_xml_node(root, callback, parse_html, serialize_html)
# remove tags and from result
- value = etree.tostring(result, method='html', encoding='utf8').decode('utf8')[5:-6]
+ value = serialize_html(result)[5:-6]
except ValueError:
_logger.exception("Cannot translate malformed HTML, using source value instead")
diff --git a/requirements.txt b/requirements.txt
index 94893f524e3..8be33c36ef6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,7 +13,7 @@ Mako==1.0.4
MarkupSafe==0.23
mock==2.0.0
num2words==0.5.4
-ofxparse==0.15
+ofxparse==0.16
passlib==1.6.5
Pillow==3.4.1
psutil==4.3.1
|