diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js
index e28fd896182..b300cb22cf4 100644
--- a/addons/web/static/src/js/chrome/abstract_action.js
+++ b/addons/web/static/src/js/chrome/abstract_action.js
@@ -9,12 +9,14 @@ odoo.define('web.AbstractAction', function (require) {
*/
var ActionMixin = require('web.ActionMixin');
-var ControlPanelView = require('web.ControlPanelView');
+var ControlPanel = require('web.ControlPanel');
+var ControlPanelModel = require('web.ControlPanelModel');
var Widget = require('web.Widget');
+const { ComponentWrapper } = require('web.OwlCompatibility');
var AbstractAction = Widget.extend(ActionMixin, {
config: {
- ControlPanelView: ControlPanelView,
+ ControlPanel: ControlPanel,
},
/**
@@ -33,7 +35,7 @@ var AbstractAction = Widget.extend(ActionMixin, {
* For example, the Discuss application adds the following line in its
* constructor::
*
- * this.controlPanelParams.modelName = 'mail.message';
+ * this.controlPanelModelConfig.modelName = 'mail.message';
*
* @type boolean
*/
@@ -73,15 +75,25 @@ var AbstractAction = Widget.extend(ActionMixin, {
init: function (parent, action, options) {
this._super(parent);
this._title = action.display_name || action.name;
- this.controlPanelParams = {
- actionId: action.id,
- context: action.context,
- breadcrumbs: options && options.breadcrumbs || [],
- title: this.getTitle(),
- viewId: action.search_view_id && action.search_view_id[0],
- withSearchBar: this.withSearchBar,
- searchMenuTypes: this.searchMenuTypes,
- };
+
+ if (this.hasControlPanel) {
+ this.controlPanelModelConfig = {
+ actionId: action.id,
+ actionContext: action.context || {},
+ actionDomain: action.domain || [],
+ env: owl.Component.env,
+ withSearchBar: this.withSearchBar,
+ };
+
+ this.viewId = action.search_view_id && action.search_view_id[0];
+
+ this.controlPanelProps = {
+ action,
+ breadcrumbs: options && options.breadcrumbs,
+ withSearchBar: this.withSearchBar,
+ searchMenuTypes: this.searchMenuTypes,
+ };
+ }
},
/**
* The willStart method is actually quite complicated if the client action
@@ -89,40 +101,73 @@ var AbstractAction = Widget.extend(ActionMixin, {
*
* @override
*/
- willStart: function () {
- var self = this;
- var proms = [this._super.apply(this, arguments)];
+ willStart: async function () {
+ const proms = [this._super(...arguments)];
if (this.hasControlPanel) {
- var params = this.controlPanelParams;
if (this.loadControlPanel) {
- proms.push(this
- .loadFieldView(params.modelName, params.context || {}, params.viewId, 'search')
- .then(function (fieldsView) {
- params.viewInfo = {
- arch: fieldsView.arch,
- fields: fieldsView.fields,
- };
- }));
+ const { context, searchMenuTypes } = this.controlPanelProps;
+ const { modelName } = this.controlPanelModelConfig;
+ const options = { load_filters: searchMenuTypes.includes('favorite') };
+ const args = [modelName, context || {}, this.viewId, 'search', options];
+ const loadFieldViewPromise = this.loadFieldView(...args);
+ const {arch, fields, favoriteFilters } = await loadFieldViewPromise;
+ this.controlPanelModelConfig.viewInfo = {arch, fields, favoriteFilters };
+ this.controlPanelProps.fields = fields;
}
- return Promise.all(proms).then(function () {
- var controlPanelView = new self.config.ControlPanelView(params);
- return controlPanelView.getController(self).then(function (controlPanel) {
- self._controlPanel = controlPanel;
- return self._controlPanel.appendTo(document.createDocumentFragment());
- });
- });
+ this._controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig);
+ this.controlPanelProps.controlPanelModel = this._controlPanelModel;
+ proms.push(this._controlPanelModel.isReady);
}
return Promise.all(proms);
},
/**
* @override
*/
- start: function () {
- if (this._controlPanel) {
- this._controlPanel.$el.prependTo(this.$el);
+ start: async function () {
+ await this._super(...arguments);
+ if (this.hasControlPanel) {
+ if ('title' in this.controlPanelProps) {
+ this._setTitle(this.controlPanelProps.title);
+ }
+ this.controlPanelProps.title = this.getTitle();
+ this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps);
+ await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
+
}
- return this._super.apply(this, arguments);
},
+ /**
+ * @override
+ */
+ destroy: function() {
+ this._super.apply(this, arguments);
+ ActionMixin.destroy.call(this);
+ },
+ /**
+ * @override
+ */
+ on_attach_callback: function () {
+ ActionMixin.on_attach_callback.call(this);
+ if (this.hasControlPanel) {
+ this._controlPanelModel.on('search', this, this._onSearch);
+ this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
+ }
+ },
+ /**
+ * @override
+ */
+ on_detach_callback: function () {
+ ActionMixin.on_detach_callback.call(this);
+ if (this.hasControlPanel) {
+ this._controlPanelModel.off('search', this);
+ this._controlPanelModel.off('get-controller-query-params', this);
+ }
+ },
+
+ /**
+ * @private
+ * @param {Object} [searchQuery]
+ */
+ _onSearch: function () {},
});
return AbstractAction;
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 8837ffe813e..9012b7f53fd 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -32,9 +32,7 @@ var _t = core._t;
var AbstractWebClient = Widget.extend(KeyboardNavigationMixin, {
dependencies: ['notification'],
- events: _.extend({}, KeyboardNavigationMixin.events, {
- 'click .o_search_options .dropdown-menu': '_onClickDropDownMenu',
- }),
+ events: _.extend({}, KeyboardNavigationMixin.events),
custom_events: {
call_service: '_onCallService',
clear_uncommitted_changes: function (e) {
@@ -356,16 +354,6 @@ var AbstractWebClient = Widget.extend(KeyboardNavigationMixin, {
const result = service[payload.method].apply(service, args);
payload.callback(result);
},
- /**
- * When clicking inside a dropdown to modify search options
- * prevents the bootstrap dropdown to close on itself
- *
- * @private
- * @param {Event} ev
- */
- _onClickDropDownMenu: function (ev) {
- ev.stopPropagation();
- },
/**
* Whenever the connection is lost, we need to notify the user.
*
diff --git a/addons/web/static/src/js/chrome/action_manager.js b/addons/web/static/src/js/chrome/action_manager.js
index ee108c94326..92c090827d8 100644
--- a/addons/web/static/src/js/chrome/action_manager.js
+++ b/addons/web/static/src/js/chrome/action_manager.js
@@ -877,7 +877,7 @@ var ActionManager = Widget.extend({
/**
* @private
* @param {OdooEvent} ev
- * @param {OdooEvent} ev.data.controllerID
+ * @param {string} ev.data.controllerID
*/
_onBreadcrumbClicked: function (ev) {
ev.stopPropagation();
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 ff060153f27..f6c5661eb8e 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
@@ -546,6 +546,7 @@ ActionManager.include({
return Promise.resolve(controller.widget.willRestore()).then(function () {
viewOptions = _.extend({}, viewOptions, {
breadcrumbs: self._getBreadcrumbs(self.controllerStack.slice(0, index)),
+ shouldUpdateControlPanel: true,
});
return controller.widget.reload(viewOptions).then(function () {
return controller;
@@ -708,15 +709,15 @@ ActionManager.include({
*/
_onSwitchView: function (ev) {
ev.stopPropagation();
- var viewType = ev.data.view_type;
- var currentController = this.getCurrentController();
+ const viewType = ev.data.view_type;
+ const currentController = this.getCurrentController();
if (currentController.jsID === ev.data.controllerID) {
// only switch to the requested view if the controller that
// triggered the request is the current controller
- var action = this.actions[currentController.actionID];
- var currentControllerState = currentController.widget.exportState();
+ const action = this.actions[currentController.actionID];
+ const currentControllerState = currentController.widget.exportState();
action.controllerState = _.extend({}, action.controllerState, currentControllerState);
- var options = {
+ const options = {
controllerState: action.controllerState,
currentId: ev.data.res_id,
};
diff --git a/addons/web/static/src/js/chrome/action_mixin.js b/addons/web/static/src/js/chrome/action_mixin.js
index a704e5736a0..99f45405c72 100644
--- a/addons/web/static/src/js/chrome/action_mixin.js
+++ b/addons/web/static/src/js/chrome/action_mixin.js
@@ -18,11 +18,13 @@ odoo.define('web.ActionMixin', function (require) {
* action manager.
*
* @module web.ActionMixin
+ * @extends WidgetAdapterMixin
*/
const core = require('web.core');
+ const { WidgetAdapterMixin } = require('web.OwlCompatibility');
- const ActionMixin = {
+ const ActionMixin = Object.assign({}, WidgetAdapterMixin, {
template: 'Action',
/**
@@ -41,7 +43,7 @@ odoo.define('web.ActionMixin', function (require) {
* will also implement the ParentedMixin which actually manages those
*/
custom_events: {
- get_controller_query_params: '_onGetControllerQueryParams',
+ get_controller_query_params: '_onGetOwnedQueryParams',
},
/**
@@ -63,16 +65,6 @@ odoo.define('web.ActionMixin', function (require) {
*/
_title: '',
- /**
- * Called each time the action is attached into the DOM.
- */
- on_attach_callback: function () { },
-
- /**
- * Called each time the action is detached from the DOM.
- */
- on_detach_callback: function () { },
-
/**
* @override
*/
@@ -177,21 +169,40 @@ odoo.define('web.ActionMixin', function (require) {
renderButtons: function ($node) { },
/**
- * This is the main method to customize the controlpanel content.
- *
- * @see updateContents method in ControlPanelRenderer for more info
- *
- * @param {Object} [status]
- * @param {string} [status.title]
- * @param {Object} [options]
- * @param {boolean} [options.clear]
+ * Method used to update the widget buttons state.
*/
- updateControlPanel: function (status, options) {
- if (this._controlPanel) {
- status = status || {};
- status.title = status.title || this.getTitle();
- this._controlPanel.updateContents(status, options || {});
+ updateButtons: function () { },
+
+ /**
+ * The parameter newProps is used to update the props of
+ * the controlPanelWrapper before render it. The key 'cp_content'
+ * is not a prop of the control panel itself. One should if possible use
+ * the slot mechanism.
+ *
+ * @param {Object} [newProps={}]
+ * @returns {Promise}
+ */
+ updateControlPanel: async function (newProps = {}) {
+ if (!this.withControlPanel && !this.hasControlPanel) {
+ return;
}
+ const props = Object.assign({}, newProps); // Work with a clean new object
+ if ('title' in props) {
+ this._setTitle(props.title);
+ this.controlPanelProps.title = this.getTitle();
+ delete props.title;
+ }
+ if ('cp_content' in props) {
+ // cp_content has been updated: refresh it.
+ this.controlPanelProps.cp_content = Object.assign({},
+ this.controlPanelProps.cp_content,
+ props.cp_content,
+ );
+ delete props.cp_content;
+ }
+ // Update props state
+ Object.assign(this.controlPanelProps, props);
+ return this._controlPanelWrapper.update(this.controlPanelProps);
},
//---------------------------------------------------------------------
@@ -204,7 +215,6 @@ odoo.define('web.ActionMixin', function (require) {
*/
_setTitle: function (title) {
this._title = title;
- this.updateControlPanel({ title: this._title }, { clear: false });
},
//---------------------------------------------------------------------
@@ -218,15 +228,13 @@ odoo.define('web.ActionMixin', function (require) {
* current controller.
*
* @private
- * @param {OdooEvent} ev
- * @param {function} ev.data.callback used to send the requested state
+ * @param {function} callback used to send the requested state
*/
- _onGetControllerQueryParams: function (ev) {
- ev.stopPropagation();
+ _onGetOwnedQueryParams: function (callback) {
const state = this.getOwnedQueryParams();
- ev.data.callback(state || {});
+ callback(state || {});
},
- };
+ });
return ActionMixin;
});
diff --git a/addons/web/static/src/js/components/custom_file_input.js b/addons/web/static/src/js/components/custom_file_input.js
index eb9c4267cfc..14f521a4ed6 100644
--- a/addons/web/static/src/js/components/custom_file_input.js
+++ b/addons/web/static/src/js/components/custom_file_input.js
@@ -82,7 +82,7 @@ odoo.define('web.CustomFileInput', function (require) {
model: { type: String, optional: 1 },
multi_upload: { type: Boolean, optional: 1 },
};
- CustomFileInput.template = 'CustomFileInput';
+ CustomFileInput.template = 'web.CustomFileInput';
return CustomFileInput;
});
diff --git a/addons/web/static/src/js/core/custom_hooks.js b/addons/web/static/src/js/core/custom_hooks.js
index 69750066a8c..883f385ac3c 100644
--- a/addons/web/static/src/js/core/custom_hooks.js
+++ b/addons/web/static/src/js/core/custom_hooks.js
@@ -5,35 +5,37 @@ odoo.define('web.custom_hooks', function () {
const { onMounted, onPatched, onWillUnmount } = hooks;
/**
- * Returns a function which purpose is to focus the given selector on the next
- * repaint (mount or patch). Its default selector is the first element having
- * an `autofocus' attribute. Text selection will be set at the end of the value
- * if the target is a text element. The action is lost if no element was found.
- *
- * @returns {Function}
+ * Focus a given selector as soon as it appears in the DOM and if it was not
+ * displayed before. If the selected target is an input|textarea, set the selection
+ * at the end.
+ * @param {Object} [params]
+ * @param {string} [params.selector='autofocus'] default: select the first element
+ * with an `autofocus` attribute.
+ * @returns {Function} function that forces the focus on the next update if visible.
*/
- function useFocusOnUpdate() {
- const component = Component.current;
- let willFocus = null;
-
- function _focusSelector() {
- if (willFocus) {
- const target = component.el.querySelector(willFocus);
- if (target) {
- target.focus();
- if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
- target.selectionStart = target.selectionEnd = target.value.length;
- }
+ function useAutofocus(params = {}) {
+ const comp = Component.current;
+ // Prevent autofocus in mobile
+ if (comp.env.device.isMobile) {
+ return () => {};
+ }
+ const selector = params.selector || '[autofocus]';
+ let target = null;
+ function autofocus() {
+ const prevTarget = target;
+ target = comp.el.querySelector(selector);
+ if (target && target !== prevTarget) {
+ target.focus();
+ if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
+ target.selectionStart = target.selectionEnd = target.value.length;
}
- willFocus = null;
}
}
+ onMounted(autofocus);
+ onPatched(autofocus);
- onMounted(_focusSelector);
- onPatched(_focusSelector);
-
- return function focusOnUpdate(selector = '[autofocus]') {
- willFocus = selector;
+ return function focusOnUpdate() {
+ target = null;
};
}
@@ -110,7 +112,7 @@ odoo.define('web.custom_hooks', function () {
}
return {
- useFocusOnUpdate,
+ useAutofocus,
useListener,
};
});
diff --git a/addons/web/static/src/js/core/owl_dialog.js b/addons/web/static/src/js/core/owl_dialog.js
index d376e042ae4..daa29468934 100644
--- a/addons/web/static/src/js/core/owl_dialog.js
+++ b/addons/web/static/src/js/core/owl_dialog.js
@@ -163,7 +163,7 @@ odoo.define('web.OwlDialog', function () {
$(this.mainButton)
.tooltip({
delay: { show: 200, hide: 0 },
- title: () => this.env.qweb.renderToString('DialogButton.tooltip', {
+ title: () => this.env.qweb.renderToString('web.DialogButton.tooltip', {
title: this.mainButton.innerText.toUpperCase(),
}),
trigger: 'manual',
@@ -263,7 +263,7 @@ odoo.define('web.OwlDialog', function () {
technical: Boolean,
title: String,
};
- Dialog.template = 'OwlDialog';
+ Dialog.template = 'web.OwlDialog';
return Dialog;
});
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index 705a0c872fa..667c2a1b6ca 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -16,7 +16,7 @@ odoo.define('web.relational_fields', function (require) {
var AbstractField = require('web.AbstractField');
var basicFields = require('web.basic_fields');
var concurrency = require('web.concurrency');
-var ControlPanelView = require('web.ControlPanelView');
+const ControlPanelX2Many = require('web.ControlPanelX2Many');
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
@@ -26,7 +26,7 @@ const Domain = require('web.Domain');
var KanbanRecord = require('web.KanbanRecord');
var KanbanRenderer = require('web.KanbanRenderer');
var ListRenderer = require('web.ListRenderer');
-var Pager = require('web.Pager');
+const { ComponentWrapper, WidgetAdapterMixin } = require('web.OwlCompatibility');
var _t = core._t;
var _lt = core._lt;
@@ -963,7 +963,7 @@ var KanbanFieldMany2One = AbstractField.extend({
// X2Many widgets
//------------------------------------------------------------------------------
-var FieldX2Many = AbstractField.extend({
+var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
tagName: 'div',
custom_events: _.extend({}, AbstractField.prototype.custom_events, {
add_record: '_onAddRecord',
@@ -980,6 +980,7 @@ var FieldX2Many = AbstractField.extend({
navigation_move: '_onNavigationMove',
save_optional_fields: '_onSaveOrLoadOptionalFields',
load_optional_fields: '_onSaveOrLoadOptionalFields',
+ pager_changed: '_onPagerChanged',
}),
// We need to trigger the reset on every changes to be aware of the parent changes
@@ -1006,6 +1007,21 @@ var FieldX2Many = AbstractField.extend({
this.isMany2Many = this.field.type === 'many2many' || this.attrs.widget === 'many2many';
this.activeActions = {};
this.recordParams = {fieldName: this.name, viewType: this.viewType};
+ // The limit is fixed so it cannot be changed by adding/removing lines in
+ // the widget. It will only change through a hard reload or when manually
+ // changing the pager (see _onPagerChanged).
+ this.pagingState = {
+ currentMinimum: this.value.offset + 1,
+ limit: this.value.limit,
+ size: this.value.count,
+ validate: () => {
+ // TODO: we should have some common method in the basic renderer...
+ return this.view.arch.tag === 'tree' ?
+ this.renderer.unselectRow() :
+ Promise.resolve();
+ },
+ withAccessKey: false,
+ };
var arch = this.view && this.view.arch;
if (arch) {
this.activeActions.create = arch.attrs.create ?
@@ -1024,8 +1040,21 @@ var FieldX2Many = AbstractField.extend({
/**
* @override
*/
- start: function () {
- return this._renderControlPanel().then(this._super.bind(this));
+ start: async function () {
+ const _super = this._super.bind(this);
+ if (this.view) {
+ this._renderButtons();
+ this._controlPanelWrapper = new ComponentWrapper(this, ControlPanelX2Many, {
+ cp_content: { $buttons: this.$buttons },
+ pager: this.pagingState,
+ });
+ await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
+ }
+ return _super(...arguments);
+ },
+ destroy: function () {
+ WidgetAdapterMixin.destroy.call(this);
+ this._super();
},
/**
* For the list renderer to properly work, it must know if it is in the DOM,
@@ -1033,6 +1062,7 @@ var FieldX2Many = AbstractField.extend({
*/
on_attach_callback: function () {
this.isInDOM = true;
+ WidgetAdapterMixin.on_attach_callback.call(this);
if (this.renderer) {
this.renderer.on_attach_callback();
}
@@ -1042,6 +1072,10 @@ var FieldX2Many = AbstractField.extend({
*/
on_detach_callback: function () {
this.isInDOM = false;
+ WidgetAdapterMixin.on_detach_callback.call(this);
+ if (this.renderer) {
+ this.renderer.on_detach_callback();
+ }
},
//--------------------------------------------------------------------------
@@ -1116,7 +1150,10 @@ var FieldX2Many = AbstractField.extend({
}
}
return this._super.apply(this, arguments).then(() => {
- this._updateControlPanel();
+ if (this.view) {
+ this._renderButtons();
+ this._updateControlPanel();
+ }
});
},
@@ -1249,8 +1286,8 @@ var FieldX2Many = AbstractField.extend({
addTrashIcon: this._hasTrashIcon(),
columnInvisibleFields: this.currentColInvisibleFields,
keepWidths: true,
- }).then(function () {
- self.pager.updateState({ size: self.value.count });
+ }).then(() => {
+ this._updateControlPanel({ size: this.value.count });
});
}
var arch = this.view.arch;
@@ -1302,59 +1339,6 @@ var FieldX2Many = AbstractField.extend({
return this._super();
}
},
- /**
- * Instanciates a control panel with the appropriate buttons and a pager.
- * Prepends the control panel's $el to this widget's $el.
- *
- * @private
- * @returns {Promise}
- */
- _renderControlPanel: function () {
- if (!this.view) {
- return Promise.resolve();
- }
- var self = this;
- var defs = [];
- var controlPanelView = new ControlPanelView({
- template: 'X2ManyControlPanel',
- withSearchBar: false,
- });
- var cpDef = controlPanelView.getController(this).then(function (controlPanel) {
- self._controlPanel = controlPanel;
- return self._controlPanel.prependTo(self.$el);
- });
- 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() : Promise.resolve();
- },
- });
- this.pager.on('pager_changed', this, function (new_state) {
- self.trigger_up('load', {
- id: self.value.id,
- limit: new_state.limit,
- offset: new_state.current_min - 1,
- on_success: function (value) {
- self.value = value;
- self._render();
- },
- });
- });
- this._renderButtons();
- defs.push(this.pager.appendTo($('
'))); // start the pager
- defs.push(cpDef);
- return Promise.all(defs).then(function () {
- self._controlPanel.updateContents({
- cp_content: {
- $buttons: self.$buttons,
- $pager: self.pager.$el,
- }
- });
- });
- },
/**
* Renders the buttons and sets this.$buttons.
*
@@ -1395,8 +1379,8 @@ var FieldX2Many = AbstractField.extend({
} else {
self.renderer.setRowMode(recordID, 'readonly').then(resolve);
}
- }).then(function () {
- self.pager.updateState({ size: self.value.count });
+ }).then(async function () {
+ self._updateControlPanel({ size: self.value.count });
var newEval = self._evalColumnInvisibleFields();
if (!_.isEqual(self.currentColInvisibleFields, newEval)) {
self.currentColInvisibleFields = newEval;
@@ -1409,18 +1393,17 @@ var FieldX2Many = AbstractField.extend({
/**
* Re-renders buttons and updates the control panel. This method is called
* when the widget is reset, as the available buttons might have changed.
+ * The only mutable element in X2Many fields will be the pager.
*
* @private
*/
- _updateControlPanel: function () {
- if (this._controlPanel) {
- this._renderButtons();
- const params = {
- cp_content: {
- $buttons: this.$buttons,
- }
+ _updateControlPanel: function (pagingState) {
+ if (this._controlPanelWrapper) {
+ const newProps = {
+ cp_content: { $buttons: this.$buttons },
+ pager: Object.assign(this.pagingState, pagingState),
};
- this._controlPanel.updateContents(params, { clear: false });
+ return this._controlPanelWrapper.update(newProps);
}
},
/**
@@ -1576,6 +1559,30 @@ var FieldX2Many = AbstractField.extend({
_onOpenRecord: function () {
// to implement
},
+ /**
+ * We re-render the pager immediately with the new event values to allow
+ * it to request another pager change while another one is still ongoing.
+ * @see field_manager_mixin for concurrency handling.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onPagerChanged: function (ev) {
+ ev.stopPropagation();
+ const { currentMinimum, limit } = ev.data;
+ this._updateControlPanel({ currentMinimum, limit });
+ this.trigger_up('load', {
+ id: this.value.id,
+ limit,
+ offset: currentMinimum - 1,
+ on_success: value => {
+ this.value = value;
+ this.pagingState.limit = value.limit;
+ this.pagingState.size = value.count;
+ this._render();
+ },
+ });
+ },
/**
* Called when the renderer ask to save a line (the user tries to leave it)
* -> Nothing is to "save" here, the model was already notified of the line
@@ -1777,7 +1784,7 @@ var FieldOne2Many = FieldX2Many.extend({
*/
reset: function (record, ev) {
var self = this;
- return this._super.apply(this, arguments).then(function () {
+ return this._super.apply(this, arguments).then(() => {
if (ev && ev.target === self && ev.data.changes && self.view.arch.tag === 'tree') {
if (ev.data.changes[self.name] && ev.data.changes[self.name].operation === 'CREATE') {
var index = 0;
@@ -1792,7 +1799,7 @@ var FieldOne2Many = FieldX2Many.extend({
// 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});
+ self._updateControlPanel({ size: self.value.count - 1 });
}
var newID = self.value.data[index].id;
self.renderer.editRecord(newID);
@@ -1832,7 +1839,7 @@ var FieldOne2Many = FieldX2Many.extend({
*/
_renderButtons: function () {
if (this.activeActions.create) {
- this._super.apply(this, arguments);
+ return this._super(...arguments);
}
},
/**
diff --git a/addons/web/static/src/js/report/client_action.js b/addons/web/static/src/js/report/client_action.js
index c8b5621e6de..9bd7c037d6e 100644
--- a/addons/web/static/src/js/report/client_action.js
+++ b/addons/web/static/src/js/report/client_action.js
@@ -37,7 +37,7 @@ var ReportAction = AbstractAction.extend({
start: function () {
var self = this;
this.iframe = this.$('iframe')[0];
- return Promise.all([this._super.apply(this, arguments), session.is_bound]).then(function () {
+ return Promise.all([this._super.apply(this, arguments), session.is_bound]).then(async function () {
var web_base_url = session['web.base.url'];
var trusted_host = utils.get_host_from_url(web_base_url);
var trusted_protocol = utils.get_protocol_from_url(web_base_url);
@@ -46,7 +46,11 @@ var ReportAction = AbstractAction.extend({
self.$buttons = $(QWeb.render('report.client_action.ControlButtons', {}));
self.$buttons.on('click', '.o_report_print', self.on_click_print);
- self._update_control_panel();
+ await self.updateControlPanel({
+ cp_content: {
+ $buttons: self.$buttons,
+ },
+ });
// Load the report in the iframe. Note that we use a relative URL.
self.iframe.src = self.report_url;
@@ -54,7 +58,11 @@ var ReportAction = AbstractAction.extend({
},
do_show: function () {
- this._update_control_panel();
+ this.updateControlPanel({
+ cp_content: {
+ $buttons: this.$buttons,
+ },
+ });
return this._super.apply(this, arguments);
},
@@ -69,14 +77,6 @@ var ReportAction = AbstractAction.extend({
$(window).off('message', this.on_message_received);
},
- _update_control_panel: function () {
- this.updateControlPanel({
- cp_content: {
- $buttons: this.$buttons,
- },
- });
- },
-
/**
* Event handler of the message post. We only handle them if they're from
* `web.base.url` host and protocol and if they're part of `AUTHORIZED_MESSAGES`.
diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js
index 45374d4833b..12d7ce24f8d 100644
--- a/addons/web/static/src/js/services/data_manager.js
+++ b/addons/web/static/src/js/services/data_manager.js
@@ -99,26 +99,28 @@ return core.Class.extend({
},
model: model,
method: 'load_views',
- }).then(function (result) {
+ }).then(async result => {
// Freeze the fields dict as it will be shared between views and
// no one should edit it
utils.deepFreeze(result.fields);
// Insert views into the fields_views cache
- _.each(views_descr, function (view_descr) {
+ for (const view_descr of views_descr) {
var toolbar = options.toolbar && view_descr[1] !== 'search';
var fv_key = self._gen_key(model, view_descr[0], view_descr[1], toolbar, context);
var fvg = result.fields_views[view_descr[1]];
+ if (view_descr[1] === 'search') {
+ fvg.favoriteFilters = result.filters;
+ }
fvg.viewFields = fvg.fields;
fvg.fields = result.fields;
self._cache.fields_views[fv_key] = Promise.resolve(fvg);
- });
+ }
// Insert filters, if any, into the filters cache
if (result.filters) {
self._cache.filters[filters_key] = Promise.resolve(result.filters);
}
-
return result.fields_views;
}, this._invalidate.bind(this, this._cache.views, key));
}
diff --git a/addons/web/static/src/js/tools/debug_manager_backend.js b/addons/web/static/src/js/tools/debug_manager_backend.js
index 2c445ff745e..b9f211872eb 100644
--- a/addons/web/static/src/js/tools/debug_manager_backend.js
+++ b/addons/web/static/src/js/tools/debug_manager_backend.js
@@ -245,7 +245,7 @@ DebugManager.include({
action: this._action,
can_edit: this._can_edit_views,
controller: this._controller,
- controlPanelView: this._controller && this._controller._controlPanel,
+ withControlPanel: this._controller && this._controller.withControlPanel,
manager: this,
view: this._controller && _.findWhere(this._action.views, {
type: this._controller.viewType,
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index aa612cbea01..09451cdcbfa 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -15,86 +15,93 @@ odoo.define('web.AbstractController', function (require) {
var ActionMixin = require('web.ActionMixin');
var ajax = require('web.ajax');
var concurrency = require('web.concurrency');
-var config = require('web.config');
-var core = require('web.core');
+const { ComponentWrapper } = require('web.OwlCompatibility');
+const ControlPanel = require('web.ControlPanel');
var mvc = require('web.mvc');
-var { WidgetAdapterMixin } = require('web.OwlCompatibility');
-
var session = require('web.session');
-var QWeb = core.qweb;
-var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin, {
+var AbstractController = mvc.Controller.extend(ActionMixin, {
custom_events: _.extend({}, ActionMixin.custom_events, {
navigation_move: '_onNavigationMove',
open_record: '_onOpenRecord',
- search: '_onSearch',
- switch_view: '_onSwitchView',
search_panel_domain_updated: '_onSearchPanelDomainUpdated',
+ switch_view: '_onSwitchView',
}),
events: {
'click a[type="action"]': '_onActionClicked',
},
/**
- * @override
- * @param {string} params.modelName
- * @param {string} [params.controllerID] an id to ease the communication
- * with upstream components
- * @param {ControlPanelController} [params.controlPanel]
- * @param {any} [params.handle] a handle that will be given to the model (some id)
- * @param {boolean} params.isMultiRecord
+ * @param {Object} param
* @param {Object[]} params.actionViews
+ * @param {string} params.activeActions
+ * @param {string} params.bannerRoute
+ * @param {Array[]} params.controlPanelDomain
+ * @param {ControlPanelModel} [params.controlPanelModel]
+ * @param {Object} [params.controlPanelProps]
+ * @param {string} params.controllerID an id to ease the communication with
+ * upstream components
+ * @param {string} params.displayName
+ * @param {Object} params.initialState
+ * @param {string} params.modelName
+ * @param {string} [params.searchPanel]
* @param {string} params.viewType
+ * @param {boolean} [params.withControlPanel]
+ * @param {boolean} [params.withSearchPanel]
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
- this._controlPanel = params.controlPanel;
this._title = params.displayName;
this.modelName = params.modelName;
this.activeActions = params.activeActions;
this.controllerID = params.controllerID;
this.initialState = params.initialState;
this.bannerRoute = params.bannerRoute;
- this.isMultiRecord = params.isMultiRecord;
this.actionViews = params.actionViews;
this.viewType = params.viewType;
// use a DropPrevious to correctly handle concurrent updates
this.dp = new concurrency.DropPrevious();
+ this.withControlPanel = params.withControlPanel;
+ if (this.withControlPanel) {
+ this.controlPanelProps = params.controlPanelProps;
+ this._controlPanelModel = params.controlPanelModel;
+ }
+
+ this.withSearchPanel = params.withSearchPanel && params.searchPanel;
// the following attributes are used when there is a searchPanel
- this._searchPanel = params.searchPanel;
+ if (this.withSearchPanel) {
+ this._searchPanel = params.searchPanel;
+ }
this.controlPanelDomain = params.controlPanelDomain || [];
this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : [];
},
+
/**
* Simply renders and updates the url.
*
* @returns {Promise}
*/
- start: function () {
- var self = this;
- if (this._searchPanel) {
+ start: async function () {
+ if (this.withSearchPanel) {
this.$('.o_content')
.addClass('o_controller_with_searchpanel')
.prepend(this._searchPanel.$el);
}
-
this.$el.addClass('o_view_controller');
- return this._super.apply(this, arguments).then(function () {
- var prom;
- if (self._controlPanel) {
- // render the ControlPanel elements (buttons, pager, sidebar...)
- prom = self._renderControlPanelElements().then(function (elements) {
- self.controlPanelElements = elements;
- self._controlPanel.$el.prependTo(self.$el);
- });
- }
- return Promise.resolve(prom);
- }).then(function () {
- return self._update(self.initialState);
- });
+ this.renderButtons();
+ const promises = [this._super(...arguments)];
+ if (this.withControlPanel) {
+ this._updateControlPanelProps(this.initialState);
+ this._controlPanelWrapper = new ComponentWrapper(this, ControlPanel, this.controlPanelProps);
+ this._controlPanelWrapper.env.bus.on('focus-view', this, () => this.renderer.giveFocus());
+ promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
+ }
+ await Promise.all(promises);
+ await this._update(this.initialState, { shouldUpdateControlPanel: false });
+ this.updateButtons();
},
/**
* @override
@@ -103,34 +110,33 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
if (this.$buttons) {
this.$buttons.off();
}
- if (this.controlPanelElements && this.controlPanelElements.$switch_buttons) {
- this.controlPanelElements.$switch_buttons.off();
- }
+ ActionMixin.destroy.call(this);
this._super.apply(this, arguments);
- WidgetAdapterMixin.destroy.call(this, ...arguments);
},
/**
* Called each time the controller is attached into the DOM.
*/
on_attach_callback: function () {
- if (this._controlPanel) {
- this._controlPanel.on_attach_callback();
- }
- if (this._searchPanel) {
+ ActionMixin.on_attach_callback.call(this);
+ if (this.withSearchPanel) {
this._searchPanel.on_attach_callback();
}
+ if (this.withControlPanel) {
+ this._controlPanelModel.on('search', this, this._onSearch);
+ this._controlPanelModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
+ }
this.renderer.on_attach_callback();
- WidgetAdapterMixin.on_attach_callback.call(this, ...arguments);
},
/**
* Called each time the controller is detached from the DOM.
*/
on_detach_callback: function () {
- if (this._controlPanel) {
- this._controlPanel.on_detach_callback();
+ ActionMixin.on_detach_callback.call(this);
+ if (this.withControlPanel) {
+ this._controlPanelModel.off('search', this);
+ this._controlPanelModel.off('get-controller-query-params', this);
}
this.renderer.on_detach_callback();
- WidgetAdapterMixin.on_detach_callback.call(this, ...arguments);
},
//--------------------------------------------------------------------------
@@ -174,10 +180,10 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
*/
exportState: function () {
var state = {};
- if (this._controlPanel) {
- state.cpState = this._controlPanel.exportState();
+ if (this.withControlPanel) {
+ state.cpState = this._controlPanelModel.exportState();
}
- if (this._searchPanel) {
+ if (this.withSearchPanel) {
state.spState = this._searchPanel.exportState();
}
return state;
@@ -203,21 +209,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
/**
* Short helper method to reload the view
*
- * @param {Object} [params] This object will simply be given to the update
+ * @param {Object} [params={}] This object will simply be given to the update
* @returns {Promise}
*/
- reload: async function (params) {
- params = params || {};
- var searchPanelUpdateProm;
- var controllerState = params.controllerState || {};
- var cpState = controllerState.cpState;
- if (this._controlPanel && cpState) {
- await this._controlPanel.importState(cpState).then(function (searchQuery) {
- params = _.extend({}, params, searchQuery);
- });
+ reload: async function (params = {}) {
+ let searchPanelUpdateProm;
+ const controllerState = params.controllerState || {};
+ const cpState = controllerState.cpState;
+ if (this.withControlPanel && cpState) {
+ this._controlPanelModel.importState(cpState);
+ const searchQuery = this._controlPanelModel.getQuery();
+ params = Object.assign({}, params, searchQuery);
}
- var postponeRendering = false;
- if (this._searchPanel) {
+ let postponeRendering = false;
+ if (this.withSearchPanel) {
this.controlPanelDomain = params.domain || this.controlPanelDomain;
if (controllerState.spState) {
this._searchPanel.importState(controllerState.spState);
@@ -234,28 +239,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
return this.renderer._render();
}
},
- /**
- * For views that require a pager, this method will be called to allow the
- * controller to instantiate and render a pager. Note that in theory, the
- * controller can actually render whatever he wants in the pager zone. If
- * your view does not want a pager, just let this method empty.
- *
- * @param {jQuery Node} $node
- * @return {Promise}
- */
- renderPager: function ($node) {
- return Promise.resolve();
- },
- /**
- * Same as renderPager, but for the 'sidebar' zone (the zone with the menu
- * dropdown in the control panel next to the buttons)
- *
- * @param {jQuery Node} $node
- * @return {Promise}
- */
- renderSidebar: function ($node) {
- return Promise.resolve();
- },
/**
* This is the main entry point for the controller. Changes from the search
* view arrive in this method, and internal changes can sometimes also call
@@ -271,34 +254,21 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
*
* @returns {Promise}
*/
- update: function (params, options) {
- var self = this;
- var shouldReload = (options && 'reload' in options) ? options.reload : true;
- var def = shouldReload ? this.model.reload(this.handle, params) : Promise.resolve();
- // we check here that the updateIndex of the control panel hasn't changed
- // between the start of the update request and the moment the controller
- // asks the control panel to update itself ; indeed, it could happen that
- // another action/controller is executed during this one reloads itself,
- // and if that one finishes first, it replaces this controller in the DOM,
- // and this controller should no longer update the control panel.
- // note that this won't be necessary as soon as each controller will have
- // its own control panel
- var cpUpdateIndex = this._controlPanel && this._controlPanel.updateIndex;
- return this.dp.add(def).then(function (handle) {
- if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
- return;
- }
- self.handle = handle || self.handle; // update handle if we reloaded
- var state = self.model.get(self.handle);
- var localState = self.renderer.getLocalState();
- return self.dp.add(self.updateRendererState(state, params)).then(function () {
- if (self._controlPanel && cpUpdateIndex !== self._controlPanel.updateIndex) {
- return;
- }
- self.renderer.setLocalState(localState);
- return self._update(state, params);
- });
- });
+ update: async function (params, options = {}) {
+ const shouldReload = 'reload' in options ? options.reload : true;
+ if (shouldReload) {
+ this.handle = await this.dp.add(this.model.reload(this.handle, params));
+ }
+ const localState = this.renderer.getLocalState();
+ const state = this.model.get(this.handle);
+ const promises = [
+ this.updateRendererState(state, params).then(() => {
+ this.renderer.setLocalState(localState);
+ }),
+ this._update(state, params),
+ ];
+ await this.dp.add(Promise.all(promises));
+ this.updateButtons();
},
/**
* Update the state of the renderer (handle both Widget and Component
@@ -318,6 +288,16 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
// Private
//--------------------------------------------------------------------------
+
+ /**
+ * Meant to be overriden to return a proper object.
+ * @private
+ * @param {Object} [state]
+ * @return {(Object|null)}
+ */
+ _getPagingInfo: function (state) {
+ return null;
+ },
/**
* Return the current search domain. This is the searchDomain used to update
* the searchpanel. It returns the domain coming from the controlpanel. This
@@ -330,6 +310,15 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
_getSearchDomain: function () {
return this.controlPanelDomain;
},
+ /**
+ * Meant to be overriden to return a proper object.
+ * @private
+ * @param {Object} [state]
+ * @return {(Object|null)}
+ */
+ _getActionMenuItems: function (state) {
+ return null;
+ },
/**
* This method is the way a view can notifies the outside world that
* something has changed. The main use for this is to update the url, for
@@ -360,107 +349,33 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin,
* @private
* @returns {Promise}
*/
- _renderBanner: function () {
+ _renderBanner: async function () {
if (this.bannerRoute !== undefined) {
- var self = this;
- return this.dp
- .add(this._rpc({route: this.bannerRoute}))
- .then(function (response) {
- if (!response.html) {
- self.$el.removeClass('o_has_banner');
- return Promise.resolve();
- }
- self.$el.addClass('o_has_banner');
- var $banner = $(response.html);
- // we should only display one banner at a time
- if (self._$banner && self._$banner.remove) {
- self._$banner.remove();
- }
- // Css and js are moved to
- var defs = [];
- $('link[rel="stylesheet"]', $banner).each(function (i, link) {
- defs.push(ajax.loadCSS(link.href));
- link.remove();
- });
- $('script[type="text/javascript"]', $banner).each(function (i, js) {
- defs.push(ajax.loadJS(js.src));
- js.remove();
- });
- return Promise.all(defs).then(function () {
- $banner.insertBefore(self.$('> .o_content'));
- self._$banner = $banner;
- });
- });
- }
- return Promise.resolve();
- },
- /**
- * Renders the control elements (buttons, pager and sidebar) of the current
- * view.
- *
- * @private
- * @returns {Promise