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 = $('