odoo.define('web.AbstractView', function (require) { "use strict"; /** * This is the base class inherited by all (JS) views. Odoo JS views are the * widgets used to display information in the main area of the web client * (note: the search view is not a "JS view" in that sense). * * The abstract view role is to take a set of fields, an arch (the xml * describing the view in db), and some params, and then, to create a * controller, a renderer and a model. This is the classical MVC pattern, but * the word 'view' has historical significance in Odoo code, so we replaced the * V in MVC by the 'renderer' word. * * JS views are supposed to be used like this: * 1. instantiate a view with some arch, fields and params * 2. call the getController method on the view instance. This returns a * controller (with a model and a renderer as sub widgets) * 3. append the controller somewhere * * Note that once a controller has been instantiated, the view class is no * longer useful (unless you want to create another controller), and will be * in most case discarded. */ var AbstractModel = require('web.AbstractModel'); var AbstractRenderer = require('web.AbstractRenderer'); var AbstractController = require('web.AbstractController'); var ControlPanelView = require('web.ControlPanelView'); var mvc = require('web.mvc'); var viewUtils = require('web.viewUtils'); var Factory = mvc.Factory; var AbstractView = Factory.extend({ // name displayed in view switchers display_name: '', // indicates whether or not the view is mobile-friendly mobile_friendly: false, // icon is the font-awesome icon to display in the view switcher icon: 'fa-question', // multi_record is used to distinguish views displaying a single record // (e.g. FormView) from those that display several records (e.g. ListView) multi_record: true, // viewType is the type of the view, like 'form', 'kanban', 'list'... viewType: undefined, // determines if a search bar is available withSearchBar: true, // determines the search menus available and their orders searchMenuTypes: ['filter', 'groupBy', 'favorite'], // determines if a control panel should be instantiated withControlPanel: true, // determines the MVC components to use config: _.extend({}, Factory.prototype.config, { Model: AbstractModel, Renderer: AbstractRenderer, Controller: AbstractController, }), /** * The constructor function is supposed to set 3 variables: rendererParams, * controllerParams and loadParams. These values will be used to initialize * the model, renderer and controllers. * * @constructs AbstractView * * @param {Object} viewInfo * @param {Object|string} viewInfo.arch * @param {Object} viewInfo.fields * @param {Object} viewInfo.fieldsInfo * @param {Object} params * @param {string} [params.modelName] * @param {Object} [params.action={}] * @param {Object} [params.context={}] * @param {string} [params.controllerID] * @param {number} [params.count] * @param {number} [params.currentId] * @param {string} [params.controllerState] * @param {string} [params.displayName] * @param {Array[]} [params.domain=[]] * @param {number[]} [params.ids] * @param {boolean} [params.isEmbedded=false] * @param {Object} [params.searchQuery={}] * @param {Object} [params.searchQuery.context={}] * @param {Array[]} [params.searchQuery.domain=[]] * @param {string[]} [params.searchQuery.groupBy=[]] * @param {Object} [params.userContext={}] * @param {boolean} [params.withControlPanel=true] */ init: function (viewInfo, params) { var action = params.action || {}; params = _.defaults(params, this._extractParamsFromAction(action)); // in general, the fieldsView has to be processed by the View (e.g. the // arch is a string that needs to be parsed) ; the only exception is for // inline form views inside form views, as they are processed alongside // the main view, but they are opened in a FormViewDialog which // instantiates another FormView (unlike kanban or list subviews for // which only a Renderer is instantiated) if (typeof viewInfo.arch === 'string') { this.fieldsView = this._processFieldsView(viewInfo); } else { this.fieldsView = viewInfo; } this.arch = this.fieldsView.arch; this.fields = this.fieldsView.viewFields; this.userContext = params.userContext || {}; this.withControlPanel = this.withControlPanel && params.withControlPanel; // the boolean parameter 'isEmbedded' determines if the view should be // considered as a subview. For now this is only used by the graph // controller that appends a 'Group By' button beside the 'Measures' // button when the graph view is embedded. var isEmbedded = params.isEmbedded || false; this.rendererParams = { arch: this.arch, isEmbedded: isEmbedded, noContentHelp: params.noContentHelp, }; this.controllerParams = { actionViews: params.actionViews, activeActions: { edit: this.arch.attrs.edit ? JSON.parse(this.arch.attrs.edit) : true, create: this.arch.attrs.create ? JSON.parse(this.arch.attrs.create) : true, delete: this.arch.attrs.delete ? JSON.parse(this.arch.attrs.delete) : true, duplicate: this.arch.attrs.duplicate ? JSON.parse(this.arch.attrs.duplicate) : true, }, bannerRoute: this.arch.attrs.banner_route, controllerID: params.controllerID, displayName: params.displayName, isEmbedded: isEmbedded, isMultiRecord: this.multi_record, modelName: params.modelName, viewType: this.viewType, }; var controllerState = params.controllerState || {}; var currentId = controllerState.currentId || params.currentId; this.loadParams = { context: params.context, count: params.count || ((this.controllerParams.ids !== undefined) && this.controllerParams.ids.length) || 0, domain: params.domain, modelName: params.modelName, 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) { order = order.trim().split(' '); return {name: order[0], asc: order[1] !== 'desc'}; }); } if (params.searchQuery) { this._updateMVCParams(params.searchQuery); } this.controlPanelParams = { action: action, activateDefaultFavorite: params.activateDefaultFavorite, 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, }; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ getController: function (parent) { var self = this; var def; if (this.withControlPanel) { def = this._createControlPanel(parent); } var _super = this._super.bind(this); return $.when(def).then(function (controlPanel) { if (controlPanel) { var searchQuery = controlPanel.getSearchQuery(); self._updateMVCParams(searchQuery); } // 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(); return _super(parent).done(function (controller) { if (controlPanel) { controlPanel.setParent(controller); } if (modelParent) { // if we already add a model, restore its parent self.model.setParent(modelParent); } }); }); }, /** * Ensures that only one instance of AbstractModel is created * * @override */ getModel: function () { if (!this.model) { this.model = this._super.apply(this, arguments); } return this.model; }, /** * This is useful to customize the actual class to use before calling * createView. * * @param {Controller} Controller */ setController: function (Controller) { this.Controller = Controller; }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Instantiates and starts a ControlPanelController. * * @private * @param {Widget} parent * @returns {ControlPanelController} */ _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 () { return controlPanel; }); }); }, /** * @private * @param {Object} [action] * @param {Object} [action.context || {}] * @param {boolean} [action.context.no_breadcrumbs=false] * @param {integer} [action.context.active_id] * @param {integer[]} [action.context.active_ids] * @param {Object} [action.controlPanelFieldsView] * @param {string} [action.display_name] * @param {Array[]} [action.domain=[]] * @param {string} [action.help] * @param {integer} [action.id] * @param {integer} [action.limit] * @param {string} [action.name] * @param {string} [action.res_model] * @param {string} [action.target] * @returns {Object} */ _extractParamsFromAction: function (action) { action = action || {}; var context = action.context || {}; var inline = action.target === 'inline'; return { actionId: action.id || false, actionViews: action.views || [], activateDefaultFavorite: !context.active_id && !context.active_ids, context: action.context || {}, controlPanelFieldsView: action.controlPanelFieldsView, currentId: action.res_id ? action.res_id : undefined, // load returns 0 displayName: action.display_name || action.name, domain: action.domain || [], limit: action.limit, modelName: action.res_model, noContentHelp: action.help, searchMenuTypes: inline ? [] : this.searchMenuTypes, withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true, withControlPanel: this.withControlPanel, withSearchBar: inline ? false : this.withSearchBar, }; }, /** * Processes a fieldsView. In particular, parses its arch. * * @private * @param {Object} fieldsView * @param {string} fieldsView.arch * @returns {Object} the processed fieldsView */ _processFieldsView: function (fieldsView) { var fv = _.extend({}, fieldsView); fv.arch = viewUtils.parseArch(fv.arch); fv.viewFields = _.defaults({}, fv.viewFields, fv.fields); return fv; }, /** * Hook to update the renderer, controller and load params with the result * of a search (i.e. a context, a domain and a groupBy). * * @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 {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, 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; }, }); return AbstractView; });