odoo.define('web.AbstractController', function (require) { "use strict"; /** * The Controller class is the class coordinating the model and the renderer. * It is the C in MVC, and is what was formerly known in Odoo as a View. * * Its role is to listen to events bubbling up from the model/renderer, and call * the appropriate methods if necessary. It also render control panel buttons, * and react to changes in the search view. Basically, all interactions from * the renderer/model with the outside world (meaning server/reading in session/ * reading localstorage, ...) has to go through the controller. */ var ActionMixin = require('web.ActionMixin'); var ajax = require('web.ajax'); var concurrency = require('web.concurrency'); const { ComponentWrapper } = require('web.OwlCompatibility'); var mvc = require('web.mvc'); var session = require('web.session'); var AbstractController = mvc.Controller.extend(ActionMixin, { custom_events: _.extend({}, ActionMixin.custom_events, { navigation_move: '_onNavigationMove', open_record: '_onOpenRecord', switch_view: '_onSwitchView', }), events: { 'click a[type="action"]': '_onActionClicked', }, /** * @param {Object} param * @param {Object[]} params.actionViews * @param {string} params.activeActions * @param {string} params.bannerRoute * @param {Object} [params.controlPanel] * @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 {ActionModel} [params.searchModel] * @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._title = params.displayName; this.modelName = params.modelName; this.activeActions = params.activeActions; this.controllerID = params.controllerID; this.initialState = params.initialState; this.bannerRoute = params.bannerRoute; 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; this.withSearchPanel = params.withSearchPanel; if (params.searchModel) { this.searchModel = params.searchModel; } if (this.withControlPanel) { const { Component, props } = params.controlPanel; this.ControlPanel = Component; this.controlPanelProps = props; } if (this.withSearchPanel) { const { Component, props } = params.searchPanel; this.SearchPanel = Component; this.searchPanelProps = props; } }, /** * Simply renders and updates the url. * * @returns {Promise} */ start: async function () { this.$el.addClass('o_view_controller'); this.renderButtons(); const promises = [this._super(...arguments)]; if (this.withControlPanel) { this._updateControlPanelProps(this.initialState); this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps); this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus()); promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' })); } if (this.withSearchPanel) { this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps); const content = this.el.querySelector(':scope .o_content'); content.classList.add('o_controller_with_searchpanel'); promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' })); } await Promise.all(promises); await this._update(this.initialState, { shouldUpdateSearchComponents: false }); this.updateButtons(); this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode()); }, /** * @override */ destroy: function () { if (this.$buttons) { this.$buttons.off(); } ActionMixin.destroy.call(this); this._super.apply(this, arguments); }, /** * Called each time the controller is attached into the DOM. */ on_attach_callback: function () { ActionMixin.on_attach_callback.call(this); this.searchModel.on('search', this, this._onSearch); this.searchModel.trigger('focus-control-panel'); if (this.withControlPanel) { this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams); } if (!(this.renderer instanceof owl.Component)) { this.renderer.on_attach_callback(); } }, /** * Called each time the controller is detached from the DOM. */ on_detach_callback: function () { ActionMixin.on_detach_callback.call(this); this.searchModel.off('search', this); if (this.withControlPanel) { this.searchModel.off('get-controller-query-params', this); } if (!(this.renderer instanceof owl.Component)) { this.renderer.on_detach_callback(); } }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ canBeRemoved: function () { // AAB: get rid of 'readonlyIfRealDiscard' option when on_hashchange mechanism is improved return this.discardChanges(undefined, { noAbandon: true, readonlyIfRealDiscard: true, }); }, /** * Discards the changes made on the record associated to the given ID, or * all changes made by the current controller if no recordID is given. For * example, when the user opens the 'home' screen, the action manager calls * this method on the active view to make sure it is ok to open the home * screen (and lose all current state). * * Note that it returns a Promise, because the view could choose to ask the * user if he agrees to discard. * * @param {string} [recordID] * if not given, we consider all the changes made by the controller * @param {Object} [options] * @returns {Promise} resolved if properly discarded, rejected otherwise */ discardChanges: function (recordID, options) { return Promise.resolve(); }, /** * Export the state of the controller containing information that is shared * between different controllers of a same action (like the current search * model state or the states of some components). * * @returns {Object} */ exportState() { const exported = { searchModel: this.searchModel.exportState(), }; if (this.withSearchPanel) { const searchPanel = this._searchPanelWrapper.componentRef.comp; exported.searchPanel = searchPanel.exportState(); } return exported; }, /** * Parses and imports a previously exported state. * * @param {Object} state */ importState(state) { this.searchModel.importState(state.searchModel); if (this.withSearchPanel) { const searchPanel = this._searchPanelWrapper.componentRef.comp; searchPanel.importState(state.searchPanel); } }, /** * The use of this method is discouraged. It is still snakecased, because * it currently is used in many templates, but we will move to a simpler * mechanism as soon as we can. * * @deprecated * @param {string} action type of action, such as 'create', 'read', ... * @returns {boolean} */ is_action_enabled: function (action) { return this.activeActions[action]; }, /** * Short helper method to reload the view * * @param {Object} [params={}] * @param {Object} [params.controllerState={}] * @returns {Promise} */ reload: async function (params = {}) { if (params.controllerState) { this.importState(params.controllerState); Object.assign(params, this.searchModel.get('query')); } return this.update(params, {}); }, /** * 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 * this method. It is basically the way everything notifies the controller * that something has changed. * * The update method is responsible for fetching necessary data, then * updating the renderer and wait for the rendering to complete. * * @param {Object} params will be given to the model and to the renderer * @param {Object} [options={}] * @param {boolean} [options.reload=true] if true, the model will reload data * @returns {Promise} */ async update(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, { withSampleData: true }); const promises = [ this._updateRendererState(state, params).then(() => { this.renderer.setLocalState(localState); }), this._update(this.model.get(this.handle), params) ]; await this.dp.add(Promise.all(promises)); this.updateButtons(); this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode()); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Meant to be overriden to return a proper object. * @private * @param {Object} [state] * @return {(Object|null)} */ _getPagingInfo: function (state) { return null; }, /** * Meant to be overriden to return a proper object. * @private * @param {Object} [state] * @return {(Object|null)} */ _getActionMenuItems: function (state) { return null; }, /** * Gives the focus to the renderer if not in sample mode. * * @private */ _giveFocus() { if (!this.model.isInSampleMode()) { this.renderer.giveFocus(); } }, /** * 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 * example with a new id. * * @private */ _pushState: function () { this.trigger_up('push_state', { controllerID: this.controllerID, state: this.getState(), }); }, /** * @private * @param {function} callback function to execute before removing classname * 'o_view_sample_data' (may be async). This allows to reload and/or * rerender before removing the className, thus preventing the view from * flickering. */ async _removeSampleData(callback) { this.model.leaveSampleMode(); if (callback) { await callback(); } this.el.classList.remove('o_view_sample_data'); }, /** * Renders the html provided by the route specified by the * bannerRoute attribute on the controller (banner_route in the template). * Renders it before the view output and add a css class 'o_has_banner' to it. * There can be only one banner displayed at a time. * * If the banner contains stylesheet links or js files, they are moved to
* (and will only be fetched once). * * Route example: * @http.route('/module/hello', auth='user', type='json') * def hello(self): * return {'html': '