summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2018-03-21 11:04:48 +0100
committerChristophe Simonis <chs@odoo.com>2018-03-21 11:04:48 +0100
commitb51f2d38c247dce9afc04c29774a77d773e3f393 (patch)
tree1133e0dd4c082543d1f361fb7ec160ac0b2cccc6 /addons/web/static
parentc7dff653d56910fd46e4dcf745c2c6088ce9fc58 (diff)
parent500e3f497057c5d00ae5cc34da7012c6f31f81d4 (diff)
[MERGE] forward port branch saas-11.2 up to 500e3f49705
Diffstat (limited to 'addons/web/static')
-rw-r--r--addons/web/static/lib/ace/ace.odoo-custom.js9
-rw-r--r--addons/web/static/src/js/chrome/action_manager.js28
-rw-r--r--addons/web/static/src/js/chrome/action_manager_act_window.js48
-rw-r--r--addons/web/static/src/js/chrome/control_panel.js3
-rw-r--r--addons/web/static/src/js/chrome/menu.js17
-rw-r--r--addons/web/static/src/js/chrome/web_client.js4
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js5
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js29
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js2
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js19
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js4
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js1
-rw-r--r--addons/web/static/src/less/kanban_view.less4
-rw-r--r--addons/web/static/src/less/kanban_view_mobile.less3
-rw-r--r--addons/web/static/src/xml/base.xml28
-rw-r--r--addons/web/static/tests/chrome/action_manager_tests.js268
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js76
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js411
-rw-r--r--addons/web/static/tests/views/form_tests.js40
-rw-r--r--addons/web/static/tests/views/view_dialogs_tests.js122
20 files changed, 1039 insertions, 82 deletions
diff --git a/addons/web/static/lib/ace/ace.odoo-custom.js b/addons/web/static/lib/ace/ace.odoo-custom.js
index d8f84294316..49af24f5074 100644
--- a/addons/web/static/lib/ace/ace.odoo-custom.js
+++ b/addons/web/static/lib/ace/ace.odoo-custom.js
@@ -1173,15 +1173,16 @@ define("ace/lib/regexp", ["require", "exports", "module"], function(e, t, n) {
if (!t.onCompositionEnd || t.$readOnly) return;
var r = h;
h = !1;
- var i = setTimeout(function() {
- i = null;
+ var i2 = setTimeout(function() {
+ i2 = null;
var e = n.value.replace(/\x01/g, "");
if (h) return;
e == r.lastValue ? w() : !r.lastValue && e && (w(), A(e))
});
k = function(n) {
- return i && clearTimeout(i), n = n.replace(/\x01/g, ""), n == r.lastValue ? "" : (r.lastValue && i && t.undo(), n)
- }, t.onCompositionEnd(), t.removeListener("mousedown", F), e.type == "compositionend" && r.range && t.selection.setRange(r.range)
+ return i2 && clearTimeout(i2), n = n.replace(/\x01/g, ""), n == r.lastValue ? "" : (r.lastValue && i2 && t.undo(), n)
+ }, t.onCompositionEnd(), t.removeListener("mousedown", F), e.type == "compositionend" && r.range && t.selection.setRange(r.range),
+ i.isChrome && i.isChrome >= 53 && O()
},
I = o.delayedCall(j, 50);
r.addListener(n, "compositionstart", B), i.isGecko ? r.addListener(n, "text", function() {
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index 4cf10b8813a..c4d3f591545 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -62,7 +62,6 @@ var ActionManager = Widget.extend({
// dialog (i.e. coming from an action with target='new')
this.currentDialogController = null;
},
-
/**
* @override
*/
@@ -140,7 +139,7 @@ var ActionManager = Widget.extend({
* loaded and appended to the DOM ; rejected if the action can't be
* executed (e.g. if doAction has been called to execute another action
* before this one was complete).
- */
+ */
doAction: function (action, options) {
var self = this;
options = _.defaults({}, options, {
@@ -177,6 +176,11 @@ var ActionManager = Widget.extend({
self._preprocessAction(action, options);
return self._handleAction(action, options).then(function () {
+ // now that the action has been executed, force its 'pushState'
+ // flag to 'true', as we don't want to prevent its controller
+ // from pushing its state if it changes in the future
+ action.pushState = true;
+
return action;
});
});
@@ -354,11 +358,6 @@ var ActionManager = Widget.extend({
// toggle the fullscreen mode for actions in target='fullscreen'
self._toggleFullscreen();
- // now that the action has been executed, force its 'pushState'
- // flag to 'true', as we don't want to prevent its controller
- // from pushing its state if it changes in the future
- action.pushState = true;
-
return action;
})
.fail(function () {
@@ -451,6 +450,7 @@ var ActionManager = Widget.extend({
}
var controllerID = _.uniqueId('controller_');
+ options.controllerID = controllerID;
var controller = {
actionID: action.jsID,
jsID: controllerID,
@@ -903,13 +903,13 @@ var ActionManager = Widget.extend({
}
},
/**
- * Intercepts and triggers a redirection on a link
- *
- * @private
- * @param {OdooEvent} ev
- * @param {integer} ev.data.res_id
- * @param {string} ev.data.res_model
- */
+ * Intercepts and triggers a redirection on a link.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {integer} ev.data.res_id
+ * @param {string} ev.data.res_model
+ */
_onRedirect: function (ev) {
this.do_action({
type:'ir.actions.act_window',
diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js
index dfb512900e8..4e636de6355 100644
--- a/addons/web/static/src/js/chrome/action_manager_act_window.js
+++ b/addons/web/static/src/js/chrome/action_manager_act_window.js
@@ -224,6 +224,31 @@ ActionManager.include({
return action.controllers[viewType];
},
/**
+ * Destroys the controllers and search view of a given action of type
+ * 'ir.actions.act_window'.
+ *
+ * @private
+ * @param {Object} action
+ */
+ _destroyWindowAction: function (action) {
+ var self = this;
+ _.each(action.controllers, function (controllerDef) {
+ controllerDef.then(function (controller) {
+ delete self.controllers[controller.jsID];
+ if (controller.widget) {
+ controller.widget.destroy();
+ }
+ });
+ // reject the deferred if it is not yet resolved, so that the
+ // controller is correctly destroyed as soon as it is ready, and
+ // its reference is removed
+ controllerDef.reject();
+ });
+ if (action.searchView) {
+ action.searchView.destroy();
+ }
+ },
+ /**
* Executes actions of type 'ir.actions.act_window'.
*
* @private
@@ -245,7 +270,7 @@ ActionManager.include({
}
action.flags = this._generateActionFlags(action);
- return this._loadViews(action).then(function (fieldsViews) {
+ return this.dp.add(this._loadViews(action)).then(function (fieldsViews) {
var views = self._generateActionViews(action, fieldsViews);
action._views = action.views; // save the initial attribute
action.views = views;
@@ -291,7 +316,7 @@ ActionManager.include({
if (mainView) {
defs.push(self._createViewController(action, mainView.type, {}, {lazy: true}));
}
- return $.when.apply($, defs);
+ return self.dp.add($.when.apply($, defs));
}).then(function (controller, lazyLoadedController) {
action.controllerID = controller.jsID;
return self._executeAction(action, options).done(function () {
@@ -302,7 +327,7 @@ ActionManager.include({
}, {clear: false});
}
});
- });
+ }).fail(self._destroyWindowAction.bind(self, action));
});
},
/**
@@ -490,25 +515,10 @@ ActionManager.include({
* @private
*/
_removeAction: function (actionID) {
- var self = this;
var action = this.actions[actionID];
if (action.type === 'ir.actions.act_window') {
delete this.actions[action.jsID];
- _.each(action.controllers, function (controllerDef) {
- controllerDef.then(function (controller) {
- delete self.controllers[controller.jsID];
- if (controller.widget) {
- controller.widget.destroy();
- }
- });
- // reject the deferred if it is not yet resolved, so that the
- // controller is correctly destroyed as soon as it is ready, and
- // its reference is removed
- controllerDef.reject();
- });
- if (action.searchView) {
- action.searchView.destroy();
- }
+ this._destroyWindowAction(action);
} else {
this._super.apply(this, arguments);
}
diff --git a/addons/web/static/src/js/chrome/control_panel.js b/addons/web/static/src/js/chrome/control_panel.js
index 03b9e756bb7..558dde933e9 100644
--- a/addons/web/static/src/js/chrome/control_panel.js
+++ b/addons/web/static/src/js/chrome/control_panel.js
@@ -252,9 +252,6 @@ var ControlPanel = Widget.extend({
*/
_update_search_view: function (searchview, isHidden, groupable) {
if (searchview) {
- // Set the $buttons div (in the DOM) of the searchview as the $buttons
- // have been appended to a jQuery node not in the DOM at SearchView initialization
- searchview.$buttons = this.nodes.$searchview_buttons;
searchview.toggle_visibility(!isHidden);
if (groupable !== undefined){
searchview.groupby_menu.do_toggle(groupable);
diff --git a/addons/web/static/src/js/chrome/menu.js b/addons/web/static/src/js/chrome/menu.js
index 1e5e2922186..1e253ba54f7 100644
--- a/addons/web/static/src/js/chrome/menu.js
+++ b/addons/web/static/src/js/chrome/menu.js
@@ -147,15 +147,22 @@ var Menu = Widget.extend({
});
},
/**
- * Call open_menu with the first menu_item matching an action_id
+ * Call open_menu on a menu_item that matches the action_id
+ *
+ * If `menuID` is a match on this action, open this menu_item.
+ * Otherwise open the first menu_item that matches the action_id.
*
* @param {Number} id the action_id to match
+ * @param {Number} [menuID] a menu ID that may match with provided action
*/
- open_action: function (id) {
+ open_action: function (id, menuID) {
var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]');
- var menu_id = $menu.data('menu');
- if (menu_id) {
- this.open_menu(menu_id);
+ if (!(menuID && $menu.filter("[data-menu='" + menuID + "']").length)) {
+ // menuID doesn't match action, so pick first menu_item
+ menuID = $menu.data('menu');
+ }
+ if (menuID) {
+ this.open_menu(menuID);
}
},
/**
diff --git a/addons/web/static/src/js/chrome/web_client.js b/addons/web/static/src/js/chrome/web_client.js
index 3f832a6a49f..e0ce1f2c9f2 100644
--- a/addons/web/static/src/js/chrome/web_client.js
+++ b/addons/web/static/src/js/chrome/web_client.js
@@ -136,7 +136,7 @@ return AbstractWebClient.extend({
self.action_manager.loadState(state, !!self._current_state).then(function () {
var action = self.action_manager.getCurrentAction();
if (action) {
- self.menu.open_action(action.id);
+ self.menu.open_action(action.id, state.menu_id);
}
});
}
@@ -157,7 +157,7 @@ return AbstractWebClient.extend({
var completed = $.Deferred();
self.action_manager.doAction(result, {
clear_breadcrumbs: true,
- action_menu_id: self.menu.current_menu,
+ action_menu_id: options.id,
}).fail(function() {
self.menu.open_menu(options.previous_menu_id);
}).always(function() {
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 3b55efbc826..3a5a1537387 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -2062,8 +2062,9 @@ var FieldToggleBoolean = AbstractField.extend({
* @private
*/
_render: function () {
- var className = this.value ? 'o_toggle_button_success' : 'text-muted';
- this.$('i').addClass('fa fa-circle ' + className);
+ this.$('i')
+ .toggleClass('o_toggle_button_success', !!this.value)
+ .toggleClass('text-muted', !this.value);
var title = this.value ? this.attrs.options.active : this.attrs.options.inactive;
this.$el.attr('title', title);
},
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index ecd7dd688e6..8f5ffd65fac 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -878,10 +878,15 @@ var FieldX2Many = AbstractField.extend({
this.pager = new Pager(this, this.value.count, this.value.offset + 1, this.value.limit, {
single_page_hidden: true,
withAccessKey: false,
+ validate: function () {
+ var isList = self.view.arch.tag === 'tree';
+ // TODO: we should have some common method in the basic renderer...
+ return isList ? self.renderer.unselectRow() : $.when();
+ },
});
this.pager.on('pager_changed', this, function (new_state) {
- this.trigger_up('load', {
- id: this.value.id,
+ self.trigger_up('load', {
+ id: self.value.id,
limit: new_state.limit,
offset: new_state.current_min - 1,
on_success: function (value) {
@@ -930,6 +935,7 @@ var FieldX2Many = AbstractField.extend({
* did not agree to discard.
*/
_saveLine: function (recordID) {
+ var self = this;
var def = $.Deferred();
var fieldNames = this.renderer.canBeSaved(recordID);
if (fieldNames.length) {
@@ -941,7 +947,9 @@ var FieldX2Many = AbstractField.extend({
} else {
this.renderer.setRowMode(recordID, 'readonly').done(def.resolve.bind(def));
}
- return def;
+ return def.then(function () {
+ self.pager.updateState({ size: self.value.count });
+ });
},
/**
* Parses the 'columnInvisibleFields' attribute to search for the domains
@@ -1178,7 +1186,20 @@ var FieldOne2Many = FieldX2Many.extend({
return this._super.apply(this, arguments).then(function () {
if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') {
if (ev.data.changes[self.name].operation === 'CREATE') {
- var index = self.editable === 'top' ? 0 : self.value.data.length - 1;
+ var index = 0;
+ if (self.editable !== 'top') {
+ index = self.value.data.length - 1;
+ // we consider that a new record, created in the bottom,
+ // does not count as a record worth mentioning in the
+ // pager, at least not until the line has been saved.
+ // We prevent the pager from increasing its size, which
+ // means that the pager is not displayed when we just
+ // reach the limit. For example, if limit is 3, if we
+ // have 3 records, and we click on add, we will see the
+ // 4 records on the same page, but we do not want a
+ // pager.
+ self.pager.updateState({ size: self.value.count - 1});
+ }
var newID = self.value.data[index].id;
self.renderer.editRecord(newID);
}
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index ee39fd95596..4fff15d5663 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -330,7 +330,7 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
if (this.searchView) {
_.extend(cpContent, {
$searchview: this.searchView.$el,
- $searchview_buttons: this.searchView.$buttons.contents(),
+ $searchview_buttons: this.searchView.$buttons,
});
}
this.update_control_panel({
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 3013cb32f85..4cf6f9fa27b 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -370,6 +370,11 @@ var BasicModel = AbstractModel.extend({
elem._changes = null;
elem._isDirty = false;
}
+ elem.offset = 0;
+ if (elem.tempLimitIncrement) {
+ elem.limit -= elem.tempLimitIncrement;
+ delete elem.tempLimitIncrement;
+ }
});
},
/**
@@ -820,6 +825,7 @@ var BasicModel = AbstractModel.extend({
this.discardChanges(id, {rollback: false});
}
} else if (element._changes) {
+ delete element.tempLimitIncrement;
_.each(element._changes, function (change) {
delete change.isNew;
});
@@ -1657,6 +1663,12 @@ var BasicModel = AbstractModel.extend({
// handle multiple add: command[2] may be a dict of values (1
// record added) or an array of dict of values
var data = _.isArray(command.ids) ? command.ids : [command.ids];
+
+ // Ensure the local data repository (list) boundaries can handle incoming records (data)
+ if (data.length + list.res_ids.length > list.limit) {
+ list.limit = data.length + list.res_ids.length;
+ }
+
var list_records = {};
_.each(data, function (d) {
rec = self._makeDataPoint({
@@ -1701,6 +1713,10 @@ var BasicModel = AbstractModel.extend({
case 'CREATE':
var options = {position: command.position};
def = this._addX2ManyDefaultRecord(list, options).then(function (id) {
+ if (command.position === 'bottom' && list.orderedResIDs.length >= list.limit) {
+ list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1;
+ list.limit += 1;
+ }
// FIXME: hack for lunch widget, which does useless default_get and onchange
if (command.data) {
return self._applyChange(id, command.data);
@@ -3856,7 +3872,8 @@ var BasicModel = AbstractModel.extend({
// generate the current count and res_ids list by applying the changes
var currentCount = list.count;
var currentResIDs = list.res_ids;
- var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount;
+ var effectiveLimit = (list.limit || 0) - (list.tempLimitIncrement || 0);
+ var upperBound = effectiveLimit ? Math.min(list.offset + effectiveLimit, currentCount) : currentCount;
var fieldNames = list.getFieldNames();
for (var i = list.offset; i < upperBound; i++) {
var resId = currentResIDs[i];
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 b28d25c5b90..f6430d4a251 100644
--- a/addons/web/static/src/js/views/form/form_renderer.js
+++ b/addons/web/static/src/js/views/form/form_renderer.js
@@ -613,6 +613,10 @@ var FormRenderer = BasicRenderer.extend({
var $result = $('<div/>', {class: 'o_group'});
var colSize = Math.max(1, Math.round(12 / (parseInt(node.attrs.col, 10) || 2)));
+ if (node.attrs.string) {
+ var $sep = $('<div/>', {class: 'o_horizontal_separator'}).text(node.attrs.string);
+ $result.append($sep);
+ }
$result.append(_.map(node.children, function (child) {
if (child.tag === 'newline') {
return $('<br/>');
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index 1b74068d44d..d4ae709008c 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -252,6 +252,7 @@ var SelectCreateDialog = ViewDialog.extend({
}
},
selection_changed: function (event) {
+ event.stopPropagation();
this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length);
},
search: function (event) {
diff --git a/addons/web/static/src/less/kanban_view.less b/addons/web/static/src/less/kanban_view.less
index d28221ede63..b9eec962eaf 100644
--- a/addons/web/static/src/less/kanban_view.less
+++ b/addons/web/static/src/less/kanban_view.less
@@ -229,7 +229,7 @@
height: 20px;
}
.oe_kanban_bottom_left {
- .o-flex(1, 0, auto);
+ .o-flex(1, 1, auto);
> * {
margin-right: 6px;
@@ -242,7 +242,7 @@
}
}
.oe_kanban_bottom_right{
- .o-flex(0, 0, auto);
+ .o-flex(0, 1, auto);
.oe_kanban_avatar {
width: 20px;
diff --git a/addons/web/static/src/less/kanban_view_mobile.less b/addons/web/static/src/less/kanban_view_mobile.less
index 72ba087e0c2..d09b6968925 100644
--- a/addons/web/static/src/less/kanban_view_mobile.less
+++ b/addons/web/static/src/less/kanban_view_mobile.less
@@ -29,7 +29,8 @@
}
}
- &.o_kanban_small_column .o_kanban_group:not(.o_column_folded) {
+ // [class] to get same specificity as elsewhere (kanban_view.less)
+ &[class] .o_kanban_group:not(.o_column_folded) {
.o-position-absolute(@top: @o-kanban-mobile-tabs-height, @left: 0, @bottom: 0);
overflow-x: scroll;
width: 100%;
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 748addc4b87..cc046a2bb89 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -209,7 +209,7 @@
<t t-name="WebClient.DebugManager">
<li class="o_debug_manager">
- <a href="#" title="Open Developer Tools" class="dropdown-toggle" data-toggle="dropdown">
+ <a href="#" title="Open Developer Tools" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<span class="fa fa-bug"/>
</a>
<ul class="dropdown-menu o_debug_dropdown" role="menu"/>
@@ -325,12 +325,12 @@
<t t-name="Sidebar">
<t t-foreach="widget.sections" t-as="section">
<div class="btn-group o_dropdown">
- <button t-if="section.name != 'buttons'" class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown">
+ <button t-if="section.name != 'buttons'" class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<t t-if="section.name == 'files'" t-raw="widget.items[section.name].length || ''"/>
<t t-esc="section.label"/> <span class="caret"/>
</button>
<t t-if="section.name == 'buttons'" t-foreach="widget.items[section.name]" t-as="item" t-att-class="item.classname">
- <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url" target="_blank">
+ <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url or '#'" target="_blank">
<t t-raw="item.label"/>
</a>
</t>
@@ -352,7 +352,7 @@
<t t-raw="item.write_uid[1]"/> <t t-esc="item.write_date_string"/>
</t>
</t>
- <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url">
+ <a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url or '#'">
<t t-raw="item.label"/>
<span t-if="section.name == 'files' and widget.options.editable and !item.callback" class="fa fa-trash-o o_sidebar_delete_attachment" t-att-data-id="item.id" title="Delete this attachment"/>
</a>
@@ -469,7 +469,7 @@
</form>
<t t-name="GraphView.buttons">
<div class="btn-group btn-group-sm" role="group">
- <button class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown">
+ <button class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
Measures <span class="caret"/>
</button>
<ul class="dropdown-menu o_graph_measures_list">
@@ -498,7 +498,7 @@
</div>
<t t-name="PivotView.buttons">
<div class="btn-group btn-group-sm" role="group">
- <button class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown">
+ <button class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
Measures <span class="caret"/>
</button>
<ul class="dropdown-menu o_pivot_measures_list">
@@ -852,7 +852,7 @@
<t t-call="FieldStatus.content.button"/>
</li>
</ul>
- <button type="button" class="btn btn-sm o_arrow_button btn-default dropdown-toggle" data-toggle="dropdown">More <span class="caret"/></button>
+ <button type="button" class="btn btn-sm o_arrow_button btn-default dropdown-toggle" data-toggle="dropdown" aria-expanded="false">More <span class="caret"/></button>
</t>
<t t-foreach="selection_unfolded.reverse()" t-as="i">
<t t-call="FieldStatus.content.button"/>
@@ -1011,7 +1011,7 @@
<ul class="dropdown-menu o_searchview_autocomplete" role="menu"></ul>
</t>
<div t-name="SearchView.FilterMenu" class="btn-group o_dropdown">
- <button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown">
+ <button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<span class="fa fa-filter"/> Filters <span class="caret"/>
</button>
<ul class="dropdown-menu o_filters_menu" role="menu">
@@ -1123,7 +1123,7 @@
</select>
</t>
<div t-name="SearchView.GroupByMenu" class="btn-group hidden-xs o_dropdown">
- <button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown">
+ <button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<span class="fa fa-bars"/> Group By <span class="caret"/>
</button>
<ul class="dropdown-menu o_group_by_menu" role="menu">
@@ -1144,7 +1144,7 @@
</li>
</t>
<div t-name="SearchView.FavoriteMenu" class="btn-group o_dropdown">
- <button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown">
+ <button class="o_dropdown_toggler_btn btn btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<span class="fa fa-star"/> Favorites <span class="caret"/>
</button>
<ul class="dropdown-menu o_favorites_menu" role="menu">
@@ -1273,8 +1273,8 @@
<span class="o_stat_text"><t t-esc="text"/></span>
</t>
<t t-name="toggle_button">
- <button type="button" class="o_icon_button" t-att-title="widget.string">
- <i t-attf-class="fa #{widget.icon}" t-att-title="widget.string"/>
+ <button type="button" class="o_icon_button" t-att-title="widget.string" t-att-aria-label="widget.string">
+ <i class="fa fa-circle" t-att-title="widget.string"/>
</button>
</t>
@@ -1288,8 +1288,8 @@
<t t-set="att_prev" t-value="{'accesskey': 'p'}" />
<t t-set="att_next" t-value="{'accesskey': 'n'}" />
</t>
- <button type="button" class="fa fa-chevron-left btn btn-icon o_pager_previous" t-att="att_prev"/>
- <button type="button" class="fa fa-chevron-right btn btn-icon o_pager_next" t-att="att_next"/>
+ <button type="button" class="fa fa-chevron-left btn btn-icon o_pager_previous" t-att="att_prev" aria-label="Previous"/>
+ <button type="button" class="fa fa-chevron-right btn btn-icon o_pager_next" t-att="att_next" aria-label="Next"/>
</span>
</div>
diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js
index 4d46b9167d1..c805da4e67c 100644
--- a/addons/web/static/tests/chrome/action_manager_tests.js
+++ b/addons/web/static/tests/chrome/action_manager_tests.js
@@ -277,6 +277,102 @@ QUnit.module('ActionManager', {
testUtils.unpatch(Widget);
});
+ QUnit.test('no memory leaks when executing an action while loading views', function (assert) {
+ assert.expect(1);
+
+ var def;
+ var delta = 0;
+ testUtils.patch(Widget, {
+ init: function () {
+ delta += 1;
+ this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ delta -= 1;
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'load_views') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // execute action 4 to know the number of widgets it instantiates
+ actionManager.doAction(4);
+ var n = delta;
+
+ // execute a first action (its 'load_views' RPC is blocked)
+ def = $.Deferred();
+ actionManager.doAction(3, {clear_breadcrumbs: true});
+
+ // execute another action meanwhile (and unlock the RPC)
+ actionManager.doAction(4, {clear_breadcrumbs: true});
+ def.resolve();
+
+ assert.strictEqual(n, delta,
+ "all widgets of action 3 should have been destroyed");
+
+ actionManager.destroy();
+ testUtils.unpatch(Widget);
+ });
+
+ QUnit.test('no memory leaks when executing an action while loading data of default view', function (assert) {
+ assert.expect(1);
+
+ var def;
+ var delta = 0;
+ testUtils.patch(Widget, {
+ init: function () {
+ delta += 1;
+ this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ delta -= 1;
+ this._super.apply(this, arguments);
+ },
+ });
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route) {
+ var result = this._super.apply(this, arguments);
+ if (route === '/web/dataset/search_read') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // execute action 4 to know the number of widgets it instantiates
+ actionManager.doAction(4);
+ var n = delta;
+
+ // execute a first action (its 'search_read' RPC is blocked)
+ def = $.Deferred();
+ actionManager.doAction(3, {clear_breadcrumbs: true});
+
+ // execute another action meanwhile (and unlock the RPC)
+ actionManager.doAction(4, {clear_breadcrumbs: true});
+ def.resolve();
+
+ assert.strictEqual(n, delta,
+ "all widgets of action 3 should have been destroyed");
+
+ actionManager.destroy();
+ testUtils.unpatch(Widget);
+ });
+
QUnit.test('action with "no_breadcrumbs" set to true', function (assert) {
assert.expect(2);
@@ -831,6 +927,56 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test('should not push a loaded state of a client action', function (assert) {
+ assert.expect(4);
+
+ var ClientAction = Widget.extend({
+ init: function (parent, action, options) {
+ this._super.apply(this, arguments);
+ this.controllerID = options.controllerID;
+ },
+ start: function () {
+ var self = this;
+ var $button = $('<button>').text('Click Me!');
+ $button.on('click', function () {
+ self.trigger_up('push_state', {
+ controllerID: self.controllerID,
+ state: {someValue: 'X'},
+ });
+ });
+ this.$el.append($button);
+ return this._super.apply(this, arguments);
+ },
+ });
+ core.action_registry.add('ClientAction', ClientAction);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ intercepts: {
+ push_state: function (ev) {
+ assert.step('push_state');
+ assert.deepEqual(ev.data.state, {
+ action: 9,
+ someValue: 'X',
+ title: 'A Client Action',
+ });
+ },
+ },
+ });
+ actionManager.loadState({action: 9});
+
+ assert.verifySteps([], "should not push the loaded state");
+
+ actionManager.$('button').click();
+
+ assert.verifySteps(['push_state'],
+ "should push the state of it changes afterwards");
+
+ actionManager.destroy();
+ });
+
QUnit.test('change a param of an ir.actions.client in the url', function (assert) {
assert.expect(7);
@@ -1253,6 +1399,101 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test('execute a new action while loading views', function (assert) {
+ assert.expect(10);
+
+ var def;
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ assert.step(args.method || route);
+ if (args.method === 'load_views') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // execute a first action (its 'load_views' RPC is blocked)
+ def = $.Deferred();
+ actionManager.doAction(3);
+
+ assert.strictEqual(actionManager.$('.o_list_view').length, 0,
+ "should not display the list view of action 3");
+
+ // execute another action meanwhile (and unlock the RPC)
+ actionManager.doAction(4);
+ def.resolve();
+
+ assert.strictEqual(actionManager.$('.o_kanban_view').length, 1,
+ "should display the kanban view of action 4");
+ assert.strictEqual(actionManager.$('.o_list_view').length, 0,
+ "should not display the list view of action 3");
+ assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1,
+ "there should be one controller in the breadcrumbs");
+
+ assert.verifySteps([
+ '/web/action/load', // action 3
+ 'load_views', // action 3
+ '/web/action/load', // action 4
+ 'load_views', // action 4
+ '/web/dataset/search_read', // search read action 4
+ ]);
+
+ actionManager.destroy();
+ });
+
+ QUnit.test('execute a new action while loading data of default view', function (assert) {
+ assert.expect(11);
+
+ var def;
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ assert.step(args.method || route);
+ if (route === '/web/dataset/search_read') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ });
+
+ // execute a first action (its 'search_read' RPC is blocked)
+ def = $.Deferred();
+ actionManager.doAction(3);
+
+ assert.strictEqual(actionManager.$('.o_list_view').length, 0,
+ "should not display the list view of action 3");
+
+ // execute another action meanwhile (and unlock the RPC)
+ actionManager.doAction(4);
+ def.resolve();
+
+ assert.strictEqual(actionManager.$('.o_kanban_view').length, 1,
+ "should display the kanban view of action 4");
+ assert.strictEqual(actionManager.$('.o_list_view').length, 0,
+ "should not display the list view of action 3");
+ assert.strictEqual($('.o_control_panel .breadcrumb li').length, 1,
+ "there should be one controller in the breadcrumbs");
+
+ assert.verifySteps([
+ '/web/action/load', // action 3
+ 'load_views', // action 3
+ '/web/dataset/search_read', // search read action 3
+ '/web/action/load', // action 4
+ 'load_views', // action 4
+ '/web/dataset/search_read', // search read action 4
+ ]);
+
+ actionManager.destroy();
+ });
+
QUnit.module('Client Actions');
QUnit.test('can execute client actions from tag name', function (assert) {
@@ -2466,6 +2707,31 @@ QUnit.module('ActionManager', {
actionManager.destroy();
});
+ QUnit.test("search menus are still available when switching between actions", function (assert) {
+ assert.expect(3);
+
+ var actionManager = createActionManager({
+ actions: this.actions,
+ archs: this.archs,
+ data: this.data,
+ });
+
+ actionManager.doAction(1);
+ assert.strictEqual($('.o_search_options .o_dropdown:visible .o_filters_menu').length, 1,
+ "the search options should be available");
+
+ actionManager.doAction(3);
+ assert.strictEqual($('.o_search_options .o_dropdown:visible .o_filters_menu').length, 1,
+ "the search options should be available");
+
+ // go back using the breadcrumbs
+ $('.o_control_panel .breadcrumb a:first').click();
+ assert.strictEqual($('.o_search_options .o_dropdown:visible .o_filters_menu').length, 1,
+ "the search options should be available");
+
+ actionManager.destroy();
+ });
+
QUnit.module('Actions in target="new"');
QUnit.test('can execute act_window actions in target="new"', function (assert) {
@@ -2647,7 +2913,6 @@ QUnit.module('ActionManager', {
QUnit.test('doAction resolved with an action', function (assert) {
assert.expect(4);
- var done = assert.async();
this.actions.push({
id: 21,
@@ -2670,7 +2935,6 @@ QUnit.module('ActionManager', {
assert.strictEqual(action.type, 'ir.actions.act_window_close',
"should be resolved with correct action type");
actionManager.destroy();
- done();
});
});
});
diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js
index 11f39bc8147..cc041c08d7b 100644
--- a/addons/web/static/tests/fields/basic_fields_tests.js
+++ b/addons/web/static/tests/fields/basic_fields_tests.js
@@ -387,7 +387,6 @@ QUnit.module('basic_fields', {
'<field name="bar" widget="toggle_button" ' +
'options="{&quot;active&quot;: &quot;Reported in last payslips&quot;, &quot;inactive&quot;: &quot;To Report in Payslip&quot;}"/>' +
'</tree>',
- res_id: 2,
});
assert.strictEqual(list.$('button i.fa.fa-circle.o_toggle_button_success').length, 4,
@@ -411,6 +410,81 @@ QUnit.module('basic_fields', {
list.destroy();
});
+ QUnit.test('toggle_button in form view (edit mode)', function (assert) {
+ assert.expect(6);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="bar" widget="toggle_button" ' +
+ 'options="{\'active\': \'Active value\', \'inactive\': \'Inactive value\'}"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.step('write');
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 2,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ assert.strictEqual(form.$('.o_field_widget[name=bar] i.o_toggle_button_success:not(.text-muted)').length,
+ 1, "should be green");
+
+ // click on the button to toggle the value
+ form.$('.o_field_widget[name=bar]').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name=bar] i.text-muted:not(.o_toggle_button_success)').length,
+ 1, "should be gray");
+ assert.verifySteps([]);
+
+ // save
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name=bar] i.text-muted:not(.o_toggle_button_success)').length,
+ 1, "should still be gray");
+ assert.verifySteps(['write']);
+
+ form.destroy();
+ });
+
+ QUnit.test('toggle_button in form view (readonly mode)', function (assert) {
+ assert.expect(4);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="bar" widget="toggle_button" ' +
+ 'options="{\'active\': \'Active value\', \'inactive\': \'Inactive value\'}"/>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.step('write');
+ }
+ return this._super.apply(this, arguments);
+ },
+ res_id: 2,
+ });
+
+ assert.strictEqual(form.$('.o_field_widget[name=bar] i.o_toggle_button_success:not(.text-muted)').length,
+ 1, "should be green");
+
+ // click on the button to toggle the value
+ form.$('.o_field_widget[name=bar]').click();
+
+ assert.strictEqual(form.$('.o_field_widget[name=bar] i.text-muted:not(.o_toggle_button_success)').length,
+ 1, "should be gray");
+ assert.verifySteps(['write']);
+
+ form.destroy();
+ });
QUnit.module('FieldFloat');
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 7b1ad052196..d3916ef9a50 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -2078,7 +2078,7 @@ QUnit.module('relational_fields', {
data: this.data,
arch: '<form>' +
'<field name="p">' +
- '<tree editable="bttom">' +
+ '<tree editable="bottom">' +
'<field name="trululu"/>' +
'</tree>' +
'</field>' +
@@ -2173,6 +2173,92 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('many2many list add *many* records, remove, re-add', function (assert) {
+ assert.expect(5);
+
+ this.data.partner.fields.timmy.domain = [['color', '=', 2]];
+ this.data.partner.fields.timmy.onChange = true;
+ this.data.partner_type.fields.product_ids = {string: "Product", type: "many2many", relation: 'product'};
+
+ for (var i=0; i<50; i++) {
+ var new_record_partner_type = {id: 100+i, display_name: "batch" + i, color: 2};
+ this.data.partner_type.records.push(new_record_partner_type);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="timmy" widget="many2many">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '<field name="product_ids" widget="many2many_tags"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner_type,false,list': '<tree><field name="display_name"/></tree>',
+ 'partner_type,false,search': '<search><field name="display_name"/><field name="color"/></search>',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'get_formview_id') {
+ assert.deepEqual(args.args[0], [1], "should call get_formview_id with correct id");
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ // First round: add 51 records in batch
+ form.$buttons.find('.btn.btn-primary.btn-sm.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ var $modal = $('.modal-dialog.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ $modal.find('thead input[type=checkbox]').click(); //select all the records we created in batch + 'gold'
+
+ $modal.find('.btn.btn-sm.btn-primary.o_select_button').click();
+
+ var m2m_records = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_data_cell:not(.o_many2many_tags_cell)');
+ assert.equal(m2m_records.length, 51,
+ 'We should have added all the records present in the search view to the m2m field'); // the 50 in batch + 'gold'
+
+ form.$buttons.find('.btn.btn-primary.btn-sm.o_form_button_save').click();
+
+ // Secound round: remove one record
+ form.$buttons.find('.btn.btn-primary.btn-sm.o_form_button_edit').click();
+ var trash_buttons = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_list_record_remove');
+
+ trash_buttons.first().click();
+
+ var pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit');
+ assert.equal(pager_limit.text(), '50',
+ 'We should have 50 records in the m2m field');
+
+ // Third round: re-add 1 records
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ $modal = $('.modal-dialog.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ $modal.find('thead input[type=checkbox]').click();
+
+ $modal.find('.btn.btn-sm.btn-primary.o_select_button').click();
+
+ pager_limit = form.$('.o_field_many2many.o_field_widget.o_field_x2many.o_field_x2many_list .o_pager_limit');
+ assert.equal(pager_limit.text(), '51',
+ 'We should have 51 records in the m2m field');
+
+ form.destroy();
+ });
+
QUnit.module('FieldOne2Many');
QUnit.test('one2many basic properties', function (assert) {
@@ -2784,7 +2870,7 @@ QUnit.module('relational_fields', {
});
QUnit.test('onchange followed by edition on the second page', function (assert) {
- assert.expect(20);
+ assert.expect(12);
var ids = [];
for (var i=1; i<85; i++) {
@@ -2854,10 +2940,32 @@ QUnit.module('relational_fields', {
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#39))').index(), 2, "should display '#39' at the third line");
form.destroy();
+ });
+
+ QUnit.test('onchange followed by edition on the second page (part 2)', function (assert) {
+ assert.expect(8);
+
+ var ids = [];
+ for (var i=1; i<85; i++) {
+ var id = 10 + i;
+ ids.push(id);
+ this.data.turtle.records.push({
+ id: id,
+ turtle_int: id/3|0,
+ turtle_foo: "#" + i,
+ });
+ }
+ ids.splice(41, 0, 1, 2, 3);
+ this.data.partner.records[0].turtles = ids;
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [[5]].concat(obj.turtles);
+ },
+ };
// bottom order
- form = createView({
+ var form = createView({
View: FormView,
model: 'partner',
data: this.data,
@@ -2889,16 +2997,16 @@ QUnit.module('relational_fields', {
form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
+ assert.strictEqual(form.$('.o_data_row').length, 41, "should display 41 records and the create line");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
- assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 39, "should display the create line at the last position");
+ assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 40, "should display the create line at the last position");
form.$('.o_data_row input:first').val('value 3').trigger('input');
form.$('.o_field_x2many_list_row_add a').click();
- assert.strictEqual(form.$('.o_data_row').length, 40, "should display 39 records and the create line");
+ assert.strictEqual(form.$('.o_data_row').length, 42, "should display 42 records and the create line");
assert.strictEqual(form.$('.o_data_row:has(.o_data_cell:contains(#76))').index(), 38, "should display '#76' at the penultimate line");
- assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 39, "should display the create line at the last position");
+ assert.strictEqual(form.$('.o_data_row:has(.o_field_char)').index(), 41, "should display the create line at the last position");
form.destroy();
});
@@ -3979,6 +4087,37 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('editable one2many list, pager is updated', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
+ this.data.partner.records[0].turtles = [1,2,3,4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record, then click in form view to confirm it
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$el.click();
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 5',
+ "pager should display the correct total");
+ form.destroy();
+ });
+
+
QUnit.test('one2many list (non editable): edition', function (assert) {
assert.expect(12);
@@ -4287,6 +4426,185 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('editable one2many list, adding line when only one page', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles=[1,2,3];
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record, to reach the page size limit
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // the record currently being added should not count in the pager
+ assert.ok(!form.$('.o_cp_pager').is(':visible'),
+ "pager should not be visible");
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, adding line, then discarding', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
+ this.data.partner.records[0].turtles = [1,2,3,4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record, then discard
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$buttons.find('.o_form_button_cancel').click();
+
+ // confirm the discard operation
+ $('.modal .modal-footer .btn-primary').click(); // click on confirm
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should still be visible");
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, required field and pager', function (assert) {
+ assert.expect(1);
+
+ this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.partner.records[0].turtles = [1,2,3,4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a (empty) record
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // go on next page. The new record is not valid and should be discarded
+ form.$('.o_pager_next').click();
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data rows");
+
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, required field, pager and confirm discard', function (assert) {
+ assert.expect(3);
+
+ this.data.turtle.records.push({id:4, turtle_foo: 'stephen hawking'});
+ this.data.turtle.fields.turtle_foo.required = true;
+ this.data.partner.records[0].turtles = [1,2,3,4];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '<field name="turtle_int"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a record with a dirty state, but not valid
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('input[name="turtle_int"]').val(4321).trigger('input');
+
+ // go to next page. The new record is not valid, but dirty. we should
+ // see a confirm dialog
+ form.$('.o_pager_next').click();
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should still display the correct total");
+
+ // click on cancel
+ $('.modal .modal-footer .btn-default').click(); // click on cancel
+
+ assert.strictEqual(form.$('.o_cp_pager').text().trim(), '1-3 / 4',
+ "pager should again display the correct total");
+ assert.strictEqual(form.$('.o_field_one2many input.o_field_invalid').length, 1,
+ "there should be an invalid input in the one2many");
+ form.destroy();
+ });
+
+ QUnit.test('editable one2many list, adding, discarding, and pager', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ // add a 4 records record (to make the pager appear)
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ // go on next page
+ form.$('.o_pager_next').click();
+
+ // discard
+ form.$buttons.find('.o_form_button_cancel').click();
+ $('.modal .modal-footer .btn-primary').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 1,
+ "should have 1 data row");
+ assert.ok(!form.$('.o_cp_pager').is(':visible'),
+ "pager should not be visible");
+
+ form.destroy();
+ });
+
QUnit.test('unselecting a line with missing required data', function (assert) {
assert.expect(5);
@@ -7602,6 +7920,42 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('editing tabbed one2many (editable=bottom), again...', function (assert) {
+ assert.expect(1);
+
+ this.data.partner.records[0].turtles = [];
+ for (var i = 0; i < 9; i++) {
+ var id = 100 + i;
+ this.data.turtle.records.push({id: id, turtle_foo: 'turtle' + (id-99)});
+ this.data.partner.records[0].turtles.push(id);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree editable="bottom" limit="3">' +
+ '<field name="turtle_foo"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ });
+
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+ form.$('.o_x2m_control_panel .o_pager_next').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 3,
+ "should have 3 data rows on the current page");
+ form.destroy();
+ });
+
QUnit.test('editing tabbed one2many (editable=top)', function (assert) {
assert.expect(15);
@@ -8217,6 +8571,49 @@ QUnit.module('relational_fields', {
form.destroy();
});
+ QUnit.test('add a new line after limit is reached should behave nicely', function (assert) {
+ assert.expect(2);
+
+ this.data.partner.records[0].turtles = [1,2,3];
+
+ this.data.partner.onchanges = {
+ turtles: function (obj) {
+ obj.turtles = [
+ [5],
+ [1, 1, {turtle_foo: "yop"}],
+ [1, 2, {turtle_foo: "blip"}],
+ [1, 3, {turtle_foo: "kawa"}],
+ [0, obj.turtles[3][2], {turtle_foo: "abc"}],
+ ];
+ },
+ };
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'<form string="Partners">' +
+ '<field name="turtles">' +
+ '<tree limit="3" editable="bottom">' +
+ '<field name="turtle_foo" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {
+ mode: 'edit',
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual(form.$('.o_data_row').length, 4, 'should have 4 data rows');
+ form.$('.o_input[name="turtle_foo"]').val('a').trigger('input');
+ assert.strictEqual(form.$('.o_data_row').length, 4,
+ 'should still have 4 data rows (the limit is increased to 4)');
+
+ form.destroy();
+ });
+
QUnit.test('onchange in a one2Many, but not in main record, with non inline view', function (assert) {
assert.expect(6);
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 2a64a2f30da..65b8ab5da38 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -5198,6 +5198,46 @@ QUnit.module('Views', {
form.destroy();
});
+ QUnit.test('outer and inner groups string attribute', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<sheet>' +
+ '<group string="parent group" class="parent_group">' +
+ '<group string="child group 1" class="group_1">' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '<group string="child group 2" class="group_2">' +
+ '<field name="bar"/>' +
+ '</group>' +
+ '</group>' +
+ '</sheet>' +
+ '</form>',
+ res_id: 1,
+ });
+
+ var $parentGroup = form.$('.parent_group');
+ var $group1 = form.$('.group_1');
+ var $group2 = form.$('.group_2');
+
+ assert.strictEqual(form.$('table.o_inner_group').length, 2,
+ "should contain two inner groups");
+ assert.strictEqual($group1.find('.o_horizontal_separator').length, 1,
+ "inner group should contain one string separator");
+ assert.strictEqual($group1.find('.o_horizontal_separator:contains(child group 1)').length, 1,
+ "first inner group should contain 'child group 1' string");
+ assert.strictEqual($group2.find('.o_horizontal_separator:contains(child group 2)').length, 1,
+ "second inner group should contain 'child group 2' string");
+ assert.strictEqual($parentGroup.find('> div.o_horizontal_separator:contains(parent group)').length, 1,
+ "outer group should contain 'parent group' string");
+
+ form.destroy();
+ });
+
QUnit.test('form group with newline tag inside', function (assert) {
assert.expect(6);
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
index 6ec4f50ab4b..39484795f5a 100644
--- a/addons/web/static/tests/views/view_dialogs_tests.js
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -5,6 +5,9 @@ var dialogs = require('web.view_dialogs');
var ListController = require('web.ListController');
var testUtils = require('web.test_utils');
var Widget = require('web.Widget');
+var FormView = require('web.FormView');
+
+var createView = testUtils.createView;
QUnit.module('Views', {
beforeEach: function () {
@@ -14,6 +17,7 @@ QUnit.module('Views', {
display_name: { string: "Displayed name", type: "char" },
foo: {string: "Foo", type: 'char'},
bar: {string: "Bar", type: "boolean"},
+ instrument: {string: 'Instruments', type: 'many2one', relation: 'instrument'},
},
records: [
{id: 1, foo: 'blip', display_name: 'blipblip', bar: true},
@@ -21,8 +25,34 @@ QUnit.module('Views', {
{id: 3, foo: 'piou piou', display_name: "Jack O'Neill", bar: true},
],
},
+ instrument: {
+ fields: {
+ name: {string: "name", type: "char"},
+ badassery: {string: 'level', type: 'many2many', relation: 'badassery', domain: [['level', '=', 'Awsome']]},
+ },
+ },
+
+ badassery: {
+ fields: {
+ level: {string: 'level', type: "char"},
+ },
+ records: [
+ {id: 1, level: 'Awsome'},
+ ],
+ },
+
+ product: {
+ fields : {
+ name: {string: "name", type: "char" },
+ partner : {string: 'Doors', type: 'one2many', relation: 'partner'},
+ },
+ records: [
+ {id: 1, name: 'The end'},
+ ],
+ },
};
},
+
}, function () {
QUnit.module('view_dialogs');
@@ -242,6 +272,98 @@ QUnit.module('Views', {
parent.destroy();
});
+ QUnit.test('SelectCreateDialog cascade x2many in create mode', function (assert) {
+ assert.expect(5);
+
+ var form = createView({
+ View: FormView,
+ model: 'product',
+ data: this.data,
+ arch: '<form>' +
+ '<field name="name"/>' +
+ '<field name="partner" widget="one2many_list" >' +
+ '<tree editable="top">' +
+ '<field name="display_name"/>' +
+ '<field name="instrument"/>' +
+ '</tree>' +
+ '</field>' +
+ '</form>',
+ res_id: 1,
+ archs: {
+ 'partner,false,form': '<form>' +
+ '<field name="name"/>' +
+ '<field name="instrument" widget="one2many_list" mode="tree"/>' +
+ '</form>',
+
+ 'instrument,false,form': '<form>'+
+ '<field name="name"/>'+
+ '<field name="badassery">' +
+ '<tree>'+
+ '<field name="level"/>'+
+ '</tree>' +
+ '</field>' +
+ '</form>',
+
+ 'badassery,false,list': '<tree>'+
+ '<field name="level"/>'+
+ '</tree>',
+
+ 'badassery,false,search': '<search>'+
+ '<field name="level"/>'+
+ '</search>',
+ },
+
+ mockRPC: function(route, args) {
+ if (route === '/web/dataset/call_kw/partner/get_formview_id') {
+ return $.when(false);
+ }
+ if (route === '/web/dataset/call_kw/instrument/get_formview_id') {
+ return $.when(false);
+ }
+ if (route === '/web/dataset/call_kw/instrument/create') {
+ assert.deepEqual(args.args, [{badassery: [[6, false, [1]]], name: false}],
+ 'The method create should have been called with the right arguments');
+ return $.when(false);
+ }
+ return this._super(route, args);
+ },
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+ form.$('.o_field_widget .o_field_many2one[name=instrument] input').click();
+ $('ul.ui-autocomplete.ui-front.ui-menu.ui-widget.ui-widget-content li.o_m2o_dropdown_option').first().click();
+
+ var $modal = $('.modal-dialog.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ $modal.find('.o_field_x2many_list_row_add a').click();
+
+ var $modals = $('.modal-dialog.modal-lg');
+
+ assert.equal($modals.length, 2,
+ 'There should be two modals');
+
+ var $second_modal = $modals.not($modal);
+ $second_modal.find('.o_list_view.table.table-condensed.table-striped.o_list_view_ungrouped .o_data_row input[type=checkbox]').click();
+
+ $second_modal.find('.o_select_button').click();
+
+ $modal = $('.modal-dialog.modal-lg');
+
+ assert.equal($modal.length, 1,
+ 'There should be one modal');
+
+ assert.equal($modal.find('.o_data_cell').text(), 'Awsome',
+ 'There should be one item in the list of the modal');
+
+ $modal.find('.btn.btn-sm.btn-primary').click();
+
+ form.destroy();
+ });
+
QUnit.test('SelectCreateDialog: save current search', function (assert) {
assert.expect(4);