From 18e5416636b19b0953b1e6e26bfaeab1ebecce4c Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Wed, 11 Mar 2020 09:38:28 +0000 Subject: [PATCH] [REF] web: Adapt views/actions to new control panel Some web actions/controllers/viewsl have been adapted to properly instantiate the newly refactored control panel and update it accordingly. We needed to adapt the starting mechanism of actions and controllers to ensure that the control panel instantiation did not delay the loading of any widget. Part of task 2196029 Co-authored-by: Mathieu Duckerts-Antoine --- .../static/src/js/chrome/abstract_action.js | 115 +++-- .../src/js/chrome/abstract_web_client.js | 14 +- .../static/src/js/chrome/action_manager.js | 2 +- .../js/chrome/action_manager_act_window.js | 11 +- .../web/static/src/js/chrome/action_mixin.js | 72 ++-- .../src/js/components/custom_file_input.js | 2 +- addons/web/static/src/js/core/custom_hooks.js | 52 +-- addons/web/static/src/js/core/owl_dialog.js | 4 +- .../static/src/js/fields/relational_fields.js | 155 +++---- .../web/static/src/js/report/client_action.js | 22 +- .../static/src/js/services/data_manager.js | 10 +- .../src/js/tools/debug_manager_backend.js | 2 +- .../src/js/views/abstract_controller.js | 403 ++++++++---------- .../web/static/src/js/views/abstract_view.js | 175 ++++---- .../src/js/views/basic/basic_controller.js | 154 +++---- .../static/src/js/views/basic/basic_model.js | 2 +- .../src/js/views/basic/basic_renderer.js | 14 + .../js/views/calendar/calendar_controller.js | 54 +-- .../src/js/views/form/form_controller.js | 322 ++++++-------- .../static/src/js/views/form/form_renderer.js | 3 - .../web/static/src/js/views/form/form_view.js | 12 +- .../src/js/views/graph/graph_controller.js | 268 ++++++++---- .../static/src/js/views/graph/graph_model.js | 112 +++-- .../static/src/js/views/graph/graph_view.js | 79 ++-- .../src/js/views/kanban/kanban_controller.js | 83 ++-- .../src/js/views/kanban/kanban_model.js | 2 +- .../static/src/js/views/kanban/kanban_view.js | 7 +- .../src/js/views/list/list_controller.js | 227 +++++----- .../static/src/js/views/list/list_renderer.js | 121 +++--- .../web/static/src/js/views/list/list_view.js | 6 +- .../src/js/views/pivot/pivot_controller.js | 67 ++- .../static/src/js/views/pivot/pivot_model.js | 87 ++-- .../static/src/js/views/pivot/pivot_view.js | 6 +- .../web/static/src/js/views/qweb/qweb_view.js | 13 +- .../web/static/src/js/views/search_panel.js | 33 +- .../web/static/src/js/views/view_dialogs.js | 1 + addons/web/static/src/js/widgets/pie_chart.js | 3 + addons/web/static/src/xml/debug.xml | 2 +- 38 files changed, 1304 insertions(+), 1413 deletions(-) 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} resolved with an object containing the control - * panel jQuery elements - */ - _renderControlPanelElements: function () { - var self = this; - var elements = { - $buttons: $('
'), - $sidebar: $('
'), - $pager: $('
'), - }; - - this.renderButtons(elements.$buttons); - var sidebarProm = this.renderSidebar(elements.$sidebar); - var pagerProm = this.renderPager(elements.$pager); - - return Promise.all([sidebarProm, pagerProm]).then(function () { - // remove the unnecessary outer div - elements = _.mapObject(elements, function ($node) { - return $node && $node.contents(); + const response = await this._rpc({route: this.bannerRoute}); + if (!response.html) { + this.$el.removeClass('o_has_banner'); + return Promise.resolve(); + } + this.$el.addClass('o_has_banner'); + var $banner = $(response.html); + // we should only display one banner at a time + if (this._$banner && this._$banner.remove) { + this._$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(); }); - elements.$switch_buttons = self._renderSwitchButtons(); - - return elements; - }); - }, - /** - * Renders the switch buttons and binds listeners on them. - * - * @private - * @returns {jQuery} - */ - _renderSwitchButtons: function () { - var self = this; - var views = _.filter(this.actionViews, {multiRecord: this.isMultiRecord}); - - if (views.length <= 1) { - return $(); + $('script[type="text/javascript"]', $banner).each(function (i, js) { + defs.push(ajax.loadJS(js.src)); + js.remove(); + }); + await Promise.all(defs); + $banner.insertBefore(this.$('> .o_content')); + this._$banner = $banner; } - - var template = config.device.isMobile ? 'ControlPanel.SwitchButtons.Mobile' : 'ControlPanel.SwitchButtons'; - var $switchButtons = $(QWeb.render(template, { - viewType: this.viewType, - views: views, - })); - // create bootstrap tooltips - _.each(views, function (view) { - $switchButtons.filter('.o_cp_switch_' + view.type).tooltip(); - }); - // add onclick event listener - var $switchButtonsFiltered = config.device.isMobile ? $switchButtons.find('button') : $switchButtons.filter('button'); - $switchButtonsFiltered.click(_.debounce(function (event) { - var viewType = $(event.target).data('view-type'); - self.trigger_up('switch_view', {view_type: viewType}); - }, 200, true)); - - // set active view's icon as view switcher button's icon in mobile - if (config.device.isMobile) { - var activeView = _.findWhere(views, {type: this.viewType}); - $switchButtons.find('.o_switch_view_button_icon').addClass('fa fa-lg ' + activeView.icon); - } - - return $switchButtons; }, /** * @override @@ -483,21 +398,57 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin, * * @private * @param {Object} state the state given by the model - * @param {Object} [params] - * @param {Object[]} [params.breadcrumbs] + * @param {Object} [params={}] + * @param {Object} [params.shouldUpdateControlPanel] * @returns {Promise} */ _update: function (state, params) { // AAB: update the control panel -> this will be moved elsewhere at some point - var cpContent = _.extend({}, this.controlPanelElements); - this.updateControlPanel({ - breadcrumbs: params && params.breadcrumbs, - cp_content: cpContent, + if (!this.$buttons) { + this.renderButtons(); + } + const promises = [this._renderBanner()]; + if (this.withControlPanel && params.shouldUpdateControlPanel !== false) { + this._updateControlPanelProps(state); + if (params.breadcrumbs) { + this.controlPanelProps.breadcrumbs = params.breadcrumbs; + } + promises.push(this.updateControlPanel()); + } + this._pushState(); + return Promise.all(promises); + }, + /** + * Can be used to update the key 'cp_content'. This method is called in start and _update methods. + * + * @private + * @param {Object} state the state given by the model + */ + _updateControlPanelProps(state) { + if (!this.controlPanelProps.cp_content) { + this.controlPanelProps.cp_content = {}; + } + if (this.$buttons) { + this.controlPanelProps.cp_content.$buttons = this.$buttons; + } + Object.assign(this.controlPanelProps, { + actionMenus: this._getActionMenuItems(state), + pager: this._getPagingInfo(state), title: this.getTitle(), }); - - this._pushState(); - return this._renderBanner(); + }, + /** + * @private + * @param {Object} state + * @param {Object} newProps + * @returns {Promise} + */ + _updatePaging: async function (state, newProps) { + const pagingInfo = this._getPagingInfo(state); + if (pagingInfo) { + Object.assign(pagingInfo, newProps); + return this.updateControlPanel({ pager: pagingInfo }); + } }, //-------------------------------------------------------------------------- @@ -574,7 +525,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin, switch (ev.data.direction) { case 'up': ev.stopPropagation(); - this._controlPanel.focusSearchBar(); + this._controlPanelModel.trigger('focus-control-panel'); break; case 'down': ev.stopPropagation(); @@ -608,17 +559,13 @@ var AbstractController = mvc.Controller.extend(ActionMixin, WidgetAdapterMixin, }, /** * Called when there is a change in the search view, so the current action's - * environment needs to be updated with the new domain, context and groupby. + * environment needs to be updated with the new domain, context, groupby,... * * @private - * @param {OdooEvent} ev - * @param {Array[]} ev.data.domain - * @param {Object} ev.data.context - * @param {string[]} ev.data.groupby + * @param {Object} searchQuery */ - _onSearch: function (ev) { - ev.stopPropagation(); - this.reload(_.extend({offset: 0, groupsOffset: 0}, ev.data)); + _onSearch: function (searchQuery) { + this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery)); }, /** * @private diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index 7dc3cc30563..c720a2a515e 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -26,11 +26,13 @@ odoo.define('web.AbstractView', function (require) { var AbstractModel = require('web.AbstractModel'); var AbstractRenderer = require('web.AbstractRenderer'); var AbstractController = require('web.AbstractController'); -var ControlPanelView = require('web.ControlPanelView'); +const ControlPanelModel = require('web.ControlPanelModel'); var mvc = require('web.mvc'); var SearchPanel = require('web.SearchPanel'); var viewUtils = require('web.viewUtils'); +const { Component } = owl; + var Factory = mvc.Factory; var AbstractView = Factory.extend({ @@ -83,7 +85,7 @@ var AbstractView = Factory.extend({ * @param {string} [params.displayName] * @param {Array[]} [params.domain=[]] * @param {Object[]} [params.dynamicFilters] transmitted to the - * ControlPanelView + * ControlPanel * @param {number[]} [params.ids] * @param {boolean} [params.isEmbedded=false] * @param {Object} [params.searchQuery={}] @@ -143,9 +145,10 @@ var AbstractView = Factory.extend({ controllerID: params.controllerID, displayName: params.displayName, isEmbedded: isEmbedded, - isMultiRecord: this.multi_record, modelName: params.modelName, viewType: this.viewType, + withControlPanel: this.withControlPanel, + withSearchPanel: this.withSearchPanel, }; var controllerState = params.controllerState || {}; @@ -159,10 +162,6 @@ var AbstractView = Factory.extend({ res_id: currentId, res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined), }; - // default_order is like: - // 'name,id desc' - // but we need it like: - // [{name: 'id', asc: false}, {name: 'name', asc: true}] var defaultOrder = this.arch.attrs.default_order; if (defaultOrder) { this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) { @@ -174,26 +173,50 @@ var AbstractView = Factory.extend({ this._updateMVCParams(params.searchQuery); } - this.controlPanelParams = { - action: action, - activateDefaultFavorite: params.activateDefaultFavorite, - dynamicFilters: params.dynamicFilters, - breadcrumbs: params.breadcrumbs, - context: this.loadParams.context, - domain: this.loadParams.domain, - modelName: params.modelName, - searchMenuTypes: params.searchMenuTypes, - state: controllerState.cpState, - viewInfo: params.controlPanelFieldsView, - withBreadcrumbs: params.withBreadcrumbs, - withSearchBar: params.withSearchBar, - }; - this.searchPanelParams = { - defaultNoFilter: params.searchPanelDefaultNoFilter, - fields: this.fields, - model: this.loadParams.modelName, - state: controllerState.spState, - }; + if (this.withControlPanel) { + this.controlPanelModelConfig = { + env: Component.env, + actionId: action.id, + actionContext: Object.assign({}, this.loadParams.context || {}), + actionDomain: this.loadParams.domain || [], + modelName: params.modelName, + // control initialization + activateDefaultFavorite: params.activateDefaultFavorite, + dynamicFilters: params.dynamicFilters, + viewInfo: params.controlPanelFieldsView, + withSearchBar: params.withSearchBar, + // used to avoid timeRanges in query + searchMenuTypes: params.searchMenuTypes, + // avoid work to initialize + importedState: controllerState.cpState, + }; + + const controlPanelModel = new ControlPanelModel(this.controlPanelModelConfig); + + const controlPanelProps = { + action, + breadcrumbs: params.breadcrumbs, + controlPanelModel, + fields: this.fields, + searchMenuTypes: params.searchMenuTypes, + view: this.fieldsView, + views: action.views && action.views.filter(v => v.multiRecord === this.multi_record), + withBreadcrumbs: params.withBreadcrumbs, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.controlPanelModel = controlPanelModel; + this.controllerParams.controlPanelProps = controlPanelProps; + } + + if (this.withSearchPanel) { + this.searchPanelParams = { + arch: (params.controlPanelFieldsView || {}).arch, + defaultNoFilter: params.searchPanelDefaultNoFilter, + fields: this.fields, + model: this.loadParams.modelName, + state: controllerState.spState, + }; + } }, //-------------------------------------------------------------------------- @@ -203,41 +226,36 @@ var AbstractView = Factory.extend({ /** * @override */ - getController: function (parent) { - var self = this; - var cpDef = this.withControlPanel && this._createControlPanel(parent); - var spDef; + getController: async function (parent) { + const _super = this._super.bind(this); + if (this.withControlPanel) { + await this.controllerParams.controlPanelModel.isReady; + const query = this.controllerParams.controlPanelModel.getQuery(); + this._updateMVCParams(query); + } + let searchPanel = false; if (this.withSearchPanel) { - var spProto = this.config.SearchPanel.prototype; - var viewInfo = this.controlPanelParams.viewInfo; - var searchPanelParams = spProto.computeSearchPanelParams(viewInfo, this.viewType); + const spProto = this.config.SearchPanel.prototype; + const { arch, fields } = this.searchPanelParams; + const searchPanelParams = spProto.computeSearchPanelParams(arch, fields, this.viewType); if (searchPanelParams.sections) { this.searchPanelParams.sections = searchPanelParams.sections; this.rendererParams.withSearchPanel = true; - spDef = Promise.resolve(cpDef).then(this._createSearchPanel.bind(this, parent, searchPanelParams)); + searchPanel = await this._createSearchPanel(parent, searchPanelParams); } } - - var _super = this._super.bind(this); - return Promise.all([cpDef, spDef]).then(function ([controlPanel, searchPanel]) { - // get the parent of the model if it already exists, as _super will - // set the new controller as parent, which we don't want - var modelParent = self.model && self.model.getParent(); - var prom = _super(parent); - prom.then(function (controller) { - if (controlPanel) { - controlPanel.setParent(controller); - } - if (searchPanel) { - searchPanel.setParent(controller); - } - if (modelParent) { - // if we already add a model, restore its parent - self.model.setParent(modelParent); - } - }); - return prom; - }); + // get the parent of the model if it already exists, as _super will + // set the new controller as parent, which we don't want + const modelParent = this.model && this.model.getParent(); + const controller = await _super(...arguments); + if (searchPanel) { + searchPanel.setParent(controller); + } + if (modelParent) { + // if we already add a model, restore its parent + this.model.setParent(modelParent); + } + return controller; }, /** * Ensures that only one instance of AbstractModel is created @@ -264,25 +282,6 @@ var AbstractView = Factory.extend({ // Private //-------------------------------------------------------------------------- - /** - * Instantiates and starts a ControlPanelController. - * - * @private - * @param {Widget} parent - * @returns {Promise} resolved when the controlPanel - * is ready - */ - _createControlPanel: function (parent) { - var self = this; - var controlPanelView = new ControlPanelView(this.controlPanelParams); - return controlPanelView.getController(parent).then(function (controlPanel) { - self.controllerParams.controlPanel = controlPanel; - return controlPanel.appendTo(document.createDocumentFragment()).then(function () { - self._updateMVCParams(controlPanel.getSearchQuery()); - return controlPanel; - }); - }); - }, /** * @private * @param {Widget} parent @@ -373,33 +372,23 @@ var AbstractView = Factory.extend({ * @private * @param {Object} searchQuery * @param {Object} searchQuery.context - * @param {Object} [searchQuery.context.timeRangeMenuData={}] - * @param {Array[]} [searchQuery.context.timeRangeMenuData.comparisonTimeRange=[]] - * @param {string} [searchQuery.context.timeRangeMenuData.comparisonTimeRangeDescription=''] - * @param {string} [searchQuery.context.timeRangeMenuData.timeRangeDescription=''] - * @param {Array[]} [searchQuery.context.timeRangeMenuData.timeRange=[]] + * @param {Object} [searchQuery.timeRanges] * @param {Array[]} searchQuery.domain * @param {string[]} searchQuery.groupBy */ _updateMVCParams: function (searchQuery) { - var timeRangeMenuData = searchQuery.context.timeRangeMenuData || {}; - var comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; - var comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || ''; - var timeRangeDescription = timeRangeMenuData.timeRangeDescription || ''; this.loadParams = _.extend(this.loadParams, { - compare: comparisonTimeRange.length > 0, - comparisonField: timeRangeMenuData.comparisonField, - comparisonTimeRange: comparisonTimeRange, - comparisonTimeRangeDescription: comparisonTimeRangeDescription, context: searchQuery.context, domain: searchQuery.domain, groupedBy: searchQuery.groupBy, - timeRange: timeRangeMenuData.timeRange || [], - timeRangeDescription: timeRangeMenuData.timeRangeDescription || '', }); - this.loadParams.orderedBy = searchQuery.orderedBy ? searchQuery.orderedBy : this.loadParams.orderedBy; - this.rendererParams.timeRangeDescription = timeRangeDescription; - this.rendererParams.comparisonTimeRangeDescription = comparisonTimeRangeDescription; + this.loadParams.orderedBy = Array.isArray(searchQuery.orderedBy) && searchQuery.orderedBy.length ? + searchQuery.orderedBy : + this.loadParams.orderedBy; + if (searchQuery.timeRanges) { + this.loadParams.timeRanges = searchQuery.timeRanges; + this.rendererParams.timeRanges = searchQuery.timeRanges; + } }, }); diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 58e03efb94a..8b4672bc291 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -11,7 +11,6 @@ var AbstractController = require('web.AbstractController'); var core = require('web.core'); var Dialog = require('web.Dialog'); var FieldManagerMixin = require('web.FieldManagerMixin'); -var Pager = require('web.Pager'); var TranslationDialog = require('web.TranslationDialog'); var _t = core._t; @@ -19,12 +18,12 @@ var _t = core._t; var BasicController = AbstractController.extend(FieldManagerMixin, { custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { discard_changes: '_onDiscardChanges', + pager_changed: '_onPagerChanged', reload: '_onReload', resequence_records: '_onResequenceRecords', set_dirty: '_onSetDirty', load_optional_fields: '_onLoadOptionalFields', save_optional_fields: '_onSaveOptionalFields', - sidebar_data_asked: '_onSidebarDataAsked', translate: '_onTranslate', }), /** @@ -52,12 +51,11 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @override * @returns {Promise} */ - start: function () { + start: async function () { // add classname to reflect the (absence of) access rights (used to // correctly display the nocontent helper) this.$el.toggleClass('o_cannot_create', !this.activeActions.create); - return this._super.apply(this, arguments) - .then(this._updateEnv.bind(this)); + await this._super(...arguments); }, //-------------------------------------------------------------------------- @@ -148,35 +146,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { isDirty: function (recordID) { return this.model.isDirty(recordID || this.handle); }, - /** - * @override - */ - renderPager: function ($node, options) { - var self = this; - var params = this._getPagerParams(); - this.pager = new Pager(this, params.size, params.current_min, params.limit, options); - - this.pager.on('pager_changed', this, function (newState) { - this.pager.disable(); - var data = this.model.get(this.handle, {raw: true}); - var limitChanged = (data.limit !== newState.limit); - var reloadParams; - if (data.groupedBy && data.groupedBy.length) { - reloadParams = {groupsLimit: newState.limit, groupsOffset: newState.current_min - 1}; - } else { - reloadParams = {limit: newState.limit, offset: newState.current_min - 1}; - } - this.reload(reloadParams).then(function () { - // reset the scroll position to the top on page changed only - if (!limitChanged) { - self.trigger_up('scrollTo', {top: 0}); - } - }).then(this.pager.enable.bind(this.pager)); - }); - return this.pager.appendTo($node).then(function () { - self._updatePager(); // to force proper visibility - }); - }, /** * Saves the record whose ID is given if necessary (@see _saveRecord). * @@ -218,13 +187,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @override * @returns {Promise} */ - update: function (params, options) { - var self = this; + update: async function (params, options) { this.mode = params.mode || this.mode; - return this._super(params, options).then(function () { - self._updateEnv(); - self._updatePager(); - }); + return this._super(params, options); }, /** * @override @@ -486,44 +451,32 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { return viewIdentifier; }, /** - * Return the params (current_min, limit and size) to pass to the pager, + * Return the params (currentMinimum, limit and size) to pass to the pager, * according to the current state. * * @private * @returns {Object} */ - _getPagerParams: function () { - var state = this.model.get(this.handle, {raw: true}); - var isGrouped = state.groupedBy && state.groupedBy.length; + _getPagingInfo: function (state) { + const isGrouped = state.groupedBy && state.groupedBy.length; return { - current_min: (isGrouped ? state.groupsOffset : state.offset) + 1, + currentMinimum: (isGrouped ? state.groupsOffset : state.offset) + 1, limit: isGrouped ? state.groupsLimit : state.limit, size: isGrouped ? state.groupsCount : state.count, }; }, /** - * Returns the new sidebar env + * Return the new actionMenus props. * + * @override * @private - * @return {Object} the new sidebar env */ - _getSidebarEnv: function () { + _getActionMenuItems: function (state) { return { - context: this.model.get(this.handle).getContext(), activeIds: this.getSelectedIds(), - model: this.modelName, + context: state.getContext(), }; }, - /** - * Determine whether or not the pager must be displayed (probably depending - * on the current state). Controllers must override this to implement their - * own logic. - * - * @private - */ - _isPagerVisible: function () { - return true; - }, /** * Sort function used to sort the fields by names, to compute the optional fields keys * @@ -633,24 +586,16 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * and notifies the component chain (by bubbling an event up) * * @private + * @param {Object} [newProps={}] */ - _updateEnv: function () { - var env = this.model.get(this.handle, {env: true}); - if (this.sidebar) { - var sidebarEnv = this._getSidebarEnv(); - this.sidebar.updateEnv(sidebarEnv); - } - }, - /** - * Update the pager with the current state. - * - * @private - */ - _updatePager: function () { - if (this.pager) { - this.pager.updateState(this._getPagerParams()); - this.pager.do_toggle(this._isPagerVisible()); - } + _updateControlPanel: function (newProps = {}) { + const state = this.model.get(this.handle); + const props = Object.assign(newProps, { + actionMenus: this._getActionMenuItems(state), + pager: this._getPagingInfo(state), + title: this.getTitle(), + }); + return this.updateControlPanel(props); }, //-------------------------------------------------------------------------- @@ -693,6 +638,27 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { } FieldManagerMixin._onFieldChanged.apply(this, arguments); }, + /** + * @private + * @param {OdooEvent} ev + */ + _onPagerChanged: async function (ev) { + ev.stopPropagation(); + const { currentMinimum, limit } = ev.data; + const state = this.model.get(this.handle, { raw: true }); + const reloadParams = state.groupedBy && state.groupedBy.length ? { + groupsLimit: limit, + groupsOffset: currentMinimum - 1, + } : { + limit, + offset: currentMinimum - 1, + }; + await this.reload(reloadParams); + // reset the scroll position to the top on page changed only + if (state.limit === limit) { + this.trigger_up('scrollTo', { top: 0 }); + } + }, /** * When a reload event triggers up, we need to reload the full view. * For example, after a form view dialog saved some data. @@ -735,24 +701,19 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { * @param {string} ev.data.handleField */ _onResequenceRecords: function (ev) { - var self = this; - this.trigger_up('mutexify', { - action: function () { - var state = self.model.get(self.handle); - var resIDs = _.map(ev.data.recordIds, function (recordID) { - return _.findWhere(state.data, {id: recordID}).res_id; - }); - var options = { + action: async () => { + let state = this.model.get(this.handle); + const resIDs = ev.data.recordIds + .map(recordID => state.data.find(d => d.id === recordID).res_id); + const options = { offset: ev.data.offset, field: ev.data.handleField, }; - return self.model.resequence(self.modelName, resIDs, self.handle, options) - .then(function () { - self._updateEnv(); - state = self.model.get(self.handle); - return self.renderer.updateState(state, {noRender: true}); - }); + await this.model.resequence(this.modelName, resIDs, this.handle, options); + this._updateControlPanel(); + state = this.model.get(this.handle); + return this.renderer.updateState(state, { noRender: true }); }, }); }, @@ -797,17 +758,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { ev.stopPropagation(); // prevent other controllers from handling this request this.model.setDirty(ev.data.dataPointID); }, - /** - * Handler used to get all the data necessary when a custom action is - * performed through the sidebar. - * - * @private - * @param {OdooEvent} ev - */ - _onSidebarDataAsked: function (ev) { - var sidebarEnv = this._getSidebarEnv(); - ev.data.callback(sidebarEnv); - }, /** * open the translation view for the current field * 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 9ba7d39b8dc..a69f6878023 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -4798,7 +4798,7 @@ var BasicModel = AbstractModel.extend({ element.context = options.context; } if (options.orderedBy !== undefined) { - element.orderedBy = options.orderedBy || element.orderedBy; + element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy; } if (options.domain !== undefined) { element.domain = options.domain; diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js index ba407b766c1..c0d3e415321 100644 --- a/addons/web/static/src/js/views/basic/basic_renderer.js +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -49,12 +49,26 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { * Called each time the renderer is attached into the DOM. */ on_attach_callback: function () { + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } WidgetAdapterMixin.on_attach_callback.call(this); }, /** * Called each time the renderer is detached from the DOM. */ on_detach_callback: function () { + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + } WidgetAdapterMixin.on_detach_callback.call(this); }, diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js index d59ea076188..dddfc122c00 100644 --- a/addons/web/static/src/js/views/calendar/calendar_controller.js +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -37,6 +37,9 @@ var CalendarController = AbstractController.extend({ updateRecord: '_onUpdateRecord', viewUpdated: '_onViewUpdated', }), + events: _.extend({}, AbstractController.prototype.events, { + today_button_click: '_onTodayButtonClicked' + }), /** * @override * @param {Widget} parent @@ -60,34 +63,16 @@ var CalendarController = AbstractController.extend({ // The quickCreating attribute ensures that we don't do several create this.quickCreating = false; }, - /** - * Overrides to unbind handler on the control panel mobile 'Today' button. - * - * @override - */ - destroy: function () { - this._super.apply(this, arguments); - if (this.$todayButton) { - this.$todayButton.off(); - } - }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * @override - * @returns {string} - */ - getTitle: function () { - return this._title; - }, /** * Render the buttons according to the CalendarView.buttons template and * add listeners on it. Set this.$buttons with the produced jQuery element * - * @param {jQueryElement} [$node] a jQuery node where the rendered buttons + * @param {jQuery} [$node] a jQuery node where the rendered buttons * should be inserted. $node may be undefined, in which case the Calendar * inserts them into this.options.$buttons or into a div of its template */ @@ -120,25 +105,6 @@ var CalendarController = AbstractController.extend({ this.$('.o_calendar_buttons').replaceWith(this.$buttons); } }, - /** - * In mobile, we want to display a special 'Today' button on the bottom - * right corner of the control panel. This is the pager area, and as there - * is no pager in Calendar views, we fool the system by defining a fake - * pager (which is actually our button) such that it will be inserted in the - * desired place. - * - * @todo get rid of this hack once the ControlPanel layout will be reworked - * - * @param {jQueryElement} $node the button should be appended to this - * element to be displayed in the bottom right corner of the control panel - */ - renderPager: function ($node) { - if (config.device.isMobile) { - this.$todayButton = $(QWeb.render('CalendarView.TodayButtonMobile')); - this.$todayButton.on('click', this._move.bind(this, 'today')); - $node.append(this.$todayButton); - } - }, //-------------------------------------------------------------------------- // Private @@ -424,7 +390,14 @@ var CalendarController = AbstractController.extend({ event.data.data.on_save = self.quick.destroy.bind(self.quick); self._onOpenCreate(event.data); self.quickCreating = false; - }) + }); + }, + /** + * In mobile, we display a special 'Today' button on the bottom right corner + * of the control panel. Its click events are handled here. + */ + _onTodayButtonClicked: function () { + this._move('today'); }, /** * @private @@ -446,7 +419,8 @@ var CalendarController = AbstractController.extend({ this.$buttons.find('.active').removeClass('active'); this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); } - this._setTitle(this.displayName + ' (' + event.data.title + ')'); + const title = `${this.displayName} (${event.data.title})`; + return this.updateControlPanel({ title }); }, }); diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 9d361693595..5c54afb7e41 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -5,7 +5,6 @@ var BasicController = require('web.BasicController'); var core = require('web.core'); var Dialog = require('web.Dialog'); var dialogs = require('web.view_dialogs'); -var Sidebar = require('web.Sidebar'); var _t = core._t; var qweb = core.qweb; @@ -24,7 +23,7 @@ var FormController = BasicController.extend({ /** * @override * - * @param {boolean} params.hasSidebar + * @param {boolean} params.hasActionMenus * @param {Object} params.toolbarActions */ init: function (parent, model, renderer, params) { @@ -34,7 +33,7 @@ var FormController = BasicController.extend({ this.disableAutofocus = params.disableAutofocus; this.footerToButtons = params.footerToButtons; this.defaultButtons = params.defaultButtons; - this.hasSidebar = params.hasSidebar; + this.hasActionMenus = params.hasActionMenus; this.toolbarActions = params.toolbarActions || {}; }, /** @@ -88,10 +87,9 @@ var FormController = BasicController.extend({ * for the new record. * @returns {Promise} */ - createRecord: function (parentID) { - var self = this; - var record = this.model.get(this.handle, {raw: true}); - return this.model.load({ + createRecord: async function (parentID) { + const record = this.model.get(this.handle, { raw: true }); + const handle = await this.model.load({ context: record.getContext(), fields: record.fields, fieldsInfo: record.fieldsInfo, @@ -100,15 +98,14 @@ var FormController = BasicController.extend({ res_ids: record.res_ids, type: 'record', viewType: 'form', - }).then(function (handle) { - self.handle = handle; - self._updateEnv(); - return self._setMode('edit'); }); + this.handle = handle; + this._updateControlPanel(); + return this._setMode('edit'); }, /** * Returns the current res_id, wrapped in a list. This is only used by the - * sidebar (and the debugmanager) + * action menus (and the debugmanager) * * @override * @@ -142,35 +139,34 @@ var FormController = BasicController.extend({ * have to use them instead of the standard buttons. * * @override method from AbstractController - * @param {jQueryElement} $node + * @param {jQuery} [$node] */ renderButtons: function ($node) { - var $footer = this.footerToButtons ? this.renderer.$('footer') : null; + var $footer = this.footerToButtons ? this.renderer.$el && this.renderer.$('footer') : null; var mustRenderFooterButtons = $footer && $footer.length; - if (!this.defaultButtons && !mustRenderFooterButtons) { - return; + if ((this.defaultButtons && !this.$buttons) || mustRenderFooterButtons) { + this.$buttons = $('
'); + if (mustRenderFooterButtons) { + this.$buttons.append($footer); + } else { + this.$buttons.append(qweb.render("FormView.buttons", {widget: this})); + this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this)); + this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this)); + this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this)); + this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this)); + this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit')); + this.$buttons.find('.o_form_buttons_edit').tooltip({ + delay: {show: 200, hide:0}, + title: function(){ + return qweb.render('SaveCancelButton.tooltip'); + }, + trigger: 'manual', + }); + } } - this.$buttons = $('
'); - if (mustRenderFooterButtons) { - this.$buttons.append($footer); - - } else { - this.$buttons.append(qweb.render("FormView.buttons", {widget: this})); - this.$buttons.on('click', '.o_form_button_edit', this._onEdit.bind(this)); - this.$buttons.on('click', '.o_form_button_create', this._onCreate.bind(this)); - this.$buttons.on('click', '.o_form_button_save', this._onSave.bind(this)); - this.$buttons.on('click', '.o_form_button_cancel', this._onDiscard.bind(this)); - this._assignSaveCancelKeyboardBehavior(this.$buttons.find('.o_form_buttons_edit')); - this.$buttons.find('.o_form_buttons_edit').tooltip({ - delay: {show: 200, hide:0}, - title: function(){ - return qweb.render('SaveCancelButton.tooltip'); - }, - trigger: 'manual', - }); - this._updateButtons(); + if (this.$buttons && $node) { + this.$buttons.appendTo($node); } - this.$buttons.appendTo($node); }, /** * The form view has to prevent a click on the pager if the form is dirty @@ -180,71 +176,58 @@ var FormController = BasicController.extend({ * @param {Object} options * @returns {Promise} */ - renderPager: function ($node, options) { - options = _.extend({}, options, { + _getPagingInfo: function () { + // Only display the pager if we are not on a new record. + if (this.model.isNew(this.handle)) { + return null; + } + return Object.assign(this._super(...arguments), { validate: this.canBeDiscarded.bind(this), }); - return this._super($node, options); }, /** - * Instantiate and render the sidebar if a sidebar is requested - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be - * inserted - * @return {Promise} + * @override + * @private **/ - renderSidebar: function ($node) { - var self = this; - if (this.hasSidebar) { - var otherItems = []; - const activeField = this.model.getActiveField(this.initialState); - const activeFieldValue = this.initialState.data[activeField]; - if (this.archiveEnabled && activeField) { - var classname = "o_sidebar_item_archive" + (activeFieldValue ? "" : " o_hidden") - otherItems.push({ - label: _t("Archive"), - callback: function () { - Dialog.confirm(self, _t("Are you sure that you want to archive this record?"), { - confirm_callback: self._toggleArchiveState.bind(self, true), + _getActionMenuItems: function () { + if (!this.hasActionMenus || this.mode === 'edit') { + return null; + } + const props = this._super(...arguments); + const activeField = this.model.getActiveField(this.initialState); + const otherActionItems = []; + if (this.archiveEnabled && activeField) { + if (this.initialState.data[activeField]) { + otherActionItems.push({ + description: _t("Archive"), + callback: () => { + Dialog.confirm(this, _t("Are you sure that you want to archive this record?"), { + confirm_callback: () => this._toggleArchiveState(true), }); }, - classname: classname, }); - classname = "o_sidebar_item_unarchive" + (activeFieldValue ? " o_hidden" : "") - otherItems.push({ - label: _t("Unarchive"), - callback: this._toggleArchiveState.bind(this, false), - classname: classname, + } else { + otherActionItems.push({ + description: _t("Unarchive"), + callback: () => this._toggleArchiveState(false), }); } - if (this.is_action_enabled('delete')) { - otherItems.push({ - label: _t('Delete'), - callback: this._onDeleteRecord.bind(this), - }); - } - if (this.is_action_enabled('create') && this.is_action_enabled('duplicate')) { - otherItems.push({ - label: _t('Duplicate'), - callback: this._onDuplicateRecord.bind(this), - }); - } - this.sidebar = new Sidebar(this, { - editable: this.is_action_enabled('edit'), - viewType: 'form', - env: { - context: this.model.get(this.handle).getContext(), - activeIds: this.getSelectedIds(), - model: this.modelName, - }, - actions: _.extend(this.toolbarActions, {other: otherItems}), - }); - return this.sidebar.appendTo($node).then(function() { - // Show or hide the sidebar according to the view mode - self._updateSidebar(); + } + if (this.activeActions.create && this.activeActions.duplicate) { + otherActionItems.push({ + description: _t("Duplicate"), + callback: () => this._onDuplicateRecord(this), }); } - return Promise.resolve(); + if (this.activeActions.delete) { + otherActionItems.push({ + description: _t("Delete"), + callback: () => this._onDeleteRecord(this), + }); + } + return Object.assign(props, { + items: Object.assign(this.toolbarActions, { other: otherActionItems }), + }); }, /** * Show a warning message if the user modified a translated field. For each @@ -252,32 +235,29 @@ var FormController = BasicController.extend({ * * @override */ - saveRecord: function () { - var self = this; - return this._super.apply(this, arguments).then(function (changedFields) { - // the title could have been changed - self._setTitle(self.getTitle()); - self._updateEnv(); + saveRecord: async function () { + const changedFields = await this._super(...arguments); + // the title could have been changed + this._updateControlPanel(); - if (_t.database.multi_lang && changedFields.length) { - // need to make sure changed fields that should be translated - // are displayed with an alert - var fields = self.renderer.state.fields; - var data = self.renderer.state.data; - var alertFields = {}; - for (var k = 0; k < changedFields.length; k++) { - var field = fields[changedFields[k]]; - var fieldData = data[changedFields[k]]; - if (field.translate && fieldData) { - alertFields[changedFields[k]] = field; - } - } - if (!_.isEmpty(alertFields)) { - self.renderer.updateAlertFields(alertFields); + if (_t.database.multi_lang && changedFields.length) { + // need to make sure changed fields that should be translated + // are displayed with an alert + var fields = this.renderer.state.fields; + var data = this.renderer.state.data; + var alertFields = {}; + for (var k = 0; k < changedFields.length; k++) { + var field = fields[changedFields[k]]; + var fieldData = data[changedFields[k]]; + if (field.translate && fieldData) { + alertFields[changedFields[k]] = field; } } - return changedFields; - }); + if (!_.isEmpty(alertFields)) { + this.renderer.updateAlertFields(alertFields); + } + } + return changedFields; }, /** * Overrides to force the viewType to 'form', so that we ensure that the @@ -285,12 +265,32 @@ var FormController = BasicController.extend({ * * @override */ - update: function (params, options) { + update: async function (params, options) { if ('currentId' in params && !params.currentId) { this.mode = 'edit'; // if there is no record, we are in 'edit' mode } params = _.extend({viewType: 'form', mode: this.mode}, params); - return this._super(params, options); + await this._super(params, options); + this.autofocus(); + }, + /** + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + if (this.footerToButtons) { + var $footer = this.renderer.$el && this.renderer.$('footer'); + if ($footer && $footer.length) { + this.$buttons.empty().append($footer); + } + } + var edit_mode = (this.mode === 'edit'); + this.$buttons.find('.o_form_buttons_edit') + .toggleClass('o_hidden', !edit_mode); + this.$buttons.find('.o_form_buttons_view') + .toggleClass('o_hidden', edit_mode); }, //-------------------------------------------------------------------------- @@ -412,15 +412,6 @@ var FormController = BasicController.extend({ this._super.apply(this, arguments); this.renderer.enableButtons(); }, - /** - * Only display the pager if we are not on a new record. - * - * @override - * @private - */ - _isPagerVisible: function () { - return !this.model.isNew(this.handle); - }, /** * Hook method, called when record(s) has been deleted. * @@ -464,74 +455,6 @@ var FormController = BasicController.extend({ } return this._super.apply(this, arguments); }, - /** - * Updates the controller's title according to the new state - * - * @override - * @private - * @param {Object} state - * @returns {Promise} - */ - _update: function () { - var self = this; - - return this._super.apply(this, arguments).then(function() { - var title = self.getTitle(); - self._setTitle(title); - self._updateButtons(); - self._updateSidebar(); - self.autofocus(); - }); - }, - /** - * @private - */ - _updateButtons: function () { - if (this.$buttons) { - if (this.footerToButtons) { - var $footer = this.renderer.$('footer'); - if ($footer.length) { - this.$buttons.empty().append($footer); - } - } - var edit_mode = (this.mode === 'edit'); - this.$buttons.find('.o_form_buttons_edit') - .toggleClass('o_hidden', !edit_mode); - this.$buttons.find('.o_form_buttons_view') - .toggleClass('o_hidden', edit_mode); - } - }, - /** - * Show or hide the sidebar according to the actual_mode - * @private - */ - _updateSidebar: function () { - if (this.sidebar) { - this.sidebar.do_toggle(this.mode === 'readonly'); - // Hide/Show Archive/Unarchive dropdown items - // We could have toggled the o_hidden class on the - // item theirselves, but the items are redrawed - // at each update, based on the initial definition - var archive_item = _.find(this.sidebar.items.other, function(item) { - return item.classname && item.classname.includes('o_sidebar_item_archive') - }) - var unarchive_item = _.find(this.sidebar.items.other, function(item) { - return item.classname && item.classname.includes('o_sidebar_item_unarchive') - }) - const state = this.model.get(this.handle, {raw: true}); - const activeField = this.model.getActiveField(state); - const activeFieldValue = this.renderer.state.data[activeField]; - if (archive_item && unarchive_item) { - if (activeFieldValue) { - archive_item.classname = 'o_sidebar_item_archive'; - unarchive_item.classname = 'o_sidebar_item_unarchive o_hidden'; - } else { - archive_item.classname = 'o_sidebar_item_archive o_hidden'; - unarchive_item.classname = 'o_sidebar_item_unarchive'; - } - } - } - }, //-------------------------------------------------------------------------- // Handlers @@ -623,18 +546,15 @@ var FormController = BasicController.extend({ this._discardChanges(); }, /** - * Called when the user clicks on 'Duplicate Record' in the sidebar + * Called when the user clicks on 'Duplicate Record' in the action menus * * @private */ - _onDuplicateRecord: function () { - var self = this; - this.model.duplicateRecord(this.handle) - .then(function (handle) { - self.handle = handle; - self._updateEnv(); - self._setMode('edit'); - }); + _onDuplicateRecord: async function () { + const handle = await this.model.duplicateRecord(this.handle); + this.handle = handle; + this._updateControlPanel(); + this._setMode('edit'); }, /** * Called when the user wants to edit the current record -> @see _setMode @@ -777,7 +697,7 @@ var FormController = BasicController.extend({ }); }, /** - * Called when clicking on 'Archive' or 'Unarchive' in the sidebar. + * Called when clicking on 'Archive' or 'Unarchive' in the action menus. * * @private * @param {boolean} archive 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 f3c663891b0..1af48aabf73 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -49,9 +49,6 @@ var FormRenderer = BasicRenderer.extend({ */ on_attach_callback: function () { this._isInDom = true; - _.forEach(this.allFieldWidgets, function (widgets){ - _.invoke(widgets, 'on_attach_callback'); - }); this._super.apply(this, arguments); }, /** diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index 13d73cf37c2..9d13fd36b16 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -25,19 +25,19 @@ var FormView = BasicView.extend({ * @override */ init: function (viewInfo, params) { - var hasSidebar = params.hasSidebar; + var hasActionMenus = params.hasActionMenus; this._super.apply(this, arguments); var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); this.loadParams.type = 'record'; // this is kind of strange, but the param object is modified by - // AbstractView, so we only need to use its hasSidebar value if it was + // AbstractView, so we only need to use its hasActionMenus value if it was // not already present in the beginning of this method - if (hasSidebar === undefined) { - hasSidebar = params.hasSidebar; + if (hasActionMenus === undefined) { + hasActionMenus = params.hasActionMenus; } - this.controllerParams.hasSidebar = hasSidebar; + this.controllerParams.hasActionMenus = hasActionMenus; this.controllerParams.disableAutofocus = params.disable_autofocus; this.controllerParams.toolbarActions = viewInfo.toolbar; this.controllerParams.footerToButtons = params.footerToButtons; @@ -75,7 +75,7 @@ var FormView = BasicView.extend({ params.withControlPanel = !(inDialog || inline); params.footerToButtons = inDialog; params.hasSearchView = inDialog ? false : params.hasSearchView; - params.hasSidebar = !inDialog && !inline; + params.hasActionMenus = !inDialog && !inline; params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes; if (inDialog || inline || fullscreen) { params.mode = 'edit'; diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js index 1c580a25bd6..f47bfaeb5bb 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -1,16 +1,30 @@ odoo.define('web.GraphController', function (require) { "use strict"; + /*--------------------------------------------------------- * Odoo Graph view *---------------------------------------------------------*/ -var core = require('web.core'); -var AbstractController = require('web.AbstractController'); -var GroupByMenuMixin = require('web.GroupByMenuMixin'); +const AbstractController = require('web.AbstractController'); +const { ComponentWrapper } = require('web.OwlCompatibility'); +const DropdownMenu = require('web.DropdownMenu'); +const { DEFAULT_INTERVAL, INTERVAL_OPTIONS } = require('web.searchUtils'); +const { qweb } = require('web.core'); -var qweb = core.qweb; +class CarretDropdownMenu extends DropdownMenu { + /** + * @override + */ + get displayCaret() { + return true; + } +} + +var GraphController = AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + item_selected: '_onItemSelected', + }), -var GraphController = AbstractController.extend(GroupByMenuMixin,{ /** * @override * @param {Widget} parent @@ -22,12 +36,12 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ * @param {string[]} params.groupableFields, */ init: function (parent, model, renderer, params) { - GroupByMenuMixin.init.call(this); this._super.apply(this, arguments); this.measures = params.measures; // this parameter condition the appearance of a 'Group By' // button in the control panel owned by the graph view. this.isEmbedded = params.isEmbedded; + this.withButtons = params.withButtons; // this parameter determines what is the list of fields // that may be used within the groupby menu available when @@ -75,6 +89,18 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ } }; }, + /** + * @override + */ + reload: async function () { + const promises = [this._super(...arguments)]; + if (this.withButtons) { + const state = this.model.get(); + this.measures.forEach(m => m.isActive = m.fieldName === state.measure); + promises.push(this.measureMenu.update({ items: this.measures })); + } + return Promise.all(promises); + }, /** * Render the buttons according to the GraphView.buttons and * add listeners on it. @@ -85,86 +111,62 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ * nothing */ renderButtons: function ($node) { - if ($node) { - var context = { - measures: _.sortBy(_.pairs(_.omit(this.measures, '__count__')), function (x) { return x[1].string.toLowerCase(); }), - }; - this.$buttons = $(qweb.render('GraphView.buttons', context)); - this.$measureList = this.$buttons.find('.o_graph_measures_list'); - this.$buttons.find('button').tooltip(); - this.$buttons.click(this._onButtonClick.bind(this)); - this._updateButtons(); - this.$buttons.appendTo($node); - if (this.isEmbedded) { - this._addGroupByMenu($node, this.groupableFields).then(function(){ - var groupByButton = $node.find('.o_dropdown_toggler_btn'); - groupByButton.removeClass("o_dropdown_toggler_btn btn btn-secondary dropdown-toggle"); - groupByButton.addClass("btn dropdown-toggle btn-outline-secondary"); - }); - } + this.$buttons = $(qweb.render('GraphView.buttons')); + this.$buttons.find('button').tooltip(); + this.$buttons.click(ev => this._onButtonClick(ev)); + if (this.withButtons) { + const actionsContainer = this.$buttons[0]; + const promises = []; + const state = this.model.get(); + const fragment = document.createDocumentFragment(); + // Instantiate and append MeasureMenu + this.measures.forEach(m => m.isActive = m.fieldName === state.measure); + this.measureMenu = new ComponentWrapper(this, CarretDropdownMenu, { + title: "Measures", + items: this.measures, + }); + promises.push(this.measureMenu.mount(fragment).then(() => { + actionsContainer.appendChild(this.measureMenu.el); + this.measureMenu.el.classList.add('o_graph_measures_list'); + })); + if ($node) { + if (this.isEmbedded) { + // Instantiate and append GroupBy menu + this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, { + title: "Group By", + icon: 'fa fa-bars', + items: this._getGroupBys(state.groupBy), + }); + promises.push(this.groupByMenu.mount(fragment).then(() => { + actionsContainer.appendChild(this.groupByMenu.el); + this.groupByMenu.el.classList.add('o_group_by_menu'); + })); + } + this.$buttons.appendTo($node); + } + Promise.all(promises).then(() => { + // Similar behaviour for all buttons + const buttons = actionsContainer.querySelectorAll('.o_dropdown_toggler_btn'); + for (const button of buttons) { + button.classList.remove('o_dropdown_toggler_btn', 'btn-secondary'); + if (this.isEmbedded) { + button.classList.add('btn-outline-secondary'); + } else { + button.classList.add('btn-primary'); + button.tabIndex = 0; + } + } + }); } }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /* - * override - * - * @private - * @param {string[]} groupBy - */ - _setGroupby: function (groupBy) { - this.update({groupBy: groupBy}); - }, /** - * @todo remove this and directly calls update. Update should be overridden - * and modified to call _updateButtons + * Makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that). * - * @private - * - * @param {'pie'|'line'|'bar'} mode + * @override */ - _setMode: function (mode) { - this.update({mode: mode}); - this._updateButtons(); - }, - /** - * @todo same as _setMode - * - * @param {string} measure should be a valid (and aggregatable) field name - */ - _setMeasure: function (measure) { - var self = this; - this.update({measure: measure}).then(function () { - self._updateButtons(); - }); - }, - /** - * @private - * - * @param {boolean} stacked - */ - _toggleStackMode: function (stacked) { - this.update({stacked: stacked}); - this._updateButtons(); - }, - /** - * override - * - * @private - */ - _update: function () { - this._updateButtons(); - return this._super.apply(this, arguments); - }, - /** - * makes sure that the buttons in the control panel matches the current - * state (so, correct active buttons and stuff like that) - */ - _updateButtons: function () { + updateButtons: function () { if (!this.$buttons) { return; } @@ -178,9 +180,60 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ .data('stacked', state.stacked) .toggleClass('active', state.stacked) .toggleClass('o_hidden', state.mode !== 'bar'); - _.each(this.$measureList.find('.dropdown-item'), function (item) { - var $item = $(item); - $item.toggleClass('selected', $item.data('field') === state.measure); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the items used by the Group By menu in embedded mode. + * + * @private + * @param {string[]} activeGroupBys + * @returns {Object[]} + */ + _getGroupBys(activeGroupBys) { + const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys); + const groupBys = Object.keys(this.groupableFields).map(fieldName => { + const field = this.groupableFields[fieldName]; + const groupByActivity = normalizedGroupBys.filter(gb => gb.fieldName === fieldName); + const groupBy = { + id: fieldName, + isActive: Boolean(groupByActivity.length), + description: field.string, + itemType: 'groupBy', + }; + if (['date', 'datetime'].includes(field.type)) { + groupBy.hasOptions = true; + const activeOptionIds = groupByActivity.map(gb => gb.interval); + groupBy.options = Object.values(INTERVAL_OPTIONS).map(o => { + return Object.assign({}, o, { isActive: activeOptionIds.includes(o.id) }); + }); + } + return groupBy; + }).sort((gb1, gb2) => { + return gb1.description.localeCompare(gb2.description); + }); + return groupBys; + }, + + /** + * This method puts the active groupBys in a convenient form. + * + * @private + * @param {string[]} activeGroupBys + * @returns {Object[]} normalizedGroupBys + */ + _normalizeActiveGroupBys(activeGroupBys) { + return activeGroupBys.map(groupBy => { + const fieldName = groupBy.split(':')[0]; + const field = this.groupableFields[fieldName]; + const normalizedGroupBy = { fieldName }; + if (['date', 'datetime'].includes(field.type)) { + normalizedGroupBy.interval = groupBy.split(':')[1] || DEFAULT_INTERVAL; + } + return normalizedGroupBy; }); }, @@ -196,18 +249,51 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ */ _onButtonClick: function (ev) { var $target = $(ev.target); - var field; if ($target.hasClass('o_graph_button')) { if (_.contains(['bar','line', 'pie'], $target.data('mode'))) { - this._setMode($target.data('mode')); + this.update({ mode: $target.data('mode') }); } else if ($target.data('mode') === 'stack') { - this._toggleStackMode(!$target.data('stacked')); + this.update({ stacked: !$target.data('stacked') }); } - } else if ($target.parents('.o_graph_measures_list').length) { - ev.preventDefault(); - ev.stopPropagation(); - field = $target.data('field'); - this._setMeasure(field); + } + }, + + /** + * @private + * @param {OdooEvent} ev + */ + _onItemSelected(ev) { + const item = ev.data.item; + if (this.isEmbedded && item.itemType === 'groupBy') { + const fieldName = item.id; + const optionId = ev.data.option && ev.data.option.id; + const activeGroupBys = this.model.get().groupBy; + if (optionId) { + const normalizedGroupBys = this._normalizeActiveGroupBys(activeGroupBys); + const index = normalizedGroupBys.findIndex(ngb => + ngb.fieldName === fieldName && ngb.interval === optionId); + if (index === -1) { + activeGroupBys.push(fieldName + ':' + optionId); + } else { + activeGroupBys.splice(index, 1); + } + } else { + const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]); + const indexOfGroupby = groupByFieldNames.indexOf(fieldName); + if (indexOfGroupby === -1) { + activeGroupBys.push(fieldName); + } else { + activeGroupBys.splice(indexOfGroupby, 1); + } + } + this.update({ groupBy: activeGroupBys }); + this.groupByMenu.update({ + items: this._getGroupBys(activeGroupBys), + }); + } else if (item.itemType === 'measure') { + this.update({ measure: item.fieldName }); + this.measures.forEach(m => m.isActive = m.fieldName === item.fieldName); + this.measureMenu.update({ items: this.measures }); } }, }); diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js index d8b8df2f1fd..8dcb241ae9c 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -2,7 +2,7 @@ odoo.define('web.GraphModel', function (require) { "use strict"; var core = require('web.core'); -const { DEFAULT_INTERVAL, rank } = require('web.controlPanelViewParameters'); +const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils'); var _t = core._t; @@ -35,10 +35,7 @@ return AbstractModel.extend({ * @returns {Object} */ get: function () { - var self = this; - return _.extend({}, this.chart, { - comparisonFieldIndex: self._getComparisonFieldIndex(), - }); + return _.extend({}, this.chart); }, /** * Initial loading. @@ -47,21 +44,16 @@ return AbstractModel.extend({ * should be done by the graphView I think. * * @param {Object} params - * @param {boolean} params.compare * @param {Object} params.context * @param {Object} params.fields - * @param {string[]} params.comparisonTimeRange * @param {string[]} params.domain * @param {string[]} params.groupBys a list of valid field names * @param {string[]} params.groupedBy a list of valid field names * @param {boolean} params.stacked - * @param {string[]} params.timeRange - * @param {string} params.comparisonField - * @param {string} params.comparisonTimeRangeDescription * @param {string} params.measure a valid field name * @param {'pie'|'bar'|'line'} params.mode * @param {string} params.modelName - * @param {string} params.timeRangeDescription + * @param {Object} params.timeRanges * @returns {Promise} The promise does not return a handle, we don't need * to keep track of various entities. */ @@ -70,11 +62,7 @@ return AbstractModel.extend({ this.initialGroupBys = groupBys; this.fields = params.fields; this.modelName = params.modelName; - this.chart = { - comparisonField: params.comparisonField, - comparisonTimeRange: params.comparisonTimeRange, - comparisonTimeRangeDescription: params.comparisonTimeRangeDescription, - compare: params.compare, + this.chart = Object.assign({ context: params.context, dataPoints: [], domain: params.domain, @@ -83,11 +71,12 @@ return AbstractModel.extend({ mode: params.context.graph_mode || params.mode, origins: [], stacked: params.stacked, - timeRange: params.timeRange, - timeRangeDescription: params.timeRangeDescription, - }; - this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy); - return this._loadGraph(this._getDomains()); + timeRanges: params.timeRanges, + }); + + this._computeDerivedParams(); + + return this._loadGraph(); }, /** * Reload data. It is similar to the load function. Note that we ignore the @@ -104,6 +93,7 @@ return AbstractModel.extend({ * @param {string[]} [params.groupBy] * @param {string} [params.measure] a valid field name * @param {string} [params.mode] one of 'bar', 'pie', 'line' + * @param {Object} [params.timeRanges] * @returns {Promise} */ reload: function (handle, params) { @@ -112,22 +102,6 @@ return AbstractModel.extend({ this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy; this.chart.measure = params.context.graph_measure || this.chart.measure; this.chart.mode = params.context.graph_mode || this.chart.mode; - var timeRangeMenuData = params.context.timeRangeMenuData; - if (timeRangeMenuData) { - this.chart.comparisonField = timeRangeMenuData.comparisonField || undefined; - this.chart.comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; - this.chart.compare = this.chart.comparisonTimeRange.length > 0; - this.chart.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription; - this.chart.timeRange = timeRangeMenuData.timeRange || []; - this.chart.timeRangeDescription = timeRangeMenuData.timeRangeDescription; - } else { - this.chart.comparisonField = undefined; - this.chart.comparisonTimeRange = []; - this.chart.compare = false; - this.chart.comparisonTimeRangeDescription = undefined; - this.chart.timeRange = []; - this.chart.timeRangeDescription = undefined; - } } if ('domain' in params) { this.chart.domain = params.domain; @@ -138,7 +112,12 @@ return AbstractModel.extend({ if ('measure' in params) { this.chart.measure = params.measure; } - this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy); + if ('timeRanges' in params) { + this.chart.timeRanges = params.timeRanges; + } + + this._computeDerivedParams(); + if ('mode' in params) { this.chart.mode = params.mode; return Promise.resolve(); @@ -147,7 +126,7 @@ return AbstractModel.extend({ this.chart.stacked = params.stacked; return Promise.resolve(); } - return this._loadGraph(this._getDomains()); + return this._loadGraph(); }, //-------------------------------------------------------------------------- @@ -155,28 +134,34 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** + * Compute this.chart.processedGroupBy, this.chart.domains, this.chart.origins, + * and this.chart.comparisonFieldIndex. + * Those parameters are determined by this.chart.timeRanges, this.chart.groupBy, and this.chart.domain. + * * @private - * @returns {number} */ - _getComparisonFieldIndex: function () { - var groupBys = this.chart.processedGroupBy.map(function (gb) { - return gb.split(":")[0]; - }); - return groupBys.indexOf(this.chart.comparisonField); - }, - /** - * @private - * @returns {Array[]} - */ - _getDomains: function () { - var domains = [this.chart.domain.concat(this.chart.timeRange)]; - this.chart.origins = [this.chart.timeRangeDescription || ""]; - if (this.chart.compare) { - domains.push(this.chart.domain.concat(this.chart.comparisonTimeRange)); - this.chart.origins.push(this.chart.comparisonTimeRangeDescription); + _computeDerivedParams: function () { + this.chart.processedGroupBy = this._processGroupBy(this.chart.groupBy); + + const { range, rangeDescription, comparisonRange, comparisonRangeDescription, fieldName } = this.chart.timeRanges; + if (range) { + this.chart.domains = [this.chart.domain.concat(range)]; + this.chart.origins = [rangeDescription]; + if (comparisonRange) { + this.chart.domains.push(this.chart.domain.concat(comparisonRange)); + this.chart.origins.push(comparisonRangeDescription); + } + const groupBys = this.chart.processedGroupBy.map(function (gb) { + return gb.split(":")[0]; + }); + this.chart.comparisonFieldIndex = groupBys.indexOf(fieldName); + } else { + this.chart.domains = [this.chart.domain]; + this.chart.origins = [""]; + this.chart.comparisonFieldIndex = -1; } - return domains; }, + /** * Fetch and process graph data. It is basically a(some) read_group(s) * with correct fields for each domain. We have to do some light processing @@ -184,10 +169,9 @@ return AbstractModel.extend({ * with an aggregation function, such as my_date:week. * * @private - * @param {Array[]} domains * @returns {Promise} */ - _loadGraph: function (domains) { + _loadGraph: function () { var self = this; this.chart.dataPoints = []; var groupBy = this.chart.processedGroupBy; @@ -205,10 +189,10 @@ return AbstractModel.extend({ } var context = _.extend({fill_temporal: true}, this.chart.context); - var defs = []; - domains.forEach(function (domain, originIndex) { - defs.push(self._rpc({ + var proms = []; + this.chart.domains.forEach(function (domain, originIndex) { + proms.push(self._rpc({ model: self.modelName, method: 'read_group', context: context, @@ -218,7 +202,7 @@ return AbstractModel.extend({ lazy: false, }).then(self._processData.bind(self, originIndex))); }); - return Promise.all(defs); + return Promise.all(proms); }, /** * Since read_group is insane and returns its result on different keys @@ -286,7 +270,7 @@ return AbstractModel.extend({ } if (groupBysMap.has(fieldName)) { const registeredInterval = groupBysMap.get(fieldName); - if (rank(registeredInterval) < rank(interval)) { + if (rankInterval(registeredInterval) < rankInterval(interval)) { groupBysMap.set(fieldName, interval); } } else { diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js index 64c65694784..488424536e6 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -16,8 +16,8 @@ var GraphRenderer = require('web.GraphRenderer'); var _t = core._t; var _lt = core._lt; -var controlPanelViewParameters = require('web.controlPanelViewParameters'); -var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES; +var searchUtils = require('web.searchUtils'); +var GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES; var GraphView = AbstractView.extend({ display_name: _lt('Graph'), @@ -39,54 +39,81 @@ var GraphView = AbstractView.extend({ init: function (viewInfo, params) { this._super.apply(this, arguments); - var self = this; - var measure; - var groupBys = []; - var measures = {__count__: {string: _t("Count"), type: "integer"}}; - var groupableFields = {}; - this.fields.__count__ = {string: _t("Count"), type: "integer"}; + const additionalMeasures = params.additionalMeasures || []; + let measure; + const measures = {}; + const measureStrings = {}; + const groupBys = []; + const groupableFields = {}; + this.fields.__count__ = { string: _t("Count"), type: 'integer' }; - var measureString = {}; - - this.arch.children.forEach(function (field) { - var fieldName = field.attrs.name; + this.arch.children.forEach(field => { + let fieldName = field.attrs.name; if (fieldName === "id") { return; } - var interval = field.attrs.interval; + const interval = field.attrs.interval; if (interval) { fieldName = fieldName + ':' + interval; } if (field.attrs.type === 'measure') { + const { string } = this.fields[fieldName]; measure = fieldName; - measures[fieldName] = self.fields[fieldName]; + measures[fieldName] = { + description: string, + fieldName, + groupNumber: 0, + isActive: false, + itemType: 'measure', + }; } else { groupBys.push(fieldName); } if (field.attrs.string) { - measureString[fieldName] = field.attrs.string; + measureStrings[fieldName] = field.attrs.string; } }); - _.each(this.fields, function (field, name) { + for (const name in this.fields) { + const field = this.fields[name]; if (name !== 'id' && field.store === true) { - if (_.contains(['integer', 'float', 'monetary'], field.type) || - _.contains(params.additionalMeasures, name)) { - measures[name] = field; + if ( + ['integer', 'float', 'monetary'].includes(field.type) || + additionalMeasures.includes(name) + ) { + measures[name] = { + description: field.string, + fieldName: name, + groupNumber: 0, + isActive: false, + itemType: 'measure', + }; } - if (_.contains(GROUPABLE_TYPES, field.type)) { + if (GROUPABLE_TYPES.includes(field.type)) { groupableFields[name] = field; } } - }); - - _.each(measureString, function (string, name) { + } + for (const name in measureStrings) { if (measures[name]) { - measures[name].string = string; + measures[name].description = measureStrings[name]; } - }); + } - this.controllerParams.measures = measures; + const sortedMeasures = Object.values(measures).sort((a, b) => { + const descA = a.description.toLowerCase(); + const descB = b.description.toLowerCase(); + return descA > descB ? 1 : descA < descB ? -1 : 0; + }); + const countMeasure = { + description: _t("Count"), + fieldName: '__count__', + groupNumber: 1, + isActive: false, + itemType: 'measure', + }; + this.controllerParams.withButtons = params.withButtons !== false; + this.controllerParams.measures = [...sortedMeasures, countMeasure]; this.controllerParams.groupableFields = groupableFields; this.rendererParams.fields = this.fields; this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js index 95012eeebfc..cca0a43cb5e 100644 --- a/addons/web/static/src/js/views/kanban/kanban_controller.js +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -58,21 +58,35 @@ var KanbanController = BasicController.extend({ //-------------------------------------------------------------------------- /** - * @param {jQueryElement} $node - * @returns {Promise} + * @param {jQuery} [$node] */ renderButtons: function ($node) { - if (this.hasButtons && this.is_action_enabled('create')) { - this.$buttons = $(qweb.render(this.buttons_template, { - btnClass: 'btn-primary', - widget: this, - })); - this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this)); - this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this)); - this._updateButtons(); - return Promise.resolve(this.$buttons.appendTo($node)); + if (!this.hasButtons || !this.is_action_enabled('create')) { + return; } - return Promise.resolve(); + this.$buttons = $(qweb.render(this.buttons_template, { + btnClass: 'btn-primary', + widget: this, + })); + this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this)); + this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this)); + if ($node) { + this.$buttons.appendTo($node); + } + }, + /** + * In grouped mode, set 'Create' button as btn-secondary if there is no column + * (except if we can't create new columns) + * + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + var state = this.model.get(this.handle, {raw: true}); + var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn; + this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden); }, //-------------------------------------------------------------------------- @@ -100,9 +114,11 @@ var KanbanController = BasicController.extend({ * @override * @private */ - _isPagerVisible: function () { - var state = this.model.get(this.handle, {raw: true}); - return !!(state.count && !state.groupedBy.length); + _getPagingInfo: function (state) { + if (!(state.count && !state.groupedBy.length)) { + return null; + } + return this._super(...arguments); }, /** * @private @@ -185,33 +201,7 @@ var KanbanController = BasicController.extend({ */ _resequenceRecords: function (column_id, ids) { var self = this; - return this.model.resequence(this.modelName, ids, column_id).then(function () { - self._updateEnv(); - }); - }, - /** - * Overrides to update the control panel buttons when the state is updated. - * - * @override - * @private - */ - _update: function () { - this._updateButtons(); - return this._super.apply(this, arguments); - }, - /** - * In grouped mode, set 'Create' button as btn-secondary if there is no column - * (except if we can't create new columns) - * - * @private - * @override from abstract controller - */ - _updateButtons: function () { - if (this.$buttons) { - var state = this.model.get(this.handle, {raw: true}); - var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn; - this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden); - } + return this.model.resequence(this.modelName, ids, column_id); }, //-------------------------------------------------------------------------- @@ -236,7 +226,6 @@ var KanbanController = BasicController.extend({ }).then(function () { return self.update({}, {reload: false}); }).then(function () { - self._updateButtons(); self.renderer.quickCreateToggleFold(); self.renderer.trigger_up("quick_create_column_created"); }); @@ -389,7 +378,6 @@ var KanbanController = BasicController.extend({ this.model.loadMore(column.db_id).then(function (db_id) { var data = self.model.get(db_id); self.renderer.updateColumn(db_id, data); - self._updateEnv(); }); }, /** @@ -412,7 +400,6 @@ var KanbanController = BasicController.extend({ // function that updates the kanban view once the record has been added // it receives the local id of the created record in arguments var update = function (db_id) { - self._updateEnv(); var columnState = self.model.getColumn(db_id); var state = self.model.get(self.handle); @@ -459,9 +446,7 @@ var KanbanController = BasicController.extend({ */ _onResequenceColumn: function (ev) { var self = this; - this._resequenceColumns(ev.data.ids).then(function () { - self._updateEnv(); - }); + this._resequenceColumns(ev.data.ids); }, /** * @private @@ -481,7 +466,6 @@ var KanbanController = BasicController.extend({ return self.renderer.updateColumn(db_id, data, options); }) .then(function () { - self._updateEnv(); if (ev.data.onSuccess) { ev.data.onSuccess(); } @@ -520,7 +504,6 @@ var KanbanController = BasicController.extend({ prom.then(function (dbID) { var data = self.model.get(dbID); self.renderer.updateColumn(dbID, data); - self._updateEnv(); }); } }, diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 2f2def8d850..3025adde2cb 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -179,7 +179,7 @@ var KanbanModel = BasicModel.extend({ * @override */ load: function (params) { - this.defaultGroupedBy = params.groupBy; + this.defaultGroupedBy = params.groupBy || []; params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy; return this._super(params); }, diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js index 7159dda9618..d49b4a73c4e 100644 --- a/addons/web/static/src/js/views/kanban/kanban_view.js +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -108,11 +108,8 @@ var KanbanView = BasicView.extend({ */ _updateMVCParams: function () { this._super.apply(this, arguments); - if (!this.noDefaultGroupby) { - var defaultGroupBy = this.arch.attrs.default_group_by; - this.loadParams.groupBy = defaultGroupBy ? - [defaultGroupBy] : - (this.loadParams.groupedBy || []); + if (!this.noDefaultGroupby && this.arch.attrs.default_group_by) { + this.loadParams.groupBy = [this.arch.attrs.default_group_by]; } }, }); diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index d6b42ee1043..41dfa18c8bc 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -12,7 +12,6 @@ var BasicController = require('web.BasicController'); var DataExport = require('web.DataExport'); var Dialog = require('web.Dialog'); var ListConfirmDialog = require('web.ListConfirmDialog'); -var Sidebar = require('web.Sidebar'); var _t = core._t; var qweb = core.qweb; @@ -42,19 +41,20 @@ var ListController = BasicController.extend({ * @override * @param {Object} params * @param {boolean} params.editable - * @param {boolean} params.hasSidebar + * @param {boolean} params.hasActionMenus * @param {Object} params.toolbarActions * @param {boolean} params.noLeaf */ init: function (parent, model, renderer, params) { this._super.apply(this, arguments); - this.hasSidebar = params.hasSidebar; + this.hasActionMenus = params.hasActionMenus; this.toolbarActions = params.toolbarActions || {}; this.editable = params.editable; this.noLeaf = params.noLeaf; this.selectedRecords = params.selectedRecords || []; this.multipleRecordsSavingPromise = null; this.fieldChangedPrevented = false; + this.selectAll = false; Object.defineProperty(this, 'mode', { get: () => this.renderer.isEditable() ? 'edit' : 'readonly', set: () => {}, @@ -78,8 +78,8 @@ var ListController = BasicController.extend({ */ getActiveDomain: function () { var self = this; - if (this.$('thead .o_list_record_selector input').prop('checked')) { - var searchQuery = this._controlPanel ? this._controlPanel.getSearchQuery() : {}; + if (this.selectAll) { + var searchQuery = this._controlPanelModel ? this._controlPanelModel.getQuery() : {}; var record = self.model.get(self.handle, {raw: true}); return record.getDomain().concat(searchQuery.domain || []); } @@ -125,75 +125,27 @@ var ListController = BasicController.extend({ * induce the leaving of the current row. * * @override - * @param {jQuery} $node */ renderButtons: function ($node) { - if (!this.noLeaf && this.hasButtons) { - this.$buttons = $(qweb.render(this.buttons_template, {widget: this})); - this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this)); - - this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add')); - this.$buttons.find('.o_list_button_add').tooltip({ - delay: {show: 200, hide: 0}, - title: function () { - return qweb.render('CreateButton.tooltip'); - }, - trigger: 'manual', - }); - this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this)); - this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); + if (this.noLeaf || !this.hasButtons) { + return; + } + this.$buttons = $(qweb.render(this.buttons_template, {widget: this})); + this.$buttons.on('click', '.o_list_button_add', this._onCreateRecord.bind(this)); + this._assignCreateKeyboardBehavior(this.$buttons.find('.o_list_button_add')); + this.$buttons.find('.o_list_button_add').tooltip({ + delay: {show: 200, hide: 0}, + title: function () { + return qweb.render('CreateButton.tooltip'); + }, + trigger: 'manual', + }); + this.$buttons.on('mousedown', '.o_list_button_discard', this._onDiscardMousedown.bind(this)); + this.$buttons.on('click', '.o_list_button_discard', this._onDiscard.bind(this)); + if ($node) { this.$buttons.appendTo($node); } }, - /** - * Render the sidebar (the 'action' menu in the control panel, right of the - * main buttons) - * - * @param {jQuery Node} $node - * @returns {Promise} - */ - renderSidebar: function ($node) { - var self = this; - if (this.hasSidebar) { - var other = [{ - label: _t("Export"), - callback: this._onExportData.bind(this) - }]; - if (this.archiveEnabled) { - other.push({ - label: _t("Archive"), - callback: function () { - Dialog.confirm(self, _t("Are you sure that you want to archive all the selected records?"), { - confirm_callback: self._toggleArchiveState.bind(self, true), - }); - } - }); - other.push({ - label: _t("Unarchive"), - callback: this._toggleArchiveState.bind(this, false) - }); - } - if (this.is_action_enabled('delete')) { - other.push({ - label: _t('Delete'), - callback: this._onDeleteSelectedRecords.bind(this) - }); - } - this.sidebar = new Sidebar(this, { - editable: this.is_action_enabled('edit'), - env: { - context: this.model.get(this.handle, {raw: true}).getContext(), - activeIds: this.getSelectedIds(), - model: this.modelName, - }, - actions: _.extend(this.toolbarActions, {other: other}), - }); - return this.sidebar.appendTo($node).then(function() { - self._toggleSidebar(); - }); - } - return Promise.resolve(); - }, /** * Overrides to update the list of selected records * @@ -214,6 +166,25 @@ var ListController = BasicController.extend({ params.selectedRecords = this.selectedRecords; return this._super.apply(this, arguments); }, + /** + * This helper simply makes sure that the control panel buttons matches the + * current mode. + * + * @override + * @param {string} mode either 'readonly' or 'edit' + */ + updateButtons: function (mode) { + if (!this.$buttons) { + return; + } + this.$buttons.toggleClass('o-editing', mode === 'edit'); + const state = this.model.get(this.handle, {raw: true}); + if (state.count) { + this.$('.o_list_export_xlsx').show(); + } else { + this.$('.o_list_export_xlsx').hide(); + } + }, //-------------------------------------------------------------------------- // Private @@ -233,7 +204,7 @@ var ListController = BasicController.extend({ if ((recordID || this.handle) !== this.handle) { var state = this.model.get(this.handle); this.renderer.removeLine(state, recordID); - this._updatePager(); + this._updatePaging(state); } }, /** @@ -257,7 +228,10 @@ var ListController = BasicController.extend({ self.renderer.updateState(state, {keepWidths: true}) .then(function () { self.renderer.editRecord(recordID); - }).then(self._updatePager.bind(self)); + }) + .then(() => { + self._updatePaging(state); + }); }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); }, /** @@ -342,7 +316,7 @@ var ListController = BasicController.extend({ } var self = this; return this._super(recordID).then(function () { - self._updateButtons('readonly'); + self.updateButtons('readonly'); }); }, /** @@ -356,27 +330,56 @@ var ListController = BasicController.extend({ return new DataExport(this, state, defaultExportFields, groupedBy, this.getActiveDomain(), this.getSelectedIds()); }, - /** - * @override - * @private - */ - _getSidebarEnv: function () { - var env = this._super.apply(this, arguments); - var record = this.model.get(this.handle); - return _.extend(env, { - domain: record.getDomain(), - selectAll: !!this.$('thead .o_list_record_selector input').prop('checked'), - }); - }, /** * Only display the pager when there are data to display. * * @override * @private */ - _isPagerVisible: function () { - var state = this.model.get(this.handle, {raw: true}); - return !!state.count; + _getPagingInfo: function (state) { + if (!state.count) { + return null; + } + return this._super(...arguments); + }, + /** + * @override + * @private + */ + _getActionMenuItems: function (state) { + if (!this.hasActionMenus || !this.selectedRecords.length) { + return null; + } + const props = this._super(...arguments); + const otherActionItems = [{ + description: _t("Export"), + callback: () => this._onExportData(), + }]; + if (this.archiveEnabled) { + otherActionItems.push({ + description: _t("Archive"), + callback: () => { + Dialog.confirm(this, _t("Are you sure that you want to archive all the selected records?"), { + confirm_callback: () => this._toggleArchiveState(true), + }); + } + }, { + description: _t("Unarchive"), + callback: () => this._toggleArchiveState(false) + }); + } + if (this.activeActions.delete) { + otherActionItems.push({ + description: _t("Delete"), + callback: () => this._onDeleteSelectedRecords() + }); + } + return Object.assign(props, { + items: Object.assign({}, this.toolbarActions, { other: otherActionItems }), + context: this.model.get(this.handle, { raw: true }).getContext(), + domain: state.getDomain(), + selectAll: this.selectAll, + }); }, /** * Saves multiple records at once. This method is called by the _onFieldChanged method @@ -406,7 +409,7 @@ var ListController = BasicController.extend({ const saveRecords = () => { this.model.saveRecords(this.handle, recordId, validRecordIds, fieldName) .then(async () => { - this._updateButtons('readonly'); + this.updateButtons('readonly'); const state = this.model.get(this.handle); // We need to check the current multi-editable state here // in case the selection is changed. If there are changes @@ -481,7 +484,7 @@ var ListController = BasicController.extend({ */ _setMode: function (mode, recordID) { if ((recordID || this.handle) !== this.handle) { - this._updateButtons(mode); + this.updateButtons(mode); return this.renderer.setRowMode(recordID, mode); } else { return this._super.apply(this, arguments); @@ -509,41 +512,14 @@ var ListController = BasicController.extend({ this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden); } }, - /** - * Display the sidebar (the 'action' menu in the control panel) if we have - * some selected records. - */ - _toggleSidebar: function () { - if (this.sidebar) { - this.sidebar.do_toggle(this.selectedRecords.length > 0); - } - }, /** * @override * @returns {Promise} */ - _update: function () { - return this._super.apply(this, arguments) - .then(this._toggleSidebar.bind(this)) - .then(this._toggleCreateButton.bind(this)) - .then(this._updateButtons.bind(this, 'readonly')); - }, - /** - * This helper simply makes sure that the control panel buttons matches the - * current mode. - * - * @param {string} mode either 'readonly' or 'edit' - */ - _updateButtons: function (mode) { - if (this.$buttons) { - this.$buttons.toggleClass('o-editing', mode === 'edit'); - const state = this.model.get(this.handle, {raw: true}); - if (state.count) { - this.$('.o_list_export_xlsx').show(); - } else { - this.$('.o_list_export_xlsx').hide(); - } - } + _update: async function () { + await this._super(...arguments); + this._toggleCreateButton(); + this.updateButtons('readonly'); }, //-------------------------------------------------------------------------- @@ -745,7 +721,8 @@ var ListController = BasicController.extend({ */ _onSelectionChanged: function (ev) { this.selectedRecords = ev.data.selection; - this._toggleSidebar(); + this.selectAll = ev.data.allChecked; + this._updateControlPanel(); }, /** * If the record is set as dirty while in multiple record edition, @@ -780,12 +757,12 @@ var ListController = BasicController.extend({ */ _onToggleColumnOrder: function (ev) { ev.stopPropagation(); - var data = this.model.get(this.handle); - if (!data.groupedBy) { - this.pager.updateState({current_min: 1}); + var state = this.model.get(this.handle); + if (!state.groupedBy) { + this._updatePaging(state, { currentMinimum: 1 }); } var self = this; - this.model.setSort(data.id, ev.data.name).then(function () { + this.model.setSort(state.id, ev.data.name).then(function () { self.update({}); }); }, diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index cf47c997597..418c39b14e0 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -2,6 +2,7 @@ odoo.define('web.ListRenderer', function (require) { "use strict"; var BasicRenderer = require('web.BasicRenderer'); +const { ComponentWrapper } = require('web.OwlCompatibility'); var config = require('web.config'); var core = require('web.core'); var dom = require('web.dom'); @@ -83,6 +84,7 @@ var ListRenderer = BasicRenderer.extend({ //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- + /** * Order to focus to be given to the content of the current view * @override @@ -570,41 +572,6 @@ var ListRenderer = BasicRenderer.extend({ } return $buttons; }, - /** - * Renders the pager for a given group - * - * @private - * @param {Object} group - * @returns {jQueryElement} the pager's $el - */ - _renderGroupPager: function (group) { - var pager = new Pager(this, group.count, group.offset + 1, group.limit); - pager.on('pager_changed', this, function (newState) { - var self = this; - pager.disable(); - this.trigger_up('load', { - id: group.id, - limit: newState.limit, - offset: newState.current_min - 1, - on_success: function (reloadedGroup) { - _.extend(group, reloadedGroup); - self._renderView(); - }, - on_fail: pager.enable.bind(pager), - }); - }); - // register the pager so that it can be destroyed on next rendering - this.pagers.push(pager); - - var pagerProm = pager._widgetRenderAndInsert(function () {}); // start the pager - this.defs.push(pagerProm); - var $el = $('
'); - pagerProm.then(function () { - $el.replaceWith(pager.$el); - }); - - return $el; - }, /** * Render the row that represent a group * @@ -670,9 +637,8 @@ var ListRenderer = BasicRenderer.extend({ $th.attr('colspan', colspanBeforeAggregate); if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) { - var $pager = this._renderGroupPager(group); - var $lastCell = cells[cells.length - 1]; - $lastCell.append($pager); + const lastCell = cells[cells.length - 1][0]; + this._renderGroupPager(group, lastCell); } if (group.isOpen && this.groupbys[groupBy]) { var $buttons = this._renderGroupButtons(group, this.groupbys[groupBy]); @@ -711,6 +677,30 @@ var ListRenderer = BasicRenderer.extend({ return [$('').append($records)]; } }, + /** + * Renders the pager for a given group + * + * @private + * @param {Object} group + * @param {HTMLElement} target + */ + _renderGroupPager: function (group, target) { + const currentMinimum = group.offset + 1; + const limit = group.limit; + const size = group.count; + if (!this._shouldRenderPager(currentMinimum, limit, size)) { + return; + } + const pager = new ComponentWrapper(this, Pager, { currentMinimum, limit, size }); + const pagerMounting = pager.mount(target).then(() => { + // Event binding is done here to get the related group and wrapper. + pager.el.addEventListener('pager-changed', ev => this._onPagerChanged(ev, group)); + // Prevent pager clicks to toggle the group. + pager.el.addEventListener('click', ev => ev.stopPropagation()); + }); + this.defs.push(pagerMounting); + this.pagers.push(pager); + }, /** * Render all groups in the view. We assume that the view is in grouped * mode. @@ -937,7 +927,7 @@ var ListRenderer = BasicRenderer.extend({ _renderView: function () { var self = this; - var oldPagers = this.pagers; + const oldPagers = this.pagers; this.pagers = []; // display the no content helper if there is no data to display @@ -945,7 +935,7 @@ var ListRenderer = BasicRenderer.extend({ this.$el.toggleClass('o_list_view', !displayNoContentHelper); if (displayNoContentHelper) { // destroy the previously instantiated pagers, if any - _.invoke(oldPagers, 'destroy'); + oldPagers.forEach(pager => pager.destroy()); this.$el.removeClass('table-responsive'); this.$el.html(this._renderNoContentHelper()); @@ -971,16 +961,22 @@ var ListRenderer = BasicRenderer.extend({ $table.append(this._renderBody()); $table.append(this._renderFooter()); } + const tableWrapper = Object.assign(document.createElement('div'), { + className: 'table-responsive', + }); + tableWrapper.appendChild($table[0]); delete this.defs; - var prom = Promise.all(defs).then(function () { + var prom = Promise.all(defs).then(() => { // destroy the previously instantiated pagers, if any - _.invoke(oldPagers, 'destroy'); + oldPagers.forEach(pager => pager.destroy()); - self.$el.html($('
', { - class: 'table-responsive', - html: $table - })); + // Append the table to the main element + self.el.innerHTML = ""; + dom.append(self.el, tableWrapper, { + callbacks: [{ widget: this }], + in_DOM: document.body.contains(self.el), + }); if (self.optionalColumns.length) { self.$el.addClass('o_list_optional_columns'); @@ -1015,6 +1011,18 @@ var ListRenderer = BasicRenderer.extend({ $tr.toggleClass(cssClass, py.PY_isTrue(py.evaluate(expr, record.evalContext))); }); }, + /** + * @private + * @returns {boolean} + */ + _shouldRenderPager: function (currentMinimum, limit, size) { + if (!limit || !size) { + return false; + } + const maximum = Math.min(currentMinimum + limit - 1, size); + const singlePage = (1 === currentMinimum) && (maximum === size); + return !singlePage; + }, /** * Update the footer aggregate values. This method should be called each * time the state of some field is changed, to make sure their sum are kept @@ -1046,7 +1054,7 @@ var ListRenderer = BasicRenderer.extend({ } }); this.$('thead .o_list_record_selector input').prop('checked', allChecked); - this.trigger_up('selection_changed', { selection: this.selection }); + this.trigger_up('selection_changed', { allChecked, selection: this.selection }); this._updateFooter(); }, @@ -1210,6 +1218,24 @@ var ListRenderer = BasicRenderer.extend({ _onMouseDown: function () { $('.o_keyboard_navigation').removeClass('o_keyboard_navigation'); }, + /** + * @private + * @param {OwlEvent} ev + * @param {Object} group + */ + _onPagerChanged: async function (ev, group) { + ev.stopPropagation(); + const { currentMinimum, limit } = ev.detail; + this.trigger_up('load', { + id: group.id, + limit: limit, + offset: currentMinimum - 1, + on_success: reloadedGroup => { + Object.assign(group, reloadedGroup); + this._renderView(); + }, + }); + }, /** * @private * @param {MouseEvent} ev @@ -1246,7 +1272,6 @@ var ListRenderer = BasicRenderer.extend({ */ _onToggleGroup: function (ev) { ev.preventDefault(); - ev.stopPropagation(); var group = $(ev.currentTarget).closest('tr').data('group'); if (group.count) { this.trigger_up('toggle_group', { diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js index bb6dfed8116..5845375fd70 100644 --- a/addons/web/static/src/js/views/list/list_view.js +++ b/addons/web/static/src/js/views/list/list_view.js @@ -33,7 +33,7 @@ var ListView = BasicView.extend({ * * @param {Object} viewInfo * @param {Object} params - * @param {boolean} params.hasSidebar + * @param {boolean} params.hasActionMenus * @param {boolean} [params.hasSelectors=true] */ init: function (viewInfo, params) { @@ -58,7 +58,7 @@ var ListView = BasicView.extend({ this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true; this.controllerParams.editable = editable; - this.controllerParams.hasSidebar = params.hasSidebar; + this.controllerParams.hasActionMenus = params.hasActionMenus; this.controllerParams.toolbarActions = viewInfo.toolbar; this.controllerParams.mode = editable ? 'edit' : 'readonly'; this.controllerParams.selectedRecords = selectedRecords; @@ -101,7 +101,7 @@ var ListView = BasicView.extend({ var params = this._super.apply(this, arguments); var inDialog = action.target === 'new'; var inline = action.target === 'inline'; - params.hasSidebar = !inDialog && !inline; + params.hasActionMenus = !inDialog && !inline; return params; }, /** diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js index c54c6affedc..ee37aba3ce5 100644 --- a/addons/web/static/src/js/views/pivot/pivot_controller.js +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -85,25 +85,44 @@ odoo.define('web.PivotController', function (require) { * add listeners on it. * Set this.$buttons with the produced jQuery element * + * @override * @param {jQuery} [$node] a jQuery node where the rendered buttons should * be inserted. $node may be undefined, in which case the PivotView * does nothing */ renderButtons: function ($node) { + const context = { + measures: Object.entries(this.measures) + .filter(x => x[0] !== '__count') + .sort((a, b) => a[1].string.toLowerCase() > b[1].string.toLowerCase() ? 1 : -1), + }; + this.$buttons = $(QWeb.render('PivotView.buttons', context)); + this.$buttons.click(this._onButtonClick.bind(this)); + this.$buttons.find('button').tooltip(); if ($node) { - const context = { - measures: Object.entries(this.measures) - .filter(x => x[0] !== '__count') - .sort((a, b) => a[1].string.toLowerCase() > b[1].string.toLowerCase() ? 1 : -1), - }; - this.$buttons = $(QWeb.render('PivotView.buttons', context)); - this.$buttons.click(this._onButtonClick.bind(this)); - this.$buttons.find('button').tooltip(); - this.$buttons.appendTo($node); - this._updateButtons(); } }, + /** + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + const state = this.model.get({ raw: true }); + Object.entries(this.measures).forEach(elt => { + const name = elt[0]; + const isSelected = state.measures.includes(name); + this.$buttons.find('.dropdown-item[data-field="' + name + '"]') + .toggleClass('selected', isSelected); + + }); + const noDataDisplayed = !state.hasData || !state.measures.length; + this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed); + this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed); + this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed); + }, //-------------------------------------------------------------------------- // Private @@ -131,34 +150,6 @@ odoo.define('web.PivotController', function (require) { error: (error) => this.call('crash_manager', 'rpc_error', error), }); }, - /** - * @override - * @private - */ - _update: function () { - this._updateButtons(); - return this._super.apply(this, arguments); - }, - /** - * @private - */ - _updateButtons: function () { - if (!this.$buttons) { - return; - } - const state = this.model.get({ raw: true }); - Object.entries(this.measures).forEach(elt => { - const name = elt[0]; - const isSelected = state.measures.includes(name); - this.$buttons.find('.dropdown-item[data-field="' + name + '"]') - .toggleClass('selected', isSelected); - - }); - const noDataDisplayed = !state.hasData || !state.measures.length; - this.$buttons.find('.o_pivot_flip_button').prop('disabled', noDataDisplayed); - this.$buttons.find('.o_pivot_expand_button').prop('disabled', noDataDisplayed); - this.$buttons.find('.o_pivot_download').prop('disabled', noDataDisplayed); - }, //-------------------------------------------------------------------------- // Handlers diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js index 67122ceb40d..8e4f92a4d75 100644 --- a/addons/web/static/src/js/views/pivot/pivot_model.js +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -530,18 +530,15 @@ var PivotModel = AbstractModel.extend({ * @param {boolean} [params.compare=false] * @param {Object} params.context * @param {Object} params.fields - * @param {Array[]} [params.comparisonTimeRange=[]] * @param {string[]} [params.groupedBy] - * @param {Array[]} [params.timeRange=[]] * @param {string[]} params.colGroupBys * @param {Array[]} params.domain * @param {string[]} params.measures * @param {string[]} params.rowGroupBys - * @param {string} [params.comparisonTimeRangeDescription=""] * @param {string} [params.default_order] - * @param {string} [params.timeRangeDescription=""] * @param {string} params.modelName * @param {Object[]} params.groupableFields + * @param {Object} params.timeRanges * @returns {Promise} */ load: function (params) { @@ -556,19 +553,14 @@ var PivotModel = AbstractModel.extend({ expandedRowGroupBys: [], expandedColGroupBys: [], domain: this.initialDomain, - timeRange: params.timeRange || [], - timeRangeDescription: params.timeRangeDescription || "", - comparisonTimeRange: params.comparisonTimeRange || [], - comparisonTimeRangeDescription: params.comparisonTimeRangeDescription || "", - compare: params.compare || false, context: _.extend({}, session.user_context, params.context), groupedBy: params.context.pivot_row_groupby || params.groupedBy, colGroupBys: params.context.pivot_column_groupby || params.colGroupBys, measures: this._processMeasures(params.context.pivot_measures) || params.measures, + timeRanges: params.timeRanges, }; + this._computeDerivedParams(); - this.data.domains = this._getDomains(); - this.data.origins = this._getOrigins(); this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys; var defaultOrder = params.default_order && params.default_order.split(' '); @@ -588,14 +580,11 @@ var PivotModel = AbstractModel.extend({ * @param {Object} params * @param {boolean} [params.compare=false] * @param {Object} params.context - * @param {Array[]} [params.comparisonTimeRange=[]] * @param {string[]} [params.groupedBy] - * @param {Array[]} [params.timeRange=[]] * @param {Array[]} params.domain * @param {string[]} params.groupBy * @param {string[]} params.measures - * @param {string} [params.comparisonTimeRangeDescription=""] - * @param {string} [params.timeRangeDescription=""] + * @param {Object} [params.timeRanges] * @returns {Promise} */ reload: function (handle, params) { @@ -608,21 +597,6 @@ var PivotModel = AbstractModel.extend({ this.data.groupedBy = params.context.pivot_row_groupby || this.data.groupedBy; this.data.measures = this._processMeasures(params.context.pivot_measures) || this.data.measures; this.defaultGroupedBy = this.data.groupedBy.length ? this.data.groupedBy : this.defaultGroupedBy; - var timeRangeMenuData = params.context.timeRangeMenuData; - if (timeRangeMenuData) { - this.data.timeRange = timeRangeMenuData.timeRange || []; - this.data.timeRangeDescription = timeRangeMenuData.timeRangeDescription || ""; - this.data.comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; - this.data.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription || ""; - this.data.compare = this.data.comparisonTimeRange.length > 0; - } else { - this.data.timeRange = []; - this.data.timeRangeDescription = ""; - this.data.comparisonTimeRange = []; - this.data.comparisonTimeRangeDescription = ""; - this.data.compare = false; - this.data.context = _.omit(this.data.context, 'timeRangeMenuData'); - } } if ('domain' in params) { this.data.domain = params.domain; @@ -633,9 +607,11 @@ var PivotModel = AbstractModel.extend({ if ('groupBy' in params) { this.data.groupedBy = params.groupBy.length ? params.groupBy : this.defaultGroupedBy; } + if ('timeRanges' in params) { + this.data.timeRanges = params.timeRanges; + } + this._computeDerivedParams(); - this.data.domains = this._getDomains(); - this.data.origins = this._getOrigins(); this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys; if (!_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { @@ -791,20 +767,6 @@ var PivotModel = AbstractModel.extend({ return values[0]; } }, - /** - * Returns the principal domains used by the pivot model to fetch data. - * The domains represent two main groups of records. - * - * @private - * @returns {Array[][]} - */ - _getDomains: function () { - var domains = [this.data.domain.concat(this.data.timeRange)]; - if (this.data.compare) { - domains.push(this.data.domain.concat(this.data.comparisonTimeRange)); - } - return domains; - }, /** * Returns the rowGroupBys and colGroupBys arrays that * are actually used by the pivot view internally @@ -1083,19 +1045,6 @@ var PivotModel = AbstractModel.extend({ return originRow; }, - /** - * Create an array with the origin descriptions. - * - * @private - * @returns {string[]} - */ - _getOrigins: function () { - var origins = [this.data.timeRangeDescription || ""]; - if (this.data.compare) { - origins.push(this.data.comparisonTimeRangeDescription); - } - return origins; - }, /** * Get the selection needed to display the group by dropdown @@ -1437,6 +1386,26 @@ var PivotModel = AbstractModel.extend({ }); } }, + /** + * Determine this.data.domains and this.data.origins from + * this.data.domain and this.data.timeRanges; + * + * @private + */ + _computeDerivedParams: function () { + const { range, rangeDescription, comparisonRange, comparisonRangeDescription } = this.data.timeRanges; + if (range) { + this.data.domains = [this.data.domain.concat(range)]; + this.data.origins = [rangeDescription]; + if (comparisonRange) { + this.data.domains.push(this.data.domain.concat(comparisonRange)); + this.data.origins.push(comparisonRangeDescription); + } + } else { + this.data.domains = [this.data.domain]; + this.data.origins = [""]; + } + }, /** * Make any group in tree a leaf if it was a leaf in oldTree. * diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index bd259d9d0aa..1fca1e14487 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -18,8 +18,8 @@ odoo.define('web.PivotView', function (require) { const _t = core._t; const _lt = core._lt; - const controlPanelViewParameters = require('web.controlPanelViewParameters'); - const GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES; + const searchUtils = require('web.searchUtils'); + const GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES; const PivotView = AbstractView.extend({ display_name: _lt('Pivot'), @@ -141,7 +141,7 @@ odoo.define('web.PivotView', function (require) { */ getRenderer(parent, state) { state = Object.assign(state || {}, this.rendererParams); - return new RendererWrapper(null, this.config.Renderer, state); + return new RendererWrapper(parent, this.config.Renderer, state); }, }); diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js index 0c858b5eb6c..21ad867c286 100644 --- a/addons/web/static/src/js/views/qweb/qweb_view.js +++ b/addons/web/static/src/js/views/qweb/qweb_view.js @@ -91,16 +91,21 @@ var Renderer = AbstractRenderer.extend({ var Controller = AbstractController.extend({ events: _.extend({}, AbstractController.prototype.events, { 'click [type="toggle"]': '_onLazyToggle', + 'click [type="action"]' : '_onActionClicked', }), init: function () { this._super.apply(this, arguments); - this._$buttons = $('