diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views')
60 files changed, 30428 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js new file mode 100644 index 00000000..ed5aef40 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -0,0 +1,607 @@ +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 <head> + * (and will only be fetched once). + * + * Route example: + * @http.route('/module/hello', auth='user', type='json') + * def hello(self): + * return {'html': '<h1>hello, world</h1>'} + * + * @private + * @returns {Promise} + */ + _renderBanner: async function () { + if (this.bannerRoute !== undefined) { + const response = await this._rpc({ + route: this.bannerRoute, + params: {context: session.user_context}, + }); + 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 <head> + 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(); + }); + await Promise.all(defs); + $banner.insertBefore(this.$('> .o_content')); + this._$banner = $banner; + } + }, + /** + * @override + * @private + */ + _startRenderer: function () { + if (this.renderer instanceof owl.Component) { + return this.renderer.mount(this.$('.o_content')[0]); + } + return this.renderer.appendTo(this.$('.o_content')); + }, + /** + * This method is called after each update or when the start method is + * completed. + * + * Its primary use is to be used as a hook to update all parts of the UI, + * besides the renderer. For example, it may be used to enable/disable + * some buttons in the control panel, such as the current graph type for a + * graph view. + * + * FIXME: this hook should be synchronous, and called once async rendering + * has been done. + * + * @private + * @param {Object} state the state given by the model + * @param {Object} [params={}] + * @param {Array} [params.breadcrumbs] + * @param {Object} [params.shouldUpdateSearchComponents] + * @returns {Promise} + */ + async _update(state, params) { + // AAB: update the control panel -> this will be moved elsewhere at some point + if (!this.$buttons) { + this.renderButtons(); + } + const promises = [this._renderBanner()]; + if (params.shouldUpdateSearchComponents !== false) { + if (this.withControlPanel) { + this._updateControlPanelProps(state); + if (params.breadcrumbs) { + this.controlPanelProps.breadcrumbs = params.breadcrumbs; + } + promises.push(this.updateControlPanel()); + } + if (this.withSearchPanel) { + this._updateSearchPanel(); + } + } + this._pushState(); + await 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(), + }); + }, + /** + * @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 }); + } + }, + /** + * Updates the state of the renderer (handle both Widget and Component + * renderers). + * + * @private + * @param {Object} state the model state + * @param {Object} [params={}] will be given to the model and to the renderer + * @return {Promise} + */ + _updateRendererState(state, params = {}) { + if (this.renderer instanceof owl.Component) { + return this.renderer.update(state); + } + return this.renderer.updateState(state, params); + }, + /** + * @private + * @param {Object} [newProps={}] + * @return {Promise} + */ + async _updateSearchPanel(newProps) { + Object.assign(this.searchPanelProps, newProps); + await this._searchPanelWrapper.update(this.searchPanelProps); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When a user clicks on an <a> link with type="action", we need to actually + * do the action. This kind of links is used a lot in no-content helpers. + * + * * if the link has both data-model and data-method attributes, the + * corresponding method is called, chained to any action it would + * return. An optional data-reload-on-close (set to a non-falsy value) + * also causes th underlying view to be reloaded after the dialog is + * closed. + * * if the link has a name attribute, invoke the action with that + * identifier (see :class:`ActionManager.doAction` to not get the + * details) + * * otherwise an *action descriptor* is built from the link's data- + * attributes (model, res-id, views, domain and context) + * + * @private + * @param ev + */ + _onActionClicked: function (ev) { // FIXME: maybe this should also work on <button> tags? + ev.preventDefault(); + var $target = $(ev.currentTarget); + var self = this; + var data = $target.data(); + + if (data.method !== undefined && data.model !== undefined) { + var options = {}; + if (data.reloadOnClose) { + options.on_close = function () { + self.trigger_up('reload'); + }; + } + this.dp.add(this._rpc({ + model: data.model, + method: data.method, + context: session.user_context, + })).then(function (action) { + if (action !== undefined) { + self.do_action(action, options); + } + }); + } else if ($target.attr('name')) { + this.do_action( + $target.attr('name'), + data.context && {additional_context: data.context} + ); + } else { + this.do_action({ + name: $target.attr('title') || _.str.strip($target.text()), + type: 'ir.actions.act_window', + res_model: data.model || this.modelName, + res_id: data.resId, + target: 'current', // TODO: make customisable? + views: data.views || (data.resId ? [[false, 'form']] : [[false, 'list'], [false, 'form']]), + domain: data.domain || [], + }, { + additional_context: _.extend({}, data.context) + }); + } + }, + /** + * Called either from the control panel to focus the controller + * or from the view to focus the search bar + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + switch (ev.data.direction) { + case 'up': + ev.stopPropagation(); + this.searchModel.trigger('focus-control-panel'); + break; + case 'down': + ev.stopPropagation(); + this._giveFocus(); + break; + } + }, + /** + * When an Odoo event arrives requesting a record to be opened, this method + * gets the res_id, and request a switch view in the appropriate mode + * + * Note: this method seems wrong, it relies on the model being a basic model, + * to get the res_id. It should receive the res_id in the event data + * @todo move this to basic controller? + * + * @private + * @param {OdooEvent} ev + * @param {number} ev.data.id The local model ID for the record to be + * opened + * @param {string} [ev.data.mode='readonly'] + */ + _onOpenRecord: function (ev) { + ev.stopPropagation(); + var record = this.model.get(ev.data.id, {raw: true}); + this.trigger_up('switch_view', { + view_type: 'form', + res_id: record.res_id, + mode: ev.data.mode || 'readonly', + model: this.modelName, + }); + }, + /** + * 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, groupby,... + * + * @private + * @param {Object} searchQuery + */ + _onSearch: function (searchQuery) { + this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery)); + }, + /** + * Intercepts the 'switch_view' event to add the controllerID into the data, + * and lets the event bubble up. + * + * @param {OdooEvent} ev + */ + _onSwitchView: function (ev) { + ev.data.controllerID = this.controllerID; + }, +}); + +return AbstractController; + +}); diff --git a/addons/web/static/src/js/views/abstract_model.js b/addons/web/static/src/js/views/abstract_model.js new file mode 100644 index 00000000..db2cce99 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_model.js @@ -0,0 +1,286 @@ +odoo.define('web.AbstractModel', function (require) { +"use strict"; + +/** + * An AbstractModel is the M in MVC. We tend to think of MVC more on the server + * side, but we are talking here on the web client side. + * + * The duties of the Model are to fetch all relevant data, and to make them + * available for the rest of the view. Also, every modification to that data + * should pass through the model. + * + * Note that the model is not a widget, it does not need to be rendered or + * appended to the dom. However, it inherits from the EventDispatcherMixin, + * in order to be able to notify its parent by bubbling events up. + * + * The model is able to generate sample (fake) data when there is no actual data + * in database. This feature can be activated by instantiating the model with + * param "useSampleModel" set to true. In this case, the model instantiates a + * duplicated version of itself, parametrized to call a SampleServer (JS) + * instead of doing RPCs. Here is how it works: the main model first load the + * data normally (from database), and then checks whether the result is empty or + * not. If it is, it asks the sample model to load with the exact same params, + * and it thus enters in "sample" mode. The model keeps doing this at reload, + * but only if the (re)load params haven't changed: as soon as a param changes, + * the "sample" mode is left, and it never enters it again in the future (in the + * lifetime of the model instance). To access those sample data from the outside, + * 'get' must be called with the the option "withSampleData" set to true. In + * this case, if the main model is in "sample" mode, it redirects the call to the + * sample model. + */ + +var fieldUtils = require('web.field_utils'); +var mvc = require('web.mvc'); +const SampleServer = require('web.SampleServer'); + + +var AbstractModel = mvc.Model.extend({ + /** + * @param {Widget} parent + * @param {Object} [params={}] + * @param {Object} [params.fields] + * @param {string} [params.modelName] + * @param {boolean} [params.isSampleModel=false] if true, will fetch data + * from a SampleServer instead of doing RPCs + * @param {boolean} [params.useSampleModel=false] if true, will use a sample + * model to generate sample data when there is no "real" data in database + * @param {AbstractModel} [params.SampleModel] the AbstractModel class + * to instantiate as sample model. This model won't do any rpc, but will + * rather call a SampleServer that will generate sample data. This param + * must be set when params.useSampleModel is true. + */ + init(parent, params = {}) { + this._super(...arguments); + this.useSampleModel = params.useSampleModel || false; + if (params.isSampleModel) { + this.isSampleModel = true; + this.sampleServer = new SampleServer(params.modelName, params.fields); + } else if (this.useSampleModel) { + const sampleModelParams = Object.assign({}, params, { + isSampleModel: true, + SampleModel: null, + useSampleModel: false, + }); + this.sampleModel = new params.SampleModel(this, sampleModelParams); + this._isInSampleMode = false; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Override to call get on the sampleModel when we are in sample mode, and + * option 'withSampleData' is set to true. + * + * @override + * @param {any} _ + * @param {Object} [options] + * @param {boolean} [options.withSampleData=false] + */ + get(_, options) { + let state; + if (options && options.withSampleData && this._isInSampleMode) { + state = this.sampleModel.__get(...arguments); + } else { + state = this.__get(...arguments); + } + return state; + }, + /** + * Under some conditions, the model is designed to generate sample data if + * there is no real data in database. This function returns a boolean which + * indicates the mode of the model: if true, we are in "sample" mode. + * + * @returns {boolean} + */ + isInSampleMode() { + return !!this._isInSampleMode; + }, + /** + * Disables the sample data (forever) on this model instance. + */ + leaveSampleMode() { + if (this.useSampleModel) { + this.useSampleModel = false; + this._isInSampleMode = false; + this.sampleModel.destroy(); + } + }, + /** + * Override to check if we need to call the sample model (and if so, to do + * it) after loading the data, in the case where there is no real data to + * display. + * + * @override + */ + async load(params) { + this.loadParams = params; + const handle = await this.__load(...arguments); + await this._callSampleModel('__load', handle, ...arguments); + return handle; + }, + /** + * When something changes, the data may need to be refetched. This is the + * job for this method: reloading (only if necessary) all the data and + * making sure that they are ready to be redisplayed. + * Sometimes, we reload the data with the "same" params as the initial load + * params (see '_haveParamsChanged'). When we do, if we were in "sample" mode, + * we call again the sample server after the reload if there is still no data + * to display. When the parameters change, we automatically leave "sample" + * mode. + * + * @param {any} _ + * @param {Object} [params] + * @returns {Promise} + */ + async reload(_, params) { + const handle = await this.__reload(...arguments); + if (this._isInSampleMode) { + if (!this._haveParamsChanged(params)) { + await this._callSampleModel('__reload', handle, ...arguments); + } else { + this.leaveSampleMode(); + } + } + return handle; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} method + * @param {any} handle + * @param {...any} args + * @returns {Promise} + */ + async _callSampleModel(method, handle, ...args) { + if (this.useSampleModel && this._isEmpty(handle)) { + try { + if (method === '__load') { + await this.sampleModel.__load(...args); + } else if (method === '__reload') { + await this.sampleModel.__reload(...args); + } + this._isInSampleMode = true; + } catch (error) { + if (error instanceof SampleServer.UnimplementedRouteError) { + this.leaveSampleMode(); + } else { + throw error; + } + } + } else { + this.leaveSampleMode(); + } + }, + /** + * @private + * @returns {Object} + */ + __get() { + return {}; + }, + /** + * This function can be overriden to determine if the result of a load or + * a reload is empty. In the affirmative, we will try to generate sample + * data to prevent from having an empty state to display. + * + * @private + * @params {any} handle, the value returned by a load or a reload + * @returns {boolean} + */ + _isEmpty(/* handle */) { + return false; + }, + /** + * To override to do the initial load of the data (this function is supposed + * to be called only once). + * + * @private + * @returns {Promise} + */ + async __load() { + return Promise.resolve(); + }, + /** + * Processes date(time) and selection field values sent by the server. + * Converts data(time) values to moment instances. + * Converts false values of selection fields to 0 if 0 is a valid key, + * because the server doesn't make a distinction between false and 0, and + * always sends false when value is 0. + * + * @param {Object} field the field description + * @param {*} value + * @returns {*} the processed value + */ + _parseServerValue: function (field, value) { + if (field.type === 'date' || field.type === 'datetime') { + // process date(time): convert into a moment instance + value = fieldUtils.parse[field.type](value, field, {isUTC: true}); + } else if (field.type === 'selection' && value === false) { + // process selection: convert false to 0, if 0 is a valid key + var hasKey0 = _.find(field.selection, function (option) { + return option[0] === 0; + }); + value = hasKey0 ? 0 : value; + } + return value; + }, + /** + * To override to reload data (this function may be called several times, + * after the initial load has been done). + * + * @private + * @returns {Promise} + */ + async __reload() { + return Promise.resolve(); + }, + /** + * Determines whether or not the given params (reload params) differ from + * the initial ones (this.loadParams). This is used to leave "sample" mode + * as soon as a parameter (e.g. domain) changes. + * + * @private + * @param {Object} [params={}] + * @param {Object} [params.context] + * @param {Array[]} [params.domain] + * @param {Object} [params.timeRanges] + * @param {string[]} [params.groupBy] + * @returns {boolean} + */ + _haveParamsChanged(params = {}) { + for (const key of ['context', 'domain', 'timeRanges']) { + if (key in params) { + const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]); + if (diff) { + return true; + } + } + } + if (this.useSampleModel && 'groupBy' in params) { + return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy); + } + }, + /** + * Override to redirect all rpcs to the SampleServer if this.isSampleModel + * is true. + * + * @override + */ + async _rpc() { + if (this.isSampleModel) { + return this.sampleServer.mockRpc(...arguments); + } + return this._super(...arguments); + }, +}); + +return AbstractModel; + +}); diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js new file mode 100644 index 00000000..c2cc7f2f --- /dev/null +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -0,0 +1,217 @@ +odoo.define('web.AbstractRenderer', function (require) { +"use strict"; + +/** + * The renderer should not handle pagination, data loading, or coordination + * with the control panel. It is only concerned with rendering. + * + */ + +var mvc = require('web.mvc'); + +// Renderers may display sample data when there is no real data to display. In +// this case the data is displayed with opacity and can't be clicked. Moreover, +// we also want to prevent the user from accessing DOM elements with TAB +// navigation. This is the list of elements we won't allow to focus. +const FOCUSABLE_ELEMENTS = [ + // focusable by default + 'a', 'button', 'input', 'select', 'textarea', + // manually set + '[tabindex="0"]' +].map((sel) => `:scope ${sel}`).join(', '); + +/** + * @class AbstractRenderer + */ +return mvc.Renderer.extend({ + // Defines the elements suppressed when in demo data. This must be a list + // of DOM selectors matching view elements that will: + // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events) + // 2. have themselves and any of their focusable children removed from the + // tab navigation + sampleDataTargets: [], + + /** + * @override + * @param {string} [params.noContentHelp] + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.arch = params.arch; + this.noContentHelp = params.noContentHelp; + this.withSearchPanel = params.withSearchPanel; + }, + /** + * The rendering is asynchronous. The start + * method simply makes sure that we render the view. + * + * @returns {Promise} + */ + async start() { + this.$el.addClass(this.arch.attrs.class); + if (this.withSearchPanel) { + this.$el.addClass('o_renderer_with_searchpanel'); + } + await Promise.all([this._render(), this._super()]); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () {}, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () {}, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns any relevant state that the renderer might want to keep. + * + * The idea is that a renderer can be destroyed, then be replaced by another + * one instantiated with the state from the model and the localState from + * the renderer, and the end result should be the same. + * + * The kind of state that we expect the renderer to have is mostly DOM state + * such as the scroll position, the currently active tab page, ... + * + * This method is called before each updateState, by the controller. + * + * @see setLocalState + * @returns {any} + */ + getLocalState: function () { + }, + /** + * Order to focus to be given to the content of the current view + */ + giveFocus: function () { + }, + /** + * Resets state that renderer keeps, state may contains scroll position, + * the currently active tab page, ... + * + * @see getLocalState + * @see setLocalState + */ + resetLocalState() { + }, + /** + * This is the reverse operation from getLocalState. With this method, we + * expect the renderer to restore all DOM state, if it is relevant. + * + * This method is called after each updateState, by the controller. + * + * @see getLocalState + * @param {any} localState the result of a call to getLocalState + */ + setLocalState: function (localState) { + }, + /** + * Updates the state of the view. It retriggers a full rerender, unless told + * otherwise (for optimization for example). + * + * @param {any} state + * @param {Object} params + * @param {boolean} [params.noRender=false] + * if true, the method only updates the state without rerendering + * @returns {Promise} + */ + async updateState(state, params) { + this._setState(state); + if (!params.noRender) { + await this._render(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Renders the widget. This method can be overriden to perform actions + * before or after the view has been rendered. + * + * @private + * @returns {Promise} + */ + async _render() { + await this._renderView(); + this._suppressFocusableElements(); + }, + /** + * @private + * @param {Object} context + */ + _renderNoContentHelper: function (context) { + let templateName; + if (!context && this.noContentHelp) { + templateName = "web.ActionHelper"; + context = { noContentHelp: this.noContentHelp }; + } else { + templateName = "web.NoContentHelper"; + } + const template = document.createElement('template'); + // FIXME: retrieve owl qweb instance via the env set on Component s.t. + // it also works in the tests (importing 'web.env' wouldn't). This + // won't be necessary as soon as this will be written in owl. + const owlQWeb = owl.Component.env.qweb; + template.innerHTML = owlQWeb.renderToString(templateName, context); + this.el.append(template.content.firstChild); + }, + /** + * Actual rendering. This method is meant to be overridden by concrete + * renderers. + * + * @abstract + * @private + * @returns {Promise} + */ + async _renderView() { }, + /** + * Assigns a new state to the renderer if not false. + * + * @private + * @param {any} [state=false] + */ + _setState(state = false) { + if (state !== false) { + this.state = state; + } + }, + /** + * Suppresses 'tabindex' property on any focusable element located inside + * root elements defined in the `this.sampleDataTargets` object and assigns + * the 'o_sample_data_disabled' class to these root elements. + * + * @private + * @see sampleDataTargets + */ + _suppressFocusableElements() { + if (!this.state.isSample || this.isEmbedded) { + return; + } + const rootEls = []; + for (const selector of this.sampleDataTargets) { + rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`)); + } + const focusableEls = new Set(rootEls); + for (const rootEl of rootEls) { + rootEl.classList.add('o_sample_data_disabled'); + for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) { + focusableEls.add(focusableEl); + } + } + for (const focusableEl of focusableEls) { + focusableEl.setAttribute('tabindex', -1); + if (focusableEl.classList.contains('dropdown-item')) { + // Tells Bootstrap to ignore the dropdown item in keynav + focusableEl.classList.add('disabled'); + } + } + }, +}); + +}); diff --git a/addons/web/static/src/js/views/abstract_renderer_owl.js b/addons/web/static/src/js/views/abstract_renderer_owl.js new file mode 100644 index 00000000..0c97159e --- /dev/null +++ b/addons/web/static/src/js/views/abstract_renderer_owl.js @@ -0,0 +1,72 @@ +odoo.define('web.AbstractRendererOwl', function () { + "use strict"; + + // Renderers may display sample data when there is no real data to display. In + // this case the data is displayed with opacity and can't be clicked. Moreover, + // we also want to prevent the user from accessing DOM elements with TAB + // navigation. This is the list of elements we won't allow to focus. + const FOCUSABLE_ELEMENTS = [ + // focusable by default + 'a', 'button', 'input', 'select', 'textarea', + // manually set + '[tabindex="0"]' + ].map((sel) => `:scope ${sel}`).join(', '); + + class AbstractRenderer extends owl.Component { + + constructor() { + super(...arguments); + // Defines the elements suppressed when in demo data. This must be a list + // of DOM selectors matching view elements that will: + // 1. receive the 'o_sample_data_disabled' class (greyd out & no user events) + // 2. have themselves and any of their focusable children removed from the + // tab navigation + this.sampleDataTargets = []; + } + + mounted() { + this._suppressFocusableElements(); + } + + patched() { + this._suppressFocusableElements(); + } + + /** + * Suppresses 'tabindex' property on any focusable element located inside + * root elements defined in the `this.sampleDataTargets` object and assigns + * the 'o_sample_data_disabled' class to these root elements. + * + * @private + * @see sampleDataTargets + */ + _suppressFocusableElements() { + if (!this.props.isSample || this.props.isEmbedded) { + const disabledEls = this.el.querySelectorAll(`.o_sample_data_disabled`); + disabledEls.forEach(el => el.classList.remove('o_sample_data_disabled')); + return; + } + const rootEls = []; + for (const selector of this.sampleDataTargets) { + rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`)); + } + const focusableEls = new Set(rootEls); + for (const rootEl of rootEls) { + rootEl.classList.add('o_sample_data_disabled'); + for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) { + focusableEls.add(focusableEl); + } + } + for (const focusableEl of focusableEls) { + focusableEl.setAttribute('tabindex', -1); + if (focusableEl.classList.contains('dropdown-item')) { + // Tells Bootstrap to ignore the dropdown item in keynav + focusableEl.classList.add('disabled'); + } + } + } + } + + return AbstractRenderer; + +}); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js new file mode 100644 index 00000000..9440bf67 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_view.js @@ -0,0 +1,440 @@ +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. + */ + +const ActionModel = require("web/static/src/js/views/action_model.js"); +var AbstractModel = require('web.AbstractModel'); +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractController = require('web.AbstractController'); +const ControlPanel = require('web.ControlPanel'); +const SearchPanel = require("web/static/src/js/views/search_panel.js"); +var mvc = require('web.mvc'); +var viewUtils = require('web.viewUtils'); + +const { Component } = owl; + +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 if a search panel could be instantiated + withSearchPanel: true, + // determines the MVC components to use + config: _.extend({}, Factory.prototype.config, { + Model: AbstractModel, + Renderer: AbstractRenderer, + Controller: AbstractController, + ControlPanel, + SearchPanel, + }), + + /** + * 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 {Object} [params.controllerState] + * @param {string} [params.displayName] + * @param {Array[]} [params.domain=[]] + * @param {Object[]} [params.dynamicFilters] transmitted to the + * ControlPanel + * @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.useSampleModel] + * @param {boolean} [params.withControlPanel=AbstractView.prototype.withControlPanel] + * @param {boolean} [params.withSearchPanel=AbstractView.prototype.withSearchPanel] + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + 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 || {}; + + // 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; + + // The noContentHelper's message can be empty, i.e. either a real empty string + // or an empty html tag. In both cases, we consider the helper empty. + var help = params.noContentHelp || ""; + var htmlHelp = document.createElement("div"); + htmlHelp.innerHTML = help; + this.rendererParams = { + arch: this.arch, + isEmbedded: isEmbedded, + noContentHelp: htmlHelp.innerText.trim() ? help : "", + }; + + 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, + 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), + }; + + const useSampleModel = 'useSampleModel' in params ? + params.useSampleModel : + !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample)); + + this.modelParams = { + fields: this.fields, + modelName: params.modelName, + useSampleModel, + }; + if (useSampleModel) { + this.modelParams.SampleModel = this.config.Model; + } + + 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.withControlPanel = this.withControlPanel && params.withControlPanel; + this.withSearchPanel = this.withSearchPanel && + this.multi_record && params.withSearchPanel && + !('search_panel' in params.context && !params.search_panel); + + const searchModelParams = Object.assign({}, params, { action }); + if (this.withControlPanel || this.withSearchPanel) { + const { arch, fields, favoriteFilters } = params.controlPanelFieldsView || {}; + const archInfo = ActionModel.extractArchInfo({ search: arch }, this.viewType); + const controlPanelInfo = archInfo[this.config.ControlPanel.modelExtension]; + const searchPanelInfo = archInfo[this.config.SearchPanel.modelExtension]; + this.withSearchPanel = this.withSearchPanel && Boolean(searchPanelInfo); + Object.assign(searchModelParams, { + fields, + favoriteFilters, + controlPanelInfo, + searchPanelInfo, + }); + } + const searchModel = this._createSearchModel(searchModelParams); + this.controllerParams.searchModel = searchModel; + if (this.controllerParams.controlPanel) { + this.controllerParams.controlPanel.props.searchModel = searchModel; + } + if (this.controllerParams.searchPanel) { + this.controllerParams.searchPanel.props.searchModel = searchModel; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {Object} params + * @param {Object} extraExtensions + * @returns {ActionModel} + */ + _createSearchModel: function (params, extraExtensions) { + // Search model + common config + const { fields, favoriteFilters, controlPanelInfo, searchPanelInfo } = params; + const extensions = Object.assign({}, extraExtensions); + const importedState = params.controllerState || {}; + + // Control panel params + if (this.withControlPanel) { + // Control panel (Model) + const ControlPanelComponent = this.config.ControlPanel; + extensions[ControlPanelComponent.modelExtension] = { + actionId: params.action.id, + // control initialization + activateDefaultFavorite: params.activateDefaultFavorite, + archNodes: controlPanelInfo.children, + dynamicFilters: params.dynamicFilters, + favoriteFilters, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.withControlPanel = true; + // Control panel (Component) + const controlPanelProps = { + action: params.action, + breadcrumbs: params.breadcrumbs, + fields, + searchMenuTypes: params.searchMenuTypes, + view: this.fieldsView, + views: params.action.views && params.action.views.filter( + v => v.multiRecord === this.multi_record + ), + withBreadcrumbs: params.withBreadcrumbs, + withSearchBar: params.withSearchBar, + }; + this.controllerParams.controlPanel = { + Component: ControlPanelComponent, + props: controlPanelProps, + }; + } + + // Search panel params + if (this.withSearchPanel) { + // Search panel (Model) + const SearchPanelComponent = this.config.SearchPanel; + extensions[SearchPanelComponent.modelExtension] = { + archNodes: searchPanelInfo.children, + }; + this.controllerParams.withSearchPanel = true; + this.rendererParams.withSearchPanel = true; + // Search panel (Component) + const searchPanelProps = { + importedState: importedState.searchPanel, + }; + if (searchPanelInfo.attrs.class) { + searchPanelProps.className = searchPanelInfo.attrs.class; + } + this.controllerParams.searchPanel = { + Component: SearchPanelComponent, + props: searchPanelProps, + }; + } + + const searchModel = new ActionModel(extensions, { + env: Component.env, + modelName: params.modelName, + context: Object.assign({}, this.loadParams.context), + domain: this.loadParams.domain || [], + importedState: importedState.searchModel, + searchMenuTypes: params.searchMenuTypes, + searchQuery: params.searchQuery, + fields, + }); + + return searchModel; + }, + + /** + * @override + */ + getController: async function () { + const _super = this._super.bind(this); + const { searchModel } = this.controllerParams; + await searchModel.load(); + this._updateMVCParams(searchModel.get("query")); + // 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 Promise.all([ + _super(...arguments), + searchModel.isReady(), + ]); + 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 + * + * @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 + //-------------------------------------------------------------------------- + + /** + * @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] + * @param {boolean} [action.useSampleModel] + * @returns {Object} + */ + _extractParamsFromAction: function (action) { + action = action || {}; + var context = action.context || {}; + var inline = action.target === 'inline'; + const params = { + 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, + withSearchPanel: this.withSearchPanel, + }; + if ('useSampleModel' in action) { + params.useSampleModel = action.useSampleModel; + } + return params; + }, + /** + * 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.timeRanges] + * @param {Array[]} searchQuery.domain + * @param {string[]} searchQuery.groupBy + */ + _updateMVCParams: function (searchQuery) { + this.loadParams = _.extend(this.loadParams, { + context: searchQuery.context, + domain: searchQuery.domain, + groupedBy: searchQuery.groupBy, + }); + 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; + } + }, +}); + +return AbstractView; + +}); diff --git a/addons/web/static/src/js/views/action_model.js b/addons/web/static/src/js/views/action_model.js new file mode 100644 index 00000000..c3b69271 --- /dev/null +++ b/addons/web/static/src/js/views/action_model.js @@ -0,0 +1,236 @@ +odoo.define("web/static/src/js/views/action_model.js", function (require) { + "use strict"; + + const Domain = require("web.Domain"); + const { FACET_ICONS } = require("web.searchUtils"); + const { Model } = require("web/static/src/js/model.js"); + const { parseArch } = require("web.viewUtils"); + const pyUtils = require("web.py_utils"); + const Registry = require("web.Registry"); + + const isNotNull = (value) => value !== null && value !== undefined; + const isObject = (obj) => typeof obj === "object" && obj !== null; + + /** + * @extends Model.Extension + */ + class ActionModelExtension extends Model.Extension { + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Initiates the asynchronous tasks of the extension and returns a + * promise resolved as soon as all the informations necessary to build + * the search query are ready. + * @returns {Promise} + */ + async callLoad() { + this.loadPromise = super.callLoad(...arguments); + await this.loadPromise; + } + + /** + * Returns a promise resolved when the extension is completely ready. + * @returns {Promise} + */ + async isReady() { + await this.loadPromise; + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @abstract + * @param {Object} archs + * @param {string | null} [viewType=null] + * @returns {null} + */ + static extractArchInfo() { + return null; + } + } + + /** + * @extends Model + */ + class ActionModel extends Model { + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + */ + get(property) { + switch (property) { + case "query": return this.config.searchQuery || this._getQuery(); + case "facets": return this._getFacets(); + } + return super.get(...arguments); + } + + /** + * Returns a promise resolved when all extensions are completely ready. + * @returns {Promise} + */ + async isReady() { + await this._awaitExtensions(); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + async _awaitExtensions() { + await Promise.all(this.extensions.flat().map( + (extension) => extension.isReady() + )); + } + + /** + * @override + */ + __get(excluded, property) { + const results = super.__get(...arguments); + switch (property) { + case "domain": return [this.config.domain, ...results]; + case "context": return [this.config.context, ...results]; + } + return results; + } + + /** + * Validates and formats all facets given by the extensions. This is + * done here rather than in the search bar because the searchMenuTypes + * are available only to the model. + * @private + * @returns {Object[]} + */ + _getFacets() { + const types = this.config.searchMenuTypes || []; + const isValidType = (type) => ( + !['groupBy', 'comparison'].includes(type) || types.includes(type) + ); + const facets = []; + for (const extension of this.extensions.flat()) { + for (const facet of extension.get("facets") || []) { + if (!isValidType(facet.type)) { + continue; + } + facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or"); + if (facet.type in FACET_ICONS) { + facet.icon = FACET_ICONS[facet.type]; + } + facets.push(facet); + } + } + return facets; + } + + /** + * @typedef TimeRanges + * @property {string} fieldName + * @property {string} comparisonRangeId + * @property {Array[]} range + * @property {string} rangeDescription + * @property {Array[]} comparisonRange + * @property {string} comparisonRangeDescription + */ + /** + * @typedef Query + * @property {Object} context + * @property {Array[]} domain + * @property {string[]} groupBy + * @property {string[]} orderedBy + * @property {TimeRanges?} timeRanges + */ + /** + * @private + * @returns {Query} + */ + _getQuery() { + const evalContext = this.env.session.user_context; + const contexts = this.__get(null, "context"); + const domains = this.__get(null, "domain"); + const query = { + context: pyUtils.eval("contexts", contexts, evalContext), + domain: Domain.prototype.normalizeArray( + pyUtils.eval("domains", domains, evalContext) + ), + orderedBy: this.get("orderedBy") || [], + }; + const searchMenuTypes = this.config.searchMenuTypes || []; + if (searchMenuTypes.includes("groupBy")) { + query.groupBy = this.get("groupBy") || []; + } else { + query.groupBy = []; + } + if (searchMenuTypes.includes("comparison")) { + query.timeRanges = this.get("timeRanges") || {}; + } + return query; + } + + /** + * Overridden to trigger a "search" event as soon as the query data + * are ready. + * @override + */ + async _loadExtensions({ isInitialLoad }) { + await super._loadExtensions(...arguments); + if (!isInitialLoad) { + this.trigger("search", this.get("query")); + await this._awaitExtensions(); + } + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @param {Object} archs + * @param {string | null} [viewType=null] + * @returns {Object} + */ + static extractArchInfo(archs, viewType = null) { + const parsedArchs = {}; + if (!archs.search) { + archs.search = "<search/>"; + } + for (const key in archs) { + const { attrs, children } = parseArch(archs[key]); + const objectChildren = children.filter(isObject); + parsedArchs[key] = { + attrs, + children: objectChildren, + }; + } + const archInfo = {}; + for (const key of this.registry.keys()) { + const extension = this.registry.get(key); + const result = extension.extractArchInfo(parsedArchs, viewType); + if (isNotNull(result)) { + archInfo[key] = result; + } + } + return archInfo; + } + } + + ActionModel.Extension = ActionModelExtension; + ActionModel.registry = new Registry(null, + (value) => value.prototype instanceof ActionModel.Extension + ); + + return ActionModel; +}); diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js new file mode 100644 index 00000000..4cbe9027 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -0,0 +1,883 @@ +odoo.define('web.BasicController', function (require) { +"use strict"; + +/** + * The BasicController is mostly here to share code between views that will use + * a BasicModel (or a subclass). Currently, the BasicViews are the form, list + * and kanban views. + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var TranslationDialog = require('web.TranslationDialog'); + +var _t = core._t; + +var BasicController = AbstractController.extend(FieldManagerMixin, { + events: Object.assign({}, AbstractController.prototype.events, { + 'click .o_content': '_onContentClicked', + }), + 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', + translate: '_onTranslate', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.archiveEnabled + * @param {boolean} params.confirmOnDelete + * @param {boolean} params.hasButtons + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.archiveEnabled = params.archiveEnabled; + this.confirmOnDelete = params.confirmOnDelete; + this.hasButtons = params.hasButtons; + FieldManagerMixin.init.call(this, this.model); + this.mode = params.mode || 'readonly'; + // savingDef is used to ensure that we always wait for pending save + // operations to complete before checking if there are changes to + // discard when discardChanges is called + this.savingDef = Promise.resolve(); + // discardingDef is used to ensure that we don't ask twice the user if + // he wants to discard changes, when 'canBeDiscarded' is called several + // times "in parallel" + this.discardingDef = null; + this.viewId = params.viewId; + }, + /** + * @override + * @returns {Promise} + */ + 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); + await this._super(...arguments); + }, + /** + * Called each time the controller is dettached into the DOM + */ + on_detach_callback() { + this._super.apply(this, arguments); + this.renderer.resetLocalState(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Determines if we can discard the current changes. If the model is not + * dirty, that is not a problem. However, if it is dirty, we have to ask + * the user for confirmation. + * + * @override + * @param {string} [recordID] - default to main recordID + * @returns {Promise<boolean>} + * resolved if can be discarded, a boolean value is given to tells + * if there is something to discard or not + * rejected otherwise + */ + canBeDiscarded: function (recordID) { + var self = this; + if (this.discardingDef) { + // discard dialog is already open + return this.discardingDef; + } + if (!this.isDirty(recordID)) { + return Promise.resolve(false); + } + + var message = _t("The record has been modified, your changes will be discarded. Do you want to proceed?"); + this.discardingDef = new Promise(function (resolve, reject) { + var dialog = Dialog.confirm(self, message, { + title: _t("Warning"), + confirm_callback: () => { + resolve(true); + self.discardingDef = null; + }, + cancel_callback: () => { + reject(); + self.discardingDef = null; + }, + }); + dialog.on('closed', self.discardingDef, reject); + }); + return this.discardingDef; + }, + /** + * Ask the renderer if all associated field widget are in a valid state for + * saving (valid value and non-empty value for required fields). If this is + * not the case, this notifies the user with a warning containing the names + * of the invalid fields. + * + * Note: changing the style of invalid fields is the renderer's job. + * + * @param {string} [recordID] - default to main recordID + * @return {boolean} + */ + canBeSaved: function (recordID) { + var fieldNames = this.renderer.canBeSaved(recordID || this.handle); + if (fieldNames.length) { + this._notifyInvalidFields(fieldNames); + return false; + } + return true; + }, + /** + * Waits for the mutex to be unlocked and for changes to be saved, then + * calls _.discardChanges. + * This ensures that the confirm dialog isn't displayed directly if there is + * a pending 'write' rpc. + * + * @see _.discardChanges + */ + discardChanges: function (recordID, options) { + return Promise.all([this.mutex.getUnlockedDef(), this.savingDef]) + .then(this._discardChanges.bind(this, recordID || this.handle, options)); + }, + /** + * Method that will be overridden by the views with the ability to have selected ids + * + * @returns {Array} + */ + getSelectedIds: function () { + return []; + }, + /** + * Returns true iff the given recordID (or the main recordID) is dirty. + * + * @param {string} [recordID] - default to main recordID + * @returns {boolean} + */ + isDirty: function (recordID) { + return this.model.isDirty(recordID || this.handle); + }, + /** + * Saves the record whose ID is given if necessary (@see _saveRecord). + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + * Rejected if the record can't be saved + */ + saveRecord: function (recordID, options) { + var self = this; + // Some field widgets can't detect (all) their changes immediately or + // may have to validate them before notifying them, so we ask them to + // commit their current value before saving. This has to be done outside + // of the mutex protection of saving because commitChanges will trigger + // changes and these are also protected. However, we must wait for the + // mutex to be idle to ensure that onchange RPCs returned before asking + // field widgets to commit their value (and validate it, for instance + // for one2many with required fields). So the actual saving has to be + // done after these changes. Also the commitChanges operation might not + // be synchronous for other reason (e.g. the x2m fields will ask the + // user if some discarding has to be made). This operation must also be + // mutex-protected as commitChanges function of x2m has to be aware of + // all final changes made to a row. + var unlockedMutex = this.mutex.getUnlockedDef() + .then(function () { + return self.renderer.commitChanges(recordID || self.handle); + }) + .then(function () { + return self.mutex.exec(self._saveRecord.bind(self, recordID, options)); + }); + this.savingDef = new Promise(function (resolve) { + unlockedMutex.then(resolve).guardedCatch(resolve); + }); + + return unlockedMutex; + }, + /** + * @override + * @returns {Promise} + */ + update: async function (params, options) { + this.mode = params.mode || this.mode; + return this._super(params, options); + }, + /** + * @override + */ + reload: function (params) { + if (params && params.controllerState) { + if (params.controllerState.currentId) { + params.currentId = params.controllerState.currentId; + } + params.ids = params.controllerState.resIds; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Does the necessary action when trying to "abandon" a given record (e.g. + * when trying to make a new record readonly without having saved it). By + * default, if the abandoned record is the main view one, the only possible + * action is to leave the current view. Otherwise, it is a x2m line, ask the + * model to remove it. + * + * @private + * @param {string} [recordID] - default to main recordID + */ + _abandonRecord: function (recordID) { + recordID = recordID || this.handle; + if (recordID === this.handle) { + this.trigger_up('history_back'); + } else { + this.model.removeLine(recordID); + } + }, + /** + * We override applyChanges (from the field manager mixin) to protect it + * with a mutex. + * + * @override + */ + _applyChanges: function (dataPointID, changes, event) { + var _super = FieldManagerMixin._applyChanges.bind(this); + return this.mutex.exec(function () { + return _super(dataPointID, changes, event); + }); + }, + /** + * Archive the current selection + * + * @private + * @param {number[]} ids + * @param {boolean} archive + * @returns {Promise} + */ + _archive: async function (ids, archive) { + if (ids.length === 0) { + return Promise.resolve(); + } + if (archive) { + await this.model.actionArchive(ids, this.handle); + } else { + await this.model.actionUnarchive(ids, this.handle); + } + return this.update({}, {reload: false}); + }, + /** + * When the user clicks on a 'action button', this function determines what + * should happen. + * + * @private + * @param {Object} attrs the attrs of the button clicked + * @param {Object} [record] the current state of the view + * @returns {Promise} + */ + _callButtonAction: function (attrs, record) { + record = record || this.model.get(this.handle); + const actionData = Object.assign({}, attrs, { + context: record.getContext({additionalContext: attrs.context || {}}) + }); + const recordData = { + context: record.getContext(), + currentID: record.data.id, + model: record.model, + resIDs: record.res_ids, + }; + return this._executeButtonAction(actionData, recordData); + }, + /** + * Called by the field manager mixin to confirm that a change just occured + * (after that potential onchanges have been applied). + * + * Basically, this only relays the notification to the renderer with the + * new state. + * + * @param {string} id - the id of one of the view's records + * @param {string[]} fields - the changed fields + * @param {OdooEvent} e - the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, e) { + if (e.name === 'discard_changes' && e.target.reset) { + // the target of the discard event is a field widget. In that + // case, we simply want to reset the specific field widget, + // not the full view + return e.target.reset(this.model.get(e.target.dataPointID), e, true); + } + + var state = this.model.get(this.handle); + return this.renderer.confirmChange(state, id, fields, e); + }, + /** + * Ask the user to confirm he wants to save the record + * @private + */ + _confirmSaveNewRecord: function () { + var self = this; + var def = new Promise(function (resolve, reject) { + var message = _t("You need to save this new record before editing the translation. Do you want to proceed?"); + var dialog = Dialog.confirm(self, message, { + title: _t("Warning"), + confirm_callback: resolve.bind(self, true), + cancel_callback: reject, + }); + dialog.on('closed', self, reject); + }); + return def; + }, + /** + * Delete records (and ask for confirmation if necessary) + * + * @param {string[]} ids list of local record ids + */ + _deleteRecords: function (ids) { + var self = this; + function doIt() { + return self.model + .deleteRecords(ids, self.modelName) + .then(self._onDeletedRecords.bind(self, ids)); + } + if (this.confirmOnDelete) { + const message = ids.length > 1 ? + _t("Are you sure you want to delete these records?") : + _t("Are you sure you want to delete this record?"); + Dialog.confirm(this, message, { confirm_callback: doIt }); + } else { + doIt(); + } + }, + /** + * Disables buttons so that they can't be clicked anymore. + * + * @private + */ + _disableButtons: function () { + if (this.$buttons) { + this.$buttons.find('button').attr('disabled', true); + } + }, + /** + * Discards the changes made to the record whose ID is given, if necessary. + * Automatically leaves to default mode for the given record. + * + * @private + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.readonlyIfRealDiscard=false] + * After discarding record changes, the usual option is to make the + * record readonly. However, the action manager calls this function + * at inappropriate times in the current code and in that case, we + * don't want to go back to readonly if there is nothing to discard + * (e.g. when switching record in edit mode in form view, we expect + * the new record to be in edit mode too, but the view manager calls + * this function as the URL changes...) @todo get rid of this when + * the webclient/action_manager's hashchange mechanism is improved. + * @param {boolean} [options.noAbandon=false] + * @returns {Promise} + */ + _discardChanges: function (recordID, options) { + var self = this; + recordID = recordID || this.handle; + options = options || {}; + return this.canBeDiscarded(recordID) + .then(function (needDiscard) { + if (options.readonlyIfRealDiscard && !needDiscard) { + return; + } + self.model.discardChanges(recordID); + if (options.noAbandon) { + return; + } + if (self.model.canBeAbandoned(recordID)) { + self._abandonRecord(recordID); + return; + } + return self._confirmSave(recordID); + }); + }, + /** + * Enables buttons so they can be clicked again. + * + * @private + */ + _enableButtons: function () { + if (this.$buttons) { + this.$buttons.find('button').removeAttr('disabled'); + } + }, + /** + * Executes the action associated with a button + * + * @private + * @param {Object} actionData: the descriptor of the action + * @param {string} actionData.type: the button's action's type, accepts "object" or "action" + * @param {string} actionData.name: the button's action's name + * either the model method's name for type "object" + * or the action's id in database, or xml_id + * @param {string} actionData.context: the action's execution context + * + * @param {Object} recordData: basic information on the current record(s) + * @param {number[]} recordData.resIDs: record ids: + * - on which an object method applies + * - that will be used as active_ids to load an action + * @param {string} recordData.model: model name + * @param {Object} recordData.context: the records' context, will be used to load + * the action, and merged into actionData.context at execution time + * + * @returns {Promise} + */ + async _executeButtonAction(actionData, recordData) { + const prom = new Promise((resolve, reject) => { + this.trigger_up('execute_action', { + action_data: actionData, + env: recordData, + on_closed: () => this.isDestroyed() ? Promise.resolve() : this.reload(), + on_success: resolve, + on_fail: () => this.update({}, { reload: false }).then(reject).guardedCatch(reject) + }); + }); + return this.alive(prom); + }, + /** + * Override to add the current record ID (currentId) and the list of ids + * (resIds) in the current dataPoint to the exported state. + * + * @override + */ + exportState: function () { + var state = this._super.apply(this, arguments); + var env = this.model.get(this.handle, {env: true}); + return _.extend(state, { + currentId: env.currentId, + resIds: env.ids, + }); + }, + /** + * Compute the optional fields local storage key using the given parts. + * + * @param {Object} keyParts + * @param {string} keyParts.viewType view type + * @param {string} [keyParts.relationalField] name of the field with subview + * @param {integer} [keyParts.subViewId] subview id + * @param {string} [keyParts.subViewType] type of the subview + * @param {Object} keyParts.fields fields + * @param {string} keyParts.fields.name field name + * @param {string} keyParts.fields.type field type + * @returns {string} local storage key for optional fields in this view + * @private + */ + _getOptionalFieldsLocalStorageKey: function (keyParts) { + keyParts.model = this.modelName; + keyParts.viewType = this.viewType; + keyParts.viewId = this.viewId; + + var parts = [ + 'model', + 'viewType', + 'viewId', + 'relationalField', + 'subViewType', + 'subViewId', + ]; + + var viewIdentifier = parts.reduce(function (identifier, partName) { + if (partName in keyParts) { + return identifier + ',' + keyParts[partName]; + } + return identifier; + }, 'optional_fields'); + + viewIdentifier = + keyParts.fields.sort(this._nameSortComparer) + .reduce(function (identifier, field) { + return identifier + ',' + field.name; + }, viewIdentifier); + + return viewIdentifier; + }, + /** + * Return the params (currentMinimum, limit and size) to pass to the pager, + * according to the current state. + * + * @private + * @returns {Object} + */ + _getPagingInfo: function (state) { + const isGrouped = state.groupedBy && state.groupedBy.length; + return { + currentMinimum: (isGrouped ? state.groupsOffset : state.offset) + 1, + limit: isGrouped ? state.groupsLimit : state.limit, + size: isGrouped ? state.groupsCount : state.count, + }; + }, + /** + * Return the new actionMenus props. + * + * @override + * @private + */ + _getActionMenuItems: function (state) { + return { + activeIds: this.getSelectedIds(), + context: state.getContext(), + }; + }, + /** + * Sort function used to sort the fields by names, to compute the optional fields keys + * + * @param {Object} left + * @param {Object} right + * @private + */ + _nameSortComparer: function(left, right) { + return left.name < right.name ? -1 : 1; + }, + /** + * Helper function to display a warning that some fields have an invalid + * value. This is used when a save operation cannot be completed. + * + * @private + * @param {string[]} invalidFields - list of field names + */ + _notifyInvalidFields: function (invalidFields) { + var record = this.model.get(this.handle, {raw: true}); + var fields = record.fields; + var warnings = invalidFields.map(function (fieldName) { + var fieldStr = fields[fieldName].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("Invalid fields:"), warnings.join('')); + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @see _deleteRecord + * @param {string[]} ids list of deleted ids (basic model local handles) + */ + _onDeletedRecords: function (ids) { + this.update({}); + }, + /** + * Saves the record whose ID is given, if necessary. Automatically leaves + * edit mode for the given record, unless told otherwise. + * + * @param {string} [recordID] - default to main recordID + * @param {Object} [options] + * @param {boolean} [options.stayInEdit=false] + * if true, leave the record in edit mode after save + * @param {boolean} [options.reload=true] + * if true, reload the record after (real) save + * @param {boolean} [options.savePoint=false] + * if true, the record will only be 'locally' saved: its changes + * will move from the _changes key to the data key + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + * Rejected if the record can't be saved + */ + _saveRecord: function (recordID, options) { + recordID = recordID || this.handle; + options = _.defaults(options || {}, { + stayInEdit: false, + reload: true, + savePoint: false, + }); + + // Check if the view is in a valid state for saving + // Note: it is the model's job to do nothing if there is nothing to save + if (this.canBeSaved(recordID)) { + var self = this; + var saveDef = this.model.save(recordID, { // Save then leave edit mode + reload: options.reload, + savePoint: options.savePoint, + viewType: options.viewType, + }); + if (!options.stayInEdit) { + saveDef = saveDef.then(function (fieldNames) { + var def = fieldNames.length ? self._confirmSave(recordID) : self._setMode('readonly', recordID); + return def.then(function () { + return fieldNames; + }); + }); + } + return saveDef; + } else { + return Promise.reject("SaveRecord: this.canBeSave is false"); // Cannot be saved + } + }, + /** + * Change the mode for the record associated to the given ID. + * If the given recordID is the view's main one, then the whole view mode is + * changed (@see BasicController.update). + * + * @private + * @param {string} mode - 'readonly' or 'edit' + * @param {string} [recordID] + * @returns {Promise} + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) === this.handle) { + return this.update({mode: mode}, {reload: false}).then(function () { + // necessary to allow all sub widgets to use their dimensions in + // layout related activities, such as autoresize on fieldtexts + core.bus.trigger('DOM_updated'); + }); + } + return Promise.resolve(); + }, + /** + * To override such that it returns true iff the primary action button must + * bounce when the user clicked on the given element, according to the + * current state of the view. + * + * @private + * @param {HTMLElement} element the node the user clicked on + * @returns {boolean} + */ + _shouldBounceOnClick: function (/* element */) { + return false; + }, + /** + * Helper method, to get the current environment variables from the model + * and notifies the component chain (by bubbling an event up) + * + * @private + * @param {Object} [newProps={}] + */ + _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); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user clicks on the 'content' part of the controller + * (typically the renderer area). Makes the first primary button in the + * control panel bounce, in some situations (see _shouldBounceOnClick). + * + * @private + * @param {MouseEvent} ev + */ + _onContentClicked(ev) { + if (this.$buttons && this._shouldBounceOnClick(ev.target)) { + this.$buttons.find('.btn-primary:visible:first').odooBounce(); + } + }, + /** + * Called when a list element asks to discard the changes made to one of + * its rows. It can happen with a x2many (if we are in a form view) or with + * a list view. + * + * @private + * @param {OdooEvent} ev + */ + _onDiscardChanges: function (ev) { + var self = this; + ev.stopPropagation(); + var recordID = ev.data.recordID; + this._discardChanges(recordID) + .then(function () { + // TODO this will tell the renderer to rerender the widget that + // asked for the discard but will unfortunately lose the click + // made on another row if any + self._confirmChange(recordID, [ev.data.fieldName], ev) + .then(ev.data.onSuccess).guardedCatch(ev.data.onSuccess); + }) + .guardedCatch(ev.data.onFailure); + }, + /** + * Forces to save directly the changes if the controller is in readonly, + * because in that case the changes come from widgets that are editable even + * in readonly (e.g. Priority). + * + * @private + * @param {OdooEvent} ev + */ + _onFieldChanged: function (ev) { + if (this.mode === 'readonly' && !('force_save' in ev.data)) { + ev.data.force_save = true; + } + 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. + * + * @todo: rename db_id into handle + * + * @param {OdooEvent} ev + * @param {Object} ev.data + * @param {string} [ev.data.db_id] handle of the data to reload and + * re-render (reload the whole form by default) + * @param {string[]} [ev.data.fieldNames] list of the record's fields to + * reload + * @param {Function} [ev.data.onSuccess] callback executed after reload is resolved + * @param {Function} [ev.data.onFailure] callback executed when reload is rejected + */ + _onReload: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + var data = ev && ev.data || {}; + var handle = data.db_id; + var prom; + if (handle) { + // reload the relational field given its db_id + prom = this.model.reload(handle).then(this._confirmSave.bind(this, handle)); + } else { + // no db_id given, so reload the main record + prom = this.reload({ + fieldNames: data.fieldNames, + keepChanges: data.keepChanges || false, + }); + } + prom.then(ev.data.onSuccess).guardedCatch(ev.data.onFailure); + }, + /** + * Resequence records in the given order. + * + * @private + * @param {OdooEvent} ev + * @param {string[]} ev.data.recordIds + * @param {integer} ev.data.offset + * @param {string} ev.data.handleField + */ + _onResequenceRecords: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + this.trigger_up('mutexify', { + 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, + }; + await this.model.resequence(this.modelName, resIDs, this.handle, options); + this._updateControlPanel(); + state = this.model.get(this.handle); + return this._updateRendererState(state, { noRender: true }); + }, + }); + }, + /** + * Load the optional columns settings in local storage for this view + * + * @param {OdooEvent} ev + * @param {Object} ev.data.keyParts see _getLocalStorageKey + * @param {function} ev.data.callback function to call with the result + * @private + */ + _onLoadOptionalFields: function (ev) { + var res = this.call( + 'local_storage', + 'getItem', + this._getOptionalFieldsLocalStorageKey(ev.data.keyParts) + ); + ev.data.callback(res); + }, + /** + * Save the optional columns settings in local storage for this view + * + * @param {OdooEvent} ev + * @param {Object} ev.data.keyParts see _getLocalStorageKey + * @param {Array<string>} ev.data.optionalColumnsEnabled list of optional + * field names that have been enabled + * @private + */ + _onSaveOptionalFields: function (ev) { + this.call( + 'local_storage', + 'setItem', + this._getOptionalFieldsLocalStorageKey(ev.data.keyParts), + ev.data.optionalColumnsEnabled + ); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onSetDirty: function (ev) { + ev.stopPropagation(); // prevent other controllers from handling this request + this.model.setDirty(ev.data.dataPointID); + }, + /** + * open the translation view for the current field + * + * @private + * @param {OdooEvent} ev + */ + _onTranslate: async function (ev) { + ev.stopPropagation(); + + if (this.model.isNew(ev.data.id)) { + await this._confirmSaveNewRecord(); + var updatedFields = await this.saveRecord(ev.data.id, { stayInEdit: true }); + await this._confirmChange(ev.data.id, updatedFields, ev); + } + var record = this.model.get(ev.data.id, { raw: true }); + var res_id = record.res_id || record.res_ids[0]; + var result = await this._rpc({ + route: '/web/dataset/call_button', + params: { + model: 'ir.translation', + method: 'translate_fields', + args: [record.model, res_id, ev.data.fieldName], + kwargs: { context: record.getContext() }, + } + }); + + this.translationDialog = new TranslationDialog(this, { + domain: result.domain, + searchName: result.context.search_default_name, + fieldName: ev.data.fieldName, + userLanguageValue: ev.target.value || '', + dataPointID: record.id, + isComingFromTranslationAlert: ev.data.isComingFromTranslationAlert, + isText: result.context.translation_type === 'text', + showSrc: result.context.translation_show_src, + }); + return this.translationDialog.open(); + }, +}); + +return BasicController; +}); diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js new file mode 100644 index 00000000..1e9868e0 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -0,0 +1,5190 @@ +odoo.define('web.BasicModel', function (require) { +"use strict"; + +/** + * Basic Model + * + * This class contains all the logic necessary to communicate between the + * python models and the web client. More specifically, its job is to give a + * simple unified API to the rest of the web client (in particular, the views and + * the field widgets) to query and modify actual records in db. + * + * From a high level perspective, BasicModel is essentially a hashmap with + * integer keys and some data and metadata object as value. Each object in this + * hashmap represents a piece of data, and can be reloaded and modified by using + * its id as key in many methods. + * + * Here is a description of what those data point look like: + * var dataPoint = { + * _cache: {Object|undefined} + * _changes: {Object|null}, + * aggregateValues: {Object}, + * context: {Object}, + * count: {integer}, + * data: {Object|Object[]}, + * domain: {*[]}, + * fields: {Object}, + * fieldsInfo: {Object}, + * getContext: {function}, + * getDomain: {function}, + * getFieldNames: {function}, + * groupedBy: {string[]}, + * id: {integer}, + * isOpen: {boolean}, + * loadMoreOffset: {integer}, + * limit: {integer}, + * model: {string}, + * offset: {integer}, + * openGroupByDefault: {boolean}, + * orderedBy: {Object[]}, + * orderedResIDs: {integer[]}, + * parentID: {string}, + * rawContext: {Object}, + * relationField: {string}, + * res_id: {integer|null}, + * res_ids: {integer[]}, + * specialData: {Object}, + * _specialDataCache: {Object}, + * static: {boolean}, + * type: {string} 'record' | 'list' + * value: ?, + * }; + * + * Notes: + * - id: is totally unrelated to res_id. id is a web client local concept + * - res_id: if set to a number or a virtual id (a virtual id is a character + * string composed of an integer and has a dash and other information), it + * is an actual id for a record in the server database. If set to + * 'virtual_' + number, it is a record not yet saved (so, in create mode). + * - res_ids: if set, it represent the context in which the data point is actually + * used. For example, a given record in a form view (opened from a list view) + * might have a res_id = 2 and res_ids = [1,2,3] + * - offset: this is mainly used for pagination. Useful when we need to load + * another page, then we can simply change the offset and reload. + * - count is basically the number of records being manipulated. We can't use + * res_ids, because we might have a very large number of records, or a + * domain, and the res_ids would be the current page, not the full set. + * - model is the actual name of a (odoo) model, such as 'res.partner' + * - fields contains the description of all the fields from the model. Note that + * these properties might have been modified by a view (for example, with + * required=true. So, the fields kind of depends of the context of the + * data point. + * - field_names: list of some relevant field names (string). Usually, it + * denotes the fields present in the view. Only those fields should be + * loaded. + * - _cache and _changes are private, they should not leak out of the basicModel + * and be used by anyone else. + * + * Commands: + * commands are the base commands for x2many (0 -> 6), but with a + * slight twist: each [0, _, values] command is augmented with a virtual id: + * it means that when the command is added in basicmodel, it generates an id + * looking like this: 'virtual_' + number, and uses this id to identify the + * element, so it can be edited later. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var Context = require('web.Context'); +var core = require('web.core'); +var Domain = require('web.Domain'); +const pyUtils = require('web.py_utils'); +var session = require('web.session'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); +var localStorage = require('web.local_storage'); + +var _t = core._t; + +// field types that can be aggregated in grouped views +const AGGREGATABLE_TYPES = ['float', 'integer', 'monetary']; + +var x2ManyCommands = { + // (0, virtualID, {values}) + CREATE: 0, + create: function (virtualID, values) { + delete values.id; + return [x2ManyCommands.CREATE, virtualID || false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + update: function (id, values) { + delete values.id; + return [x2ManyCommands.UPDATE, id, values]; + }, + // (2, id[, _]) + DELETE: 2, + delete: function (id) { + return [x2ManyCommands.DELETE, id, false]; + }, + // (3, id[, _]) removes relation, but not linked record itself + FORGET: 3, + forget: function (id) { + return [x2ManyCommands.FORGET, id, false]; + }, + // (4, id[, _]) + LINK_TO: 4, + link_to: function (id) { + return [x2ManyCommands.LINK_TO, id, false]; + }, + // (5[, _[, _]]) + DELETE_ALL: 5, + delete_all: function () { + return [5, false, false]; + }, + // (6, _, ids) replaces all linked records with provided ids + REPLACE_WITH: 6, + replace_with: function (ids) { + return [6, false, ids]; + } +}; + +var BasicModel = AbstractModel.extend({ + // constants + OPEN_GROUP_LIMIT: 10, // after this limit, groups are automatically folded + + // list of models for which the DataManager's cache should be cleared on + // create, update and delete operations + noCacheModels: [ + 'ir.actions.act_window', + 'ir.filters', + 'ir.ui.view', + ], + + /** + * @override + */ + init: function () { + // this mutex is necessary to make sure some operations are done + // sequentially, for example, an onchange needs to be completed before a + // save is performed. + this.mutex = new concurrency.Mutex(); + + // this array is used to accumulate RPC requests done in the same call + // stack, so that they can be batched in the minimum number of RPCs + this.batchedRPCsRequests = []; + + this.localData = Object.create(null); + // used to generate dataPoint ids. Note that the counter is set to 0 for + // each instance, and this is mandatory for the sample data feature to + // work: we need both the main model and the sample model to generate the + // same datapoint ids for their common data (groups, when there are real + // groups in database), so that we can easily do the mapping between + // real and sample data. + this.__id = 0; + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object. + * The default record is added in the data directly. This is meant to be used + * by list or kanban controllers (i.e. not for x2manys in form views, as in + * this case, we store changes as commands). + * + * @param {string} listID a valid handle for a list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @returns {Promise<string>} resolves to the id of the new created record + */ + addDefaultRecord: function (listID, options) { + var self = this; + var list = this.localData[listID]; + var context = _.extend({}, this._getDefaultContext(list), this._getContext(list)); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: list.viewType, + }; + return this._makeDefaultRecord(list.model, params).then(function (id) { + list.count++; + if (position === 'top') { + list.data.unshift(id); + } else { + list.data.push(id); + } + var record = self.localData[id]; + list._cache[record.res_id] = id; + return id; + }); + }, + /** + * Completes the fields and fieldsInfo of a dataPoint with the given ones. + * It is useful for the cases where a record element is shared between + * various views, such as a one2many with a tree and a form view. + * + * @param {string} datapointID a valid element ID (of type 'list' or 'record') + * @param {Object} viewInfo + * @param {Object} viewInfo.fields + * @param {Object} viewInfo.fieldInfo + * @param {string} viewInfo.viewType + * @returns {Promise} resolved when the fieldInfo have been set on the given + * datapoint and all its children, and all rawChanges have been applied + */ + addFieldsInfo: async function (dataPointID, viewInfo) { + var dataPoint = this.localData[dataPointID]; + dataPoint.fields = _.extend({}, dataPoint.fields, viewInfo.fields); + // complete the given fieldInfo with the fields of the main view, so + // that those field will be reloaded if a reload is triggered by the + // secondary view + dataPoint.fieldsInfo = dataPoint.fieldsInfo || {}; + const mainFieldInfo = dataPoint.fieldsInfo[dataPoint[viewInfo.viewType]]; + dataPoint.fieldsInfo[viewInfo.viewType] = _.defaults({}, viewInfo.fieldInfo, mainFieldInfo); + + // Some fields in the new fields info might not be in the previous one, + // so we might have stored changes for them (e.g. coming from onchange + // RPCs), that we haven't been able to process earlier (because those + // fields were unknown at that time). So we now try to process them. + if (dataPoint.type === 'record') { + await this.applyRawChanges(dataPointID, viewInfo.viewType); + } + const proms = []; + const fieldInfo = dataPoint.fieldsInfo[viewInfo.viewType]; + // recursively apply the new field info on sub datapoints + if (dataPoint.type === 'list') { + // case 'list': on all datapoints in the list + Object.values(dataPoint._cache).forEach(subDataPointID => { + proms.push(this.addFieldsInfo(subDataPointID, { + fields: dataPoint.fields, + fieldInfo: dataPoint.fieldsInfo[viewInfo.viewType], + viewType: viewInfo.viewType, + })); + }); + } else { + // case 'record': on datapoints of all x2many fields + const values = _.extend({}, dataPoint.data, dataPoint._changes); + Object.keys(fieldInfo).forEach(fieldName => { + const fieldType = dataPoint.fields[fieldName].type; + if (fieldType === 'one2many' || fieldType === 'many2many') { + const mode = fieldInfo[fieldName].mode; + const views = fieldInfo[fieldName].views; + const x2mDataPointID = values[fieldName]; + if (views[mode] && x2mDataPointID) { + proms.push(this.addFieldsInfo(x2mDataPointID, { + fields: views[mode].fields, + fieldInfo: views[mode].fieldsInfo[mode], + viewType: mode, + })); + } + } + }); + } + return Promise.all(proms); + }, + /** + * Onchange RPCs may return values for fields that are not in the current + * view. Those fields might even be unknown when the onchange returns (e.g. + * in x2manys, we only know the fields that are used in the inner view, but + * not those used in the potential form view opened in a dialog when a sub- + * record is clicked). When this happens, we can't infer their type, so the + * given value can't be processed. It is instead stored in the '_rawChanges' + * key of the record, without any processing. Later on, if this record is + * displayed in another view (e.g. the user clicked on it in the x2many + * list, and the record opens in a dialog), those changes that were left + * behind must be applied. This function applies changes stored in + * '_rawChanges' for a given viewType. + * + * @param {string} recordID local resource id of a record + * @param {string} viewType the current viewType + * @returns {Promise<string>} resolves to the id of the record + */ + applyRawChanges: function (recordID, viewType) { + var record = this.localData[recordID]; + return this._applyOnChange(record._rawChanges, record, { viewType }).then(function () { + return record.id; + }); + }, + /** + * Returns true if a record can be abandoned. + * + * Case for not abandoning the record: + * + * 1. flagged as 'no abandon' (i.e. during a `default_get`, including any + * `onchange` from a `default_get`) + * 2. registered in a list on addition + * + * 2.1. registered as non-new addition + * 2.2. registered as new additon on update + * + * 3. record is not new + * + * Otherwise, the record can be abandoned. + * + * This is useful when discarding changes on this record, as it means that + * we must keep the record even if some fields are invalids (e.g. required + * field is empty). + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + canBeAbandoned: function (id) { + // 1. no drop if flagged + if (this.localData[id]._noAbandon) { + return false; + } + // 2. no drop in a list on "ADD in some cases + var record = this.localData[id]; + var parent = this.localData[record.parentID]; + if (parent) { + var entry = _.findWhere(parent._savePoint, {operation: 'ADD', id: id}); + if (entry) { + // 2.1. no drop on non-new addition in list + if (!entry.isNew) { + return false; + } + // 2.2. no drop on new addition on "UPDATE" + var lastEntry = _.last(parent._savePoint); + if (lastEntry.operation === 'UPDATE' && lastEntry.id === id) { + return false; + } + } + } + // 3. drop new records + return this.isNew(id); + }, + /** + * Delete a list of records, then, if the records have a parent, reload it. + * + * @todo we should remove the deleted records from the localData + * @todo why can't we infer modelName? Because of grouped datapoint + * --> res_id doesn't correspond to the model and we don't have the + * information about the related model + * + * @param {string[]} recordIds list of local resources ids. They should all + * be of type 'record', be of the same model and have the same parent. + * @param {string} modelName mode name used to unlink the records + * @returns {Promise} + */ + deleteRecords: function (recordIds, modelName) { + var self = this; + var records = _.map(recordIds, function (id) { return self.localData[id]; }); + var context = _.extend(records[0].getContext(), session.user_context); + return this._rpc({ + model: modelName, + method: 'unlink', + args: [_.pluck(records, 'res_id')], + context: context, + }) + .then(function () { + _.each(records, function (record) { + var parent = record.parentID && self.localData[record.parentID]; + if (parent && parent.type === 'list') { + parent.data = _.without(parent.data, record.id); + delete self.localData[record.id]; + // Check if we are on last page and all records are deleted from current + // page i.e. if there is no state.data.length then go to previous page + if (!parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + } + } else { + record.res_ids.splice(record.offset, 1); + record.offset = Math.min(record.offset, record.res_ids.length - 1); + record.res_id = record.res_ids[record.offset]; + record.count--; + } + }); + // optionally clear the DataManager's cache + self._invalidateCache(records[0]); + }); + }, + /** + * Discard all changes in a local resource. Basically, it removes + * everything that was stored in a _changes key. + * + * @param {string} id local resource id + * @param {Object} [options] + * @param {boolean} [options.rollback=false] if true, the changes will + * be reset to the last _savePoint, otherwise, they are reset to null + */ + discardChanges: function (id, options) { + options = options || {}; + var element = this.localData[id]; + var isNew = this.isNew(id); + var rollback = 'rollback' in options ? options.rollback : isNew; + var initialOffset = element.offset; + element._domains = {}; + this._visitChildren(element, function (elem) { + if (rollback && elem._savePoint) { + if (elem._savePoint instanceof Array) { + elem._changes = elem._savePoint.slice(0); + } else { + elem._changes = _.extend({}, elem._savePoint); + } + elem._isDirty = !isNew; + } else { + elem._changes = null; + elem._isDirty = false; + } + elem.offset = 0; + if (elem.tempLimitIncrement) { + elem.limit -= elem.tempLimitIncrement; + delete elem.tempLimitIncrement; + } + }); + element.offset = initialOffset; + }, + /** + * Duplicate a record (by calling the 'copy' route) + * + * @param {string} recordID id for a local resource + * @returns {Promise<string>} resolves to the id of duplicate record + */ + duplicateRecord: function (recordID) { + var self = this; + var record = this.localData[recordID]; + var context = this._getContext(record); + return this._rpc({ + model: record.model, + method: 'copy', + args: [record.data.id], + context: context, + }) + .then(function (res_id) { + var index = record.res_ids.indexOf(record.res_id); + record.res_ids.splice(index + 1, 0, res_id); + return self.load({ + fieldsInfo: record.fieldsInfo, + fields: record.fields, + modelName: record.model, + res_id: res_id, + res_ids: record.res_ids.slice(0), + viewType: record.viewType, + context: context, + }); + }); + }, + /** + * For list resources, this freezes the current records order. + * + * @param {string} listID a valid element ID of type list + */ + freezeOrder: function (listID) { + var list = this.localData[listID]; + if (list.type === 'record') { + return; + } + list = this._applyX2ManyOperations(list); + this._sortList(list); + this.localData[listID].orderedResIDs = list.res_ids; + }, + /** + * The __get method first argument is the handle returned by the load method. + * It is optional (the handle can be undefined). In some case, it makes + * sense to use the handle as a key, for example the BasicModel holds the + * data for various records, each with its local ID. + * + * synchronous method, it assumes that the resource has already been loaded. + * + * @param {string} id local id for the resource + * @param {any} options + * @param {boolean} [options.env=false] if true, will only return res_id + * (if record) or res_ids (if list) + * @param {boolean} [options.raw=false] if true, will not follow relations + * @returns {Object} + */ + __get: function (id, options) { + var self = this; + options = options || {}; + + if (!(id in this.localData)) { + return null; + } + + var element = this.localData[id]; + + if (options.env) { + var env = { + ids: element.res_ids ? element.res_ids.slice(0) : [], + }; + if (element.type === 'record') { + env.currentId = this.isNew(element.id) ? undefined : element.res_id; + } + return env; + } + + if (element.type === 'record') { + var data = _.extend({}, element.data, element._changes); + var relDataPoint; + for (var fieldName in data) { + var field = element.fields[fieldName]; + if (data[fieldName] === null) { + data[fieldName] = false; + } + if (!field) { + continue; + } + + // get relational datapoint + if (field.type === 'many2one') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? relDataPoint.res_id : false; + } else { + data[fieldName] = this.__get(data[fieldName]) || false; + } + } else if (field.type === 'reference') { + if (options.raw) { + relDataPoint = this.localData[data[fieldName]]; + data[fieldName] = relDataPoint ? + relDataPoint.model + ',' + relDataPoint.res_id : + false; + } else { + data[fieldName] = this.__get(data[fieldName]) || false; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + if (typeof data[fieldName] === 'string') { + relDataPoint = this.localData[data[fieldName]]; + relDataPoint = this._applyX2ManyOperations(relDataPoint); + data[fieldName] = relDataPoint.res_ids; + } else { + // no datapoint has been created yet (because the loading of relational + // data has been batched, and hasn't started yet), so the value is still + // the list of ids in the relation + data[fieldName] = data[fieldName] || []; + } + } else { + data[fieldName] = this.__get(data[fieldName]) || []; + } + } + } + var record = { + context: _.extend({}, element.context), + count: element.count, + data: data, + domain: element.domain.slice(0), + evalModifiers: element.evalModifiers, + fields: element.fields, + fieldsInfo: element.fieldsInfo, + getContext: element.getContext, + getDomain: element.getDomain, + getFieldNames: element.getFieldNames, + id: element.id, + isDirty: element.isDirty, + limit: element.limit, + model: element.model, + offset: element.offset, + ref: element.ref, + res_ids: element.res_ids.slice(0), + specialData: _.extend({}, element.specialData), + type: 'record', + viewType: element.viewType, + }; + + if (!this.isNew(element.id)) { + record.res_id = element.res_id; + } + var evalContext; + Object.defineProperty(record, 'evalContext', { + get: function () { + evalContext = evalContext || self._getEvalContext(element); + return evalContext; + }, + }); + return record; + } + + // apply potential changes (only for x2many lists) + element = this._applyX2ManyOperations(element); + this._sortList(element); + + if (!element.orderedResIDs && element._changes) { + _.each(element._changes, function (change) { + if (change.operation === 'ADD' && change.isNew) { + element.data = _.without(element.data, change.id); + if (change.position === 'top') { + element.data.unshift(change.id); + } else { + element.data.push(change.id); + } + } + }); + } + + var list = { + aggregateValues: _.extend({}, element.aggregateValues), + context: _.extend({}, element.context), + count: element.count, + data: _.map(element.data, function (elemID) { + return self.__get(elemID, options); + }), + domain: element.domain.slice(0), + fields: element.fields, + getContext: element.getContext, + getDomain: element.getDomain, + getFieldNames: element.getFieldNames, + groupedBy: element.groupedBy, + groupsCount: element.groupsCount, + groupsLimit: element.groupsLimit, + groupsOffset: element.groupsOffset, + id: element.id, + isDirty: element.isDirty, + isOpen: element.isOpen, + isSample: this.isSampleModel, + limit: element.limit, + model: element.model, + offset: element.offset, + orderedBy: element.orderedBy, + res_id: element.res_id, + res_ids: element.res_ids.slice(0), + type: 'list', + value: element.value, + viewType: element.viewType, + }; + if (element.fieldsInfo) { + list.fieldsInfo = element.fieldsInfo; + } + return list; + }, + /** + * Generate default values for a given record. Those values are stored in + * the '_changes' key of the record. For relational fields, sub-dataPoints + * are created, and missing relational data is fetched. + * Typically, this function is called when a new record is created. It may + * also be called when a one2many subrecord is open in a form view (dialog), + * to generate the default values for the fields displayed in the o2m form + * view, but not in the list or kanban (mainly to correctly create + * sub-dataPoints for relational fields). + * + * @param {string} recordID local id for a record + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @param {Array} [options.fieldNames] list of field names for which a + * default value must be generated (used to complete the values dict) + * @returns {Promise} + */ + generateDefaultValues(recordID, options = {}) { + const record = this.localData[recordID]; + const viewType = options.viewType || record.viewType; + const fieldNames = options.fieldNames || Object.keys(record.fieldsInfo[viewType]); + const numericFields = ['float', 'integer', 'monetary']; + const proms = []; + record._changes = record._changes || {}; + fieldNames.forEach(fieldName => { + record.data[fieldName] = null; + if (!(fieldName in record._changes)) { + const field = record.fields[fieldName]; + if (numericFields.includes(field.type)) { + record._changes[fieldName] = 0; + } else if (field.type === 'one2many' || field.type === 'many2many') { + proms.push(this._processX2ManyCommands(record, fieldName, [], options)); + } else { + record._changes[fieldName] = null; + } + } + }); + return Promise.all(proms); + }, + /** + * Returns the current display_name for the record. + * + * @param {string} id the localID for a valid record element + * @returns {string} + */ + getName: function (id) { + var record = this.localData[id]; + var returnValue = ''; + if (record._changes && 'display_name' in record._changes) { + returnValue = record._changes.display_name; + } + else if ('display_name' in record.data) { + returnValue = record.data.display_name; + } + return returnValue || _t("New"); + }, + /** + * Returns true if a record is dirty. A record is considered dirty if it has + * some unsaved changes, marked by the _isDirty property on the record or + * one of its subrecords. + * + * @param {string} id - the local resource id + * @returns {boolean} + */ + isDirty: function (id) { + var isDirty = false; + this._visitChildren(this.localData[id], function (r) { + if (r._isDirty) { + isDirty = true; + } + }); + return isDirty; + }, + /** + * Returns true iff the datapoint is of type list and either: + * - is not grouped, and contains no records + * - is grouped, and contains columns, but all columns are empty + * In these cases, we will generate sample data to display, instead of an + * empty state. + * + * @override + */ + _isEmpty(dataPointID) { + const dataPoint = this.localData[dataPointID]; + if (dataPoint.type === 'list') { + const hasRecords = dataPoint.count === 0; + if (dataPoint.groupedBy.length) { + return dataPoint.data.length > 0 && hasRecords; + } else { + return hasRecords; + } + } + return false; + }, + /** + * Check if a localData is new, meaning if it is in the process of being + * created and no actual record exists in db. Note: if the localData is not + * of the "record" type, then it is always considered as not new. + * + * Note: A virtual id is a character string composed of an integer and has + * a dash and other information. + * E.g: in calendar, the recursive event have virtual id linked to a real id + * virtual event id "23-20170418020000" is linked to the event id 23 + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isNew: function (id) { + var data = this.localData[id]; + if (data.type !== "record") { + return false; + } + var res_id = data.res_id; + if (typeof res_id === 'number') { + return false; + } else if (typeof res_id === 'string' && /^[0-9]+-/.test(res_id)) { + return false; + } + return true; + }, + /** + * Main entry point, the goal of this method is to fetch and process all + * data (following relations if necessary) for a given record/list. + * + * @todo document all params + * + * @private + * @param {any} params + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Object} params.fields contains the description of each field + * @param {string} [params.type] 'record' or 'list' + * @param {string} [params.recordID] an ID for an existing resource. + * @returns {Promise<string>} resolves to a local id, or handle + */ + __load: async function (params) { + await this._super(...arguments); + params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); + // FIXME: the following seems only to be used by the basic_model_tests + // so it should probably be removed and the tests should be adapted + params.viewType = params.viewType || 'default'; + if (!params.fieldsInfo) { + var fieldsInfo = {}; + for (var fieldName in params.fieldNames) { + fieldsInfo[params.fieldNames[fieldName]] = {}; + } + params.fieldsInfo = {}; + params.fieldsInfo[params.viewType] = fieldsInfo; + } + + if (params.type === 'record' && params.res_id === undefined) { + params.allowWarning = true; + return this._makeDefaultRecord(params.modelName, params); + } + var dataPoint = this._makeDataPoint(params); + return this._load(dataPoint).then(function () { + return dataPoint.id; + }); + }, + /** + * Returns the list of res_ids for a given list of local ids. + * + * @param {string[]} localIds + * @returns {integer[]} + */ + localIdsToResIds: function (localIds) { + return localIds.map(localId => this.localData[localId].res_id); + }, + /** + * This helper method is designed to help developpers that want to use a + * field widget outside of a view. In that case, we want a way to create + * data without actually performing a fetch. + * + * @param {string} model name of the model + * @param {Object[]} fields a description of field properties + * @param {Object} [fieldInfo] various field info that we want to set + * @returns {Promise<string>} the local id for the created resource + */ + makeRecord: function (model, fields, fieldInfo) { + var self = this; + var defs = []; + var record_fields = {}; + _.each(fields, function (field) { + record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain', 'selection'); + }); + fieldInfo = fieldInfo || {}; + var fieldsInfo = {}; + fieldsInfo.default = {}; + _.each(fields, function (field) { + fieldsInfo.default[field.name] = fieldInfo[field.name] || {}; + }); + var record = this._makeDataPoint({ + modelName: model, + fields: record_fields, + fieldsInfo: fieldsInfo, + viewType: 'default', + }); + _.each(fields, function (field) { + var dataPoint; + record.data[field.name] = null; + if (field.type === 'many2one') { + if (field.value) { + var id = _.isArray(field.value) ? field.value[0] : field.value; + var display_name = _.isArray(field.value) ? field.value[1] : undefined; + dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: { + id: id, + display_name: display_name, + }, + parentID: record.id, + }); + record.data[field.name] = dataPoint.id; + if (display_name === undefined) { + defs.push(self._fetchNameGet(dataPoint)); + } + } + } else if (field.type === 'reference' && field.value) { + const ref = field.value.split(','); + dataPoint = self._makeDataPoint({ + context: record.context, + data: { id: parseInt(ref[1], 10) }, + modelName: ref[0], + parentID: record.id, + }); + defs.push(self._fetchNameGet(dataPoint)); + record.data[field.name] = dataPoint.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + var relatedFieldsInfo = {}; + relatedFieldsInfo.default = {}; + _.each(field.fields, function (field) { + relatedFieldsInfo.default[field.name] = {}; + }); + var dpParams = { + fieldsInfo: relatedFieldsInfo, + modelName: field.relation, + parentID: record.id, + static: true, + type: 'list', + viewType: 'default', + }; + var needLoad = false; + // As value, you could either pass: + // - a list of ids related to the record + // - a list of object + // We only need to load the datapoint in the first case. + if (field.value && field.value.length) { + if (_.isObject(field.value[0])) { + dpParams.res_ids = _.pluck(field.value, 'id'); + dataPoint = self._makeDataPoint(dpParams); + _.each(field.value, function (data) { + var recordDP = self._makeDataPoint({ + data: data, + modelName: field.relation, + parentID: dataPoint.id, + type: 'record', + }); + dataPoint.data.push(recordDP.id); + dataPoint._cache[recordDP.res_id] = recordDP.id; + }); + } else { + dpParams.res_ids = field.value; + dataPoint = self._makeDataPoint(dpParams); + needLoad = true; + } + } else { + dpParams.res_ids = []; + dataPoint = self._makeDataPoint(dpParams); + } + + if (needLoad) { + defs.push(self._load(dataPoint)); + } + record.data[field.name] = dataPoint.id; + } else if (field.value) { + record.data[field.name] = field.value; + } + }); + return Promise.all(defs).then(function () { + return record.id; + }); + }, + /** + * This is an extremely important method. All changes in any field go + * through this method. It will then apply them in the local state, check + * if onchanges needs to be applied, actually do them if necessary, then + * resolves with the list of changed fields. + * + * @param {string} record_id + * @param {Object} changes a map field => new value + * @param {Object} [options] will be transferred to the applyChange method + * @see _applyChange + * @returns {Promise<string[]>} list of changed fields + */ + notifyChanges: function (record_id, changes, options) { + return this.mutex.exec(this._applyChange.bind(this, record_id, changes, options)); + }, + /** + * Reload all data for a given resource. At any time there is at most one + * reload operation active. + * + * @private + * @param {string} id local id for a resource + * @param {Object} [options] + * @param {boolean} [options.keepChanges=false] if true, doesn't discard the + * changes on the record before reloading it + * @returns {Promise<string>} resolves to the id of the resource + */ + __reload: async function (id, options) { + await this._super(...arguments); + return this.mutex.exec(this._reload.bind(this, id, options)); + }, + /** + * In some case, we may need to remove an element from a list, without going + * through the notifyChanges machinery. The motivation for this is when the + * user click on 'Add a line' in a field one2many with a required field, + * then clicks somewhere else. The new line need to be discarded, but we + * don't want to trigger a real notifyChanges (no need for that, and also, + * we don't want to rerender the UI). + * + * @param {string} elementID some valid element id. It is necessary that the + * corresponding element has a parent. + */ + removeLine: function (elementID) { + var record = this.localData[elementID]; + var parent = this.localData[record.parentID]; + if (parent.static) { + // x2Many case: the new record has been stored in _changes, as a + // command so we remove the command(s) related to that record + parent._changes = _.filter(parent._changes, function (change) { + if (change.id === elementID && + change.operation === 'ADD' && // For now, only an ADD command increases limits + parent.tempLimitIncrement) { + // The record will be deleted from the _changes. + // So we won't be passing into the logic of _applyX2ManyOperations anymore + // implying that we have to cancel out the effects of an ADD command here + parent.tempLimitIncrement--; + parent.limit--; + } + return change.id !== elementID; + }); + } else { + // main list view case: the new record is in data + parent.data = _.without(parent.data, elementID); + parent.count--; + } + }, + /** + * Resequences records. + * + * @param {string} modelName the resIDs model + * @param {Array<integer>} resIDs the new sequence of ids + * @param {string} parentID the localID of the parent + * @param {object} [options] + * @param {integer} [options.offset] + * @param {string} [options.field] the field name used as sequence + * @returns {Promise<string>} resolves to the local id of the parent + */ + resequence: function (modelName, resIDs, parentID, options) { + options = options || {}; + if ((resIDs.length <= 1)) { + return Promise.resolve(parentID); // there is nothing to sort + } + var self = this; + var data = this.localData[parentID]; + var params = { + model: modelName, + ids: resIDs, + }; + if (options.offset) { + params.offset = options.offset; + } + if (options.field) { + params.field = options.field; + } + return this._rpc({ + route: '/web/dataset/resequence', + params: params, + }) + .then(function (wasResequenced) { + if (!wasResequenced) { + // the field on which the resequence was triggered does not + // exist, so no resequence happened server-side + return Promise.resolve(); + } + var field = params.field ? params.field : 'sequence'; + + return self._rpc({ + model: modelName, + method: 'read', + args: [resIDs, [field]], + }).then(function (records) { + if (data.data.length) { + var dataType = self.localData[data.data[0]].type; + if (dataType === 'record') { + _.each(data.data, function (dataPoint) { + var recordData = self.localData[dataPoint].data; + var inRecords = _.findWhere(records, {id: recordData.id}); + if (inRecords) { + recordData[field] = inRecords[field]; + } + }); + data.data = _.sortBy(data.data, function (d) { + return self.localData[d].data[field]; + }); + } + if (dataType === 'list') { + data.data = _.sortBy(data.data, function (d) { + return _.indexOf(resIDs, self.localData[d].res_id) + }); + } + } + data.res_ids = []; + _.each(data.data, function (d) { + var dataPoint = self.localData[d]; + if (dataPoint.type === 'record') { + data.res_ids.push(dataPoint.res_id); + } else { + data.res_ids = data.res_ids.concat(dataPoint.res_ids); + } + }); + self._updateParentResIDs(data); + return parentID; + }) + }); + }, + /** + * Save a local resource, if needed. This is a complicated operation, + * - it needs to check all changes, + * - generate commands for x2many fields, + * - call the /create or /write method according to the record status + * - After that, it has to reload all data, in case something changed, server side. + * + * @param {string} recordID local resource + * @param {Object} [options] + * @param {boolean} [options.reload=true] if true, data will be reloaded + * @param {boolean} [options.savePoint=false] if true, the record will only + * be 'locally' saved: its changes written in a _savePoint key that can + * be restored later by call discardChanges with option rollback to true + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + * Resolved with the list of field names (whose value has been modified) + */ + save: function (recordID, options) { + var self = this; + return this.mutex.exec(function () { + options = options || {}; + var record = self.localData[recordID]; + if (options.savePoint) { + self._visitChildren(record, function (rec) { + var newValue = rec._changes || rec.data; + if (newValue instanceof Array) { + rec._savePoint = newValue.slice(0); + } else { + rec._savePoint = _.extend({}, newValue); + } + }); + + // save the viewType of edition, so that the correct readonly modifiers + // can be evaluated when the record will be saved + for (let fieldName in (record._changes || {})) { + record._editionViewType[fieldName] = options.viewType; + } + } + var shouldReload = 'reload' in options ? options.reload : true; + var method = self.isNew(recordID) ? 'create' : 'write'; + if (record._changes) { + // id never changes, and should not be written + delete record._changes.id; + } + var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'}); + + // id field should never be written/changed + delete changes.id; + + if (method === 'create') { + var fieldNames = record.getFieldNames(); + _.each(fieldNames, function (name) { + if (changes[name] === null) { + delete changes[name]; + } + }); + } + + var prom = new Promise(function (resolve, reject) { + var changedFields = Object.keys(changes); + + if (options.savePoint) { + resolve(changedFields); + return; + } + + // in the case of a write, only perform the RPC if there are changes to save + if (method === 'create' || changedFields.length) { + var args = method === 'write' ? [[record.data.id], changes] : [changes]; + self._rpc({ + model: record.model, + method: method, + args: args, + context: record.getContext(), + }).then(function (id) { + if (method === 'create') { + record.res_id = id; // create returns an id, write returns a boolean + record.data.id = id; + record.offset = record.res_ids.length; + record.res_ids.push(id); + record.count++; + } + + var _changes = record._changes; + + // Erase changes as they have been applied + record._changes = {}; + + // Optionally clear the DataManager's cache + self._invalidateCache(record); + + self.unfreezeOrder(record.id); + + // Update the data directly or reload them + if (shouldReload) { + self._fetchRecord(record).then(function () { + resolve(changedFields); + }); + } else { + _.extend(record.data, _changes); + resolve(changedFields); + } + }).guardedCatch(reject); + } else { + resolve(changedFields); + } + }); + prom.then(function () { + record._isDirty = false; + }); + return prom; + }); + }, + /** + * Manually sets a resource as dirty. This is used to notify that a field + * has been modified, but with an invalid value. In that case, the value is + * not sent to the basic model, but the record should still be flagged as + * dirty so that it isn't discarded without any warning. + * + * @param {string} id a resource id + */ + setDirty: function (id) { + this.localData[id]._isDirty = true; + }, + /** + * For list resources, this changes the orderedBy key. + * + * @param {string} list_id id for the list resource + * @param {string} fieldName valid field name + * @returns {Promise} + */ + setSort: function (list_id, fieldName) { + var list = this.localData[list_id]; + if (list.type === 'record') { + return; + } else if (list._changes) { + _.each(list._changes, function (change) { + delete change.isNew; + }); + } + if (list.orderedBy.length === 0) { + list.orderedBy.push({name: fieldName, asc: true}); + } else if (list.orderedBy[0].name === fieldName){ + if (!list.orderedResIDs) { + list.orderedBy[0].asc = !list.orderedBy[0].asc; + } + } else { + var orderedBy = _.reject(list.orderedBy, function (o) { + return o.name === fieldName; + }); + list.orderedBy = [{name: fieldName, asc: true}].concat(orderedBy); + } + + list.orderedResIDs = null; + if (list.static) { + // sorting might require to fetch the field for records where the + // sort field is still unknown (i.e. on other pages for example) + return this._fetchUngroupedList(list); + } + return Promise.resolve(); + }, + /** + * For a given resource of type 'record', get the active field, if any. + * + * Since the ORM can support both `active` and `x_active` fields for + * the archiving mechanism, check if any such field exists and prioritize + * them. The `active` field should always take priority over its custom + * version. + * + * @param {Object} record local resource + * @returns {String|undefined} the field name to use for archiving purposes + * ('active', 'x_active') or undefined if no such field is present + */ + getActiveField: function (record) { + const fields = Object.keys(record.fields); + const has_active = fields.includes('active'); + if (has_active) { + return 'active'; + } + const has_x_active = fields.includes('x_active'); + return has_x_active?'x_active':undefined + }, + /** + * Toggle the active value of given records (to archive/unarchive them) + * + * @param {Array} recordIDs local ids of the records to (un)archive + * @param {boolean} value false to archive, true to unarchive (value of the active field) + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + toggleActive: function (recordIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + var resIDs = _.map(recordIDs, function (recordID) { + return self.localData[recordID].res_id; + }); + return this._rpc({ + model: parent.model, + method: 'toggle_active', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return self.do_action(action, { + on_close: function () { + return self.trigger_up('reload'); + } + }); + } else { + return self.reload(parentID); + } + }); + }, + /** + * Archive the given records + * + * @param {integer[]} resIDs ids of the records to archive + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + actionArchive: function (resIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + return this._rpc({ + model: parent.model, + method: 'action_archive', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return new Promise(function (resolve, reject) { + self.do_action(action, { + on_close: function (result) { + return self.trigger_up('reload', { + onSuccess: resolve, + }); + } + }); + }); + } else { + return self.reload(parentID); + } + }).then(function (datapoint) { + // if there are no records to display and we are not on first page(we check it + // by checking offset is greater than limit i.e. we are not on first page) + // reason for adding logic after reload to make sure there is no records after operation + if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + return self.reload(parentID); + } + return datapoint; + }); + }, + /** + * Unarchive the given records + * + * @param {integer[]} resIDs ids of the records to unarchive + * @param {string} parentID id of the parent resource to reload + * @returns {Promise<string>} resolves to the parent id + */ + actionUnarchive: function (resIDs, parentID) { + var self = this; + var parent = this.localData[parentID]; + return this._rpc({ + model: parent.model, + method: 'action_unarchive', + args: [resIDs], + }) + .then(function (action) { + // optionally clear the DataManager's cache + self._invalidateCache(parent); + if (!_.isEmpty(action)) { + return new Promise(function (resolve, reject) { + self.do_action(action, { + on_close: function () { + return self.trigger_up('reload', { + onSuccess: resolve, + }); + } + }); + }); + } else { + return self.reload(parentID); + } + }).then(function (datapoint) { + // if there are no records to display and we are not on first page(we check it + // by checking offset is greater than limit i.e. we are not on first page) + // reason for adding logic after reload to make sure there is no records after operation + if (parent && parent.type === 'list' && !parent.data.length && parent.offset > 0) { + parent.offset = Math.max(parent.offset - parent.limit, 0); + return self.reload(parentID); + } + return datapoint; + }); + }, + /** + * Toggle (open/close) a group in a grouped list, then fetches relevant + * data + * + * @param {string} groupId + * @returns {Promise<string>} resolves to the group id + */ + toggleGroup: function (groupId) { + var self = this; + var group = this.localData[groupId]; + if (group.isOpen) { + group.isOpen = false; + group.data = []; + group.res_ids = []; + group.offset = 0; + this._updateParentResIDs(group); + return Promise.resolve(groupId); + } + if (!group.isOpen) { + group.isOpen = true; + var def; + if (group.count > 0) { + def = this._load(group).then(function () { + self._updateParentResIDs(group); + }); + } + return Promise.resolve(def).then(function () { + return groupId; + }); + } + }, + /** + * For a list datapoint, unfreezes the current records order and sorts it. + * For a record datapoint, unfreezes the x2many list datapoints. + * + * @param {string} elementID a valid element ID + */ + unfreezeOrder: function (elementID) { + var list = this.localData[elementID]; + if (list.type === 'record') { + var data = _.extend({}, list.data, list._changes); + for (var fieldName in data) { + var field = list.fields[fieldName]; + if (!field || !data[fieldName]) { + continue; + } + if (field.type === 'one2many' || field.type === 'many2many') { + var recordlist = this.localData[data[fieldName]]; + recordlist.orderedResIDs = null; + for (var index in recordlist.data) { + this.unfreezeOrder(recordlist.data[index]); + } + } + } + return; + } + list.orderedResIDs = null; + this._sortList(list); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add a default record to a list object. This method actually makes a new + * record with the _makeDefaultRecord method, then adds it to the list object + * as a 'ADD' command in its _changes. This is meant to be used x2many lists, + * not by list or kanban controllers. + * + * @private + * @param {Object} list a valid list object + * @param {Object} [options] + * @param {string} [options.position=top] if the new record should be added + * on top or on bottom of the list + * @param {Array} [options.[context]] additional context to be merged before + * calling the default_get (eg. to set default values). + * If several contexts are found, multiple records are added + * @param {boolean} [options.allowWarning=false] if true, the default record + * operation can complete, even if a warning is raised + * @returns {Promise<[string]>} resolves to the new records ids + */ + _addX2ManyDefaultRecord: function (list, options) { + var self = this; + var position = options && options.position || 'top'; + var params = { + fields: list.fields, + fieldsInfo: list.fieldsInfo, + parentID: list.id, + position: position, + viewType: options.viewType || list.viewType, + allowWarning: options && options.allowWarning + }; + + var additionalContexts = options && options.context; + var makeDefaultRecords = []; + if (additionalContexts){ + _.each(additionalContexts, function (context) { + params.context = self._getContext(list, {additionalContext: context}); + makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); + }); + } else { + params.context = self._getContext(list); + makeDefaultRecords.push(self._makeDefaultRecord(list.model, params)); + } + + return Promise.all(makeDefaultRecords).then(function (resultIds){ + var ids = []; + _.each(resultIds, function (id){ + ids.push(id); + + list._changes.push({operation: 'ADD', id: id, position: position, isNew: true}); + var record = self.localData[id]; + list._cache[record.res_id] = id; + if (list.orderedResIDs) { + var index = list.offset + (position !== 'top' ? list.limit : 0); + list.orderedResIDs.splice(index, 0, record.res_id); + // list could be a copy of the original one + self.localData[list.id].orderedResIDs = list.orderedResIDs; + } + }); + + return ids; + }); + }, + /** + * This method is the private version of notifyChanges. Unlike + * notifyChanges, it is not protected by a mutex. Every changes from the + * user to the model go through this method. + * + * @param {string} recordID + * @param {Object} changes + * @param {Object} [options] + * @param {boolean} [options.doNotSetDirty=false] if this flag is set to + * true, then we will not tag the record as dirty. This should be avoided + * for most situations. + * @param {boolean} [options.notifyChange=true] if this flag is set to + * false, then we will not notify and not trigger the onchange, even though + * it was changed. + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.allowWarning=false] if true, change + * operation can complete, even if a warning is raised + * (only supported by X2ManyChange) + * @returns {Promise} list of changed fields + */ + _applyChange: function (recordID, changes, options) { + var self = this; + var record = this.localData[recordID]; + var field; + var defs = []; + options = options || {}; + record._changes = record._changes || {}; + if (!options.doNotSetDirty) { + record._isDirty = true; + } + var initialData = {}; + this._visitChildren(record, function (elem) { + initialData[elem.id] = $.extend(true, {}, _.pick(elem, 'data', '_changes')); + }); + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && (field.type === 'one2many' || field.type === 'many2many')) { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName], options)); + } else if (field && (field.type === 'many2one' || field.type === 'reference')) { + defs.push(this._applyX2OneChange(record, fieldName, changes[fieldName], options)); + } else { + record._changes[fieldName] = changes[fieldName]; + } + } + + if (options.notifyChange === false) { + return Promise.all(defs).then(function () { + return Promise.resolve(_.keys(changes)); + }); + } + + return Promise.all(defs).then(function () { + var onChangeFields = []; // the fields that have changed and that have an on_change + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field && field.onChange) { + var isX2Many = field.type === 'one2many' || field.type === 'many2many'; + if (!isX2Many || (self._isX2ManyValid(record._changes[fieldName] || record.data[fieldName]))) { + onChangeFields.push(fieldName); + } + } + } + return new Promise(function (resolve, reject) { + if (onChangeFields.length) { + self._performOnChange(record, onChangeFields, { viewType: options.viewType }) + .then(function (result) { + delete record._warning; + resolve(_.keys(changes).concat(Object.keys(result && result.value || {}))); + }).guardedCatch(function () { + self._visitChildren(record, function (elem) { + _.extend(elem, initialData[elem.id]); + }); + reject(); + }); + } else { + resolve(_.keys(changes)); + } + }).then(function (fieldNames) { + return self._fetchSpecialData(record).then(function (fieldNames2) { + // Return the names of the fields that changed (onchange or + // associated special data change) + return _.union(fieldNames, fieldNames2); + }); + }); + }); + }, + /** + * Apply an x2one (either a many2one or a reference field) change. There is + * a need for this function because the server only gives an id when a + * onchange modifies a many2one field. For this reason, we need (sometimes) + * to do a /name_get to fetch a display_name. + * + * Moreover, for the many2one case, a new value can sometimes be set (i.e. + * a display_name is given, but no id). When this happens, we first do a + * name_create. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} [data] + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + */ + _applyX2OneChange: async function (record, fieldName, data, options) { + options = options || {}; + var self = this; + if (!data || (!data.id && !data.display_name)) { + record._changes[fieldName] = false; + return Promise.resolve(); + } + + const field = record.fields[fieldName]; + const coModel = field.type === 'reference' ? data.model : field.relation; + const allowedTypes = ['many2one', 'reference']; + if (allowedTypes.includes(field.type) && !data.id && data.display_name) { + // only display_name given -> do a name_create + const result = await this._rpc({ + model: coModel, + method: 'name_create', + args: [data.display_name], + context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), + }); + // Check if a record is really created. Models without defined + // _rec_name cannot create record based on name_create. + if (!result) { + record._changes[fieldName] = false; + return Promise.resolve(); + } + data = {id: result[0], display_name: result[1]}; + } + + // here, we check that the many2one really changed. If the res_id is the + // same, we do not need to do any extra work. It can happen when the + // user edited a manyone (with the small form view button) with an + // onchange. In that case, the onchange is triggered, but the actual + // value did not change. + var relatedID; + if (record._changes && fieldName in record._changes) { + relatedID = record._changes[fieldName]; + } else { + relatedID = record.data[fieldName]; + } + var relatedRecord = this.localData[relatedID]; + if (relatedRecord && (data.id === this.localData[relatedID].res_id)) { + return Promise.resolve(); + } + var rel_data = _.pick(data, 'id', 'display_name'); + + // the reference field doesn't store its co-model in its field metadata + // but directly in the data (as the co-model isn't fixed) + var def; + if (rel_data.display_name === undefined) { + // TODO: refactor this to use _fetchNameGet + def = this._rpc({ + model: coModel, + method: 'name_get', + args: [data.id], + context: record.context, + }) + .then(function (result) { + rel_data.display_name = result[0][1]; + }); + } + return Promise.resolve(def).then(function () { + var rec = self._makeDataPoint({ + context: record.context, + data: rel_data, + fields: {}, + fieldsInfo: {}, + modelName: coModel, + parentID: record.id, + }); + record._changes[fieldName] = rec.id; + }); + }, + /** + * Applies the result of an onchange RPC on a record. + * + * @private + * @param {Object} values the result of the onchange RPC (a mapping of + * fieldnames to their value) + * @param {Object} record + * @param {Object} [options={}] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {string} [options.firstOnChange] set to true if this is the first + * onchange (if so, some initialization will need to be done) + * @returns {Promise} + */ + _applyOnChange: function (values, record, options = {}) { + var self = this; + var defs = []; + var rec; + const viewType = options.viewType || record.viewType; + record._changes = record._changes || {}; + + for (let name in (values || {})) { + const val = values[name]; + var field = record.fields[name]; + if (!field) { + // this field is unknown so we can't process it for now (it is not + // in the current view anyway, otherwise it wouldn't be unknown. + // we store its value without processing it, so that if we later + // on switch to another view in which this field is displayed, + // we could process it as we would know its type then. + // use case: an onchange sends a create command for a one2many, + // in the dict of values, there is a value for a field that is + // not in the one2many list, but that is in the one2many form. + record._rawChanges[name] = val; + // LPE TODO 1 taskid-2261084: remove this entire comment including code snippet + // when the change in behavior has been thoroughly tested. + // It is impossible to distinguish between values returned by the default_get + // and those returned by the onchange. Since those are not in _changes, they won't be saved. + // if (options.firstOnChange) { + // record._changes[name] = val; + // } + continue; + } + if (record._rawChanges[name]) { + // if previous _rawChanges exists, clear them since the field is now knwon + // and restoring outdated onchange over posterious change is wrong + delete record._rawChanges[name]; + } + var oldValue = name in record._changes ? record._changes[name] : record.data[name]; + var id; + if (field.type === 'many2one') { + id = false; + // in some case, the value returned by the onchange can + // be false (no value), so we need to avoid creating a + // local record for that. + if (val) { + // when the value isn't false, it can be either + // an array [id, display_name] or just an id. + var data = _.isArray(val) ? + {id: val[0], display_name: val[1]} : + {id: val}; + if (!oldValue || (self.localData[oldValue].res_id !== data.id)) { + // only register a change if the value has changed + rec = self._makeDataPoint({ + context: record.context, + data: data, + modelName: field.relation, + parentID: record.id, + }); + id = rec.id; + record._changes[name] = id; + } + } else { + record._changes[name] = false; + } + } else if (field.type === 'reference') { + id = false; + if (val) { + var ref = val.split(','); + var modelName = ref[0]; + var resID = parseInt(ref[1]); + if (!oldValue || self.localData[oldValue].res_id !== resID || + self.localData[oldValue].model !== modelName) { + // only register a change if the value has changed + rec = self._makeDataPoint({ + context: record.context, + data: {id: parseInt(ref[1])}, + modelName: modelName, + parentID: record.id, + }); + defs.push(self._fetchNameGet(rec)); + id = rec.id; + record._changes[name] = id; + } + } else { + record._changes[name] = id; + } + } else if (field.type === 'one2many' || field.type === 'many2many') { + var listId = record._changes[name] || record.data[name]; + var list; + if (listId) { + list = self.localData[listId]; + } else { + var fieldInfo = record.fieldsInfo[viewType][name]; + if (!fieldInfo) { + // ignore changes of x2many not in view + continue; + } + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + list = self._makeDataPoint({ + fields: view ? view.fields : fieldInfo.relatedFields, + fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + limit: fieldInfo.limit, + modelName: field.relation, + parentID: record.id, + static: true, + type: 'list', + viewType: view ? view.type : fieldInfo.viewType, + }); + } + // TODO: before registering the changes, verify that the x2many + // value has changed + record._changes[name] = list.id; + list._changes = list._changes || []; + + // save it in case of a [5] which will remove the _changes + var oldChanges = list._changes; + _.each(val, function (command) { + var rec, recID; + if (command[0] === 0 || command[0] === 1) { + // CREATE or UPDATE + if (command[0] === 0 && command[1]) { + // updating an existing (virtual) record + var previousChange = _.find(oldChanges, function (operation) { + var child = self.localData[operation.id]; + return child && (child.ref === command[1]); + }); + recID = previousChange && previousChange.id; + rec = self.localData[recID]; + } + if (command[0] === 1 && command[1]) { + // updating an existing record + rec = self.localData[list._cache[command[1]]]; + } + if (!rec) { + var params = { + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + ref: command[1], + }; + if (command[0] === 1) { + params.res_id = command[1]; + } + rec = self._makeDataPoint(params); + list._cache[rec.res_id] = rec.id; + if (options.firstOnChange) { + // this is necessary so the fields are initialized + rec.getFieldNames().forEach(fieldName => { + if (!(fieldName in rec.data)) { + rec.data[fieldName] = null; + } + }); + } + } + // Do not abandon the record if it has been created + // from `default_get`. The list has a savepoint only + // after having fully executed `default_get`. + rec._noAbandon = !list._savePoint; + list._changes.push({operation: 'ADD', id: rec.id}); + if (command[0] === 1) { + list._changes.push({operation: 'UPDATE', id: rec.id}); + } + defs.push(self._applyOnChange(command[2], rec, { + firstOnChange: options.firstOnChange, + })); + } else if (command[0] === 4) { + // LINK TO + linkRecord(list, command[1]); + } else if (command[0] === 5) { + // DELETE ALL + list._changes = [{operation: 'REMOVE_ALL'}]; + } else if (command[0] === 6) { + list._changes = [{operation: 'REMOVE_ALL'}]; + _.each(command[2], function (resID) { + linkRecord(list, resID); + }); + } + }); + var def = self._readUngroupedList(list).then(function () { + var x2ManysDef = self._fetchX2ManysBatched(list); + var referencesDef = self._fetchReferencesBatched(list); + return Promise.all([x2ManysDef, referencesDef]); + }); + defs.push(def); + } else { + var newValue = self._parseServerValue(field, val); + if (newValue !== oldValue) { + record._changes[name] = newValue; + } + } + } + return Promise.all(defs); + + // inner function that adds a record (based on its res_id) to a list + // dataPoint (used for onchanges that return commands 4 (LINK TO) or + // commands 6 (REPLACE WITH)) + function linkRecord (list, resID) { + rec = self.localData[list._cache[resID]]; + if (rec) { + // modifications done on a record are discarded if the onchange + // uses a LINK TO or a REPLACE WITH + self.discardChanges(rec.id); + } + // the dataPoint id will be set when the record will be fetched (for + // now, this dataPoint may not exist yet) + list._changes.push({ + operation: 'ADD', + id: rec ? rec.id : null, + resID: resID, + }); + } + }, + /** + * When an operation is applied to a x2many field, the field widgets + * generate one (or more) command, which describes the exact operation. + * This method tries to interpret these commands and apply them to the + * localData. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} command A command object. It should have a 'operation' + * key. For example, it looks like {operation: ADD, id: 'partner_1'} + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.allowWarning=false] if true, change + * operation can complete, even if a warning is raised + * (only supported by the 'CREATE' command.operation) + * @returns {Promise} + */ + _applyX2ManyChange: async function (record, fieldName, command, options) { + if (command.operation === 'TRIGGER_ONCHANGE') { + // the purpose of this operation is to trigger an onchange RPC, so + // there is no need to apply any change on the record (the changes + // have probably been already applied and saved, usecase: many2many + // edition in a dialog) + return Promise.resolve(); + } + + var self = this; + var localID = (record._changes && record._changes[fieldName]) || record.data[fieldName]; + var list = this.localData[localID]; + var field = record.fields[fieldName]; + var viewType = (options && options.viewType) || record.viewType; + var fieldInfo = record.fieldsInfo[viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var def, rec; + var defs = []; + list._changes = list._changes || []; + + switch (command.operation) { + case 'ADD': + // for now, we are in the context of a one2many field + // the command should look like this: + // { operation: 'ADD', id: localID } + // The corresponding record may contain value for fields that + // are unknown in the list (e.g. fields that are in the + // subrecord form view but not in the kanban or list view), so + // to ensure that onchanges are correctly handled, we extend the + // list's fields with those in the created record + var newRecord = this.localData[command.id]; + _.defaults(list.fields, newRecord.fields); + _.defaults(list.fieldsInfo, newRecord.fieldsInfo); + newRecord.fields = list.fields; + newRecord.fieldsInfo = list.fieldsInfo; + newRecord.viewType = list.viewType; + list._cache[newRecord.res_id] = newRecord.id; + list._changes.push(command); + break; + case 'ADD_M2M': + // force to use link command instead of create command + list._forceM2MLink = true; + // handle multiple add: command[2] may be a dict of values (1 + // record added) or an array of dict of values + var data = _.isArray(command.ids) ? command.ids : [command.ids]; + + // name_create records for which there is no id (typically, could + // be the case of a quick_create in a many2many_tags, so data.length + // is 1) + for (const r of data) { + if (!r.id && r.display_name) { + const prom = this._rpc({ + model: field.relation, + method: 'name_create', + args: [r.display_name], + context: this._getContext(record, {fieldName: fieldName, viewType: options.viewType}), + }).then(result => { + r.id = result[0]; + r.display_name = result[1]; + }); + defs.push(prom); + } + } + await Promise.all(defs); + + // Ensure the local data repository (list) boundaries can handle incoming records (data) + if (data.length + list.res_ids.length > list.limit) { + list.limit = data.length + list.res_ids.length; + } + + var list_records = {}; + _.each(data, function (d) { + rec = self._makeDataPoint({ + context: record.context, + modelName: field.relation, + fields: view ? view.fields : fieldInfo.relatedFields, + fieldsInfo: view ? view.fieldsInfo : fieldInfo.fieldsInfo, + res_id: d.id, + data: d, + viewType: view ? view.type : fieldInfo.viewType, + parentID: list.id, + }); + list_records[d.id] = rec; + list._cache[rec.res_id] = rec.id; + list._changes.push({operation: 'ADD', id: rec.id}); + }); + // read list's records as we only have their ids and optionally their display_name + // (we can't use function readUngroupedList because those records are only in the + // _changes so this is a very specific case) + // this could be optimized by registering the fetched records in the list's _cache + // so that if a record is removed and then re-added, it won't be fetched twice + var fieldNames = list.getFieldNames(); + if (fieldNames.length) { + def = this._rpc({ + model: list.model, + method: 'read', + args: [_.pluck(data, 'id'), fieldNames], + context: _.extend({}, record.context, field.context), + }).then(function (records) { + _.each(records, function (record) { + list_records[record.id].data = record; + self._parseServerData(fieldNames, list, record); + }); + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + }); + defs.push(def); + } + break; + case 'CREATE': + var createOptions = _.extend({ + context: command.context, + position: command.position + }, options || {}); + createOptions.viewType = fieldInfo.mode; + + def = this._addX2ManyDefaultRecord(list, createOptions).then(function (ids) { + _.each(ids, function(id){ + if (command.position === 'bottom' && list.orderedResIDs && list.orderedResIDs.length >= list.limit) { + list.tempLimitIncrement = (list.tempLimitIncrement || 0) + 1; + list.limit += 1; + } + // FIXME: hack for lunch widget, which does useless default_get and onchange + if (command.data) { + return self._applyChange(id, command.data); + } + }); + }); + defs.push(def); + break; + case 'UPDATE': + list._changes.push({operation: 'UPDATE', id: command.id}); + if (command.data) { + defs.push(this._applyChange(command.id, command.data, { + viewType: view && view.type, + })); + } + break; + case 'FORGET': + // Unlink the record of list. + list._forceM2MUnlink = true; + case 'DELETE': + // filter out existing operations involving the current + // dataPoint, and add a 'DELETE' or 'FORGET' operation only if there is + // no 'ADD' operation for that dataPoint, as it would mean + // that the record wasn't in the relation yet + var idsToRemove = command.ids; + list._changes = _.reject(list._changes, function (change, index) { + var idInCommands = _.contains(command.ids, change.id); + if (idInCommands && change.operation === 'ADD') { + idsToRemove = _.without(idsToRemove, change.id); + } + return idInCommands; + }); + _.each(idsToRemove, function (id) { + var operation = list._forceM2MUnlink ? 'FORGET': 'DELETE'; + list._changes.push({operation: operation, id: id}); + }); + break; + case 'DELETE_ALL': + // first remove all pending 'ADD' operations + list._changes = _.reject(list._changes, function (change) { + return change.operation === 'ADD'; + }); + + // then apply 'DELETE' on existing records + return this._applyX2ManyChange(record, fieldName, { + operation: 'DELETE', + ids: list.res_ids + }, options); + case 'REPLACE_WITH': + // this is certainly not optimal... and not sure that it is + // correct if some ids are added and some other are removed + list._changes = []; + var newIds = _.difference(command.ids, list.res_ids); + var removedIds = _.difference(list.res_ids, command.ids); + var addDef, removedDef, values; + if (newIds.length) { + values = _.map(newIds, function (id) { + return {id: id}; + }); + addDef = this._applyX2ManyChange(record, fieldName, { + operation: 'ADD_M2M', + ids: values + }, options); + } + if (removedIds.length) { + var listData = _.map(list.data, function (localId) { + return self.localData[localId]; + }); + removedDef = this._applyX2ManyChange(record, fieldName, { + operation: 'DELETE', + ids: _.map(removedIds, function (resID) { + if (resID in list._cache) { + return list._cache[resID]; + } + return _.findWhere(listData, {res_id: resID}).id; + }), + }, options); + } + return Promise.all([addDef, removedDef]); + case 'MULTI': + // allows batching multiple operations + _.each(command.commands, function (innerCommand){ + defs.push(self._applyX2ManyChange( + record, + fieldName, + innerCommand, + options + )); + }); + break; + } + + return Promise.all(defs).then(function () { + // ensure to fetch up to 'limit' records (may be useful if records of + // the current page have been removed) + return self._readUngroupedList(list).then(function () { + return self._fetchX2ManysBatched(list); + }); + }); + }, + /** + * In dataPoints of type list for x2manys, the changes are stored as a list + * of operations (being of type 'ADD', 'DELETE', 'FORGET', UPDATE' or 'REMOVE_ALL'). + * This function applies the operation of such a dataPoint without altering + * the original dataPoint. It returns a copy of the dataPoint in which the + * 'count', 'data' and 'res_ids' keys have been updated. + * + * @private + * @param {Object} dataPoint of type list + * @param {Object} [options] mostly contains the range of operations to apply + * @param {Object} [options.from=0] the index of the first operation to apply + * @param {Object} [options.to=length] the index of the last operation to apply + * @param {Object} [options.position] if set, each new operation will be set + * accordingly at the top or the bottom of the list + * @returns {Object} element of type list in which the commands have been + * applied + */ + _applyX2ManyOperations: function (list, options) { + if (!list.static) { + // this function only applies on x2many lists + return list; + } + var self = this; + list = _.extend({}, list); + list.res_ids = list.res_ids.slice(0); + var changes = list._changes || []; + if (options) { + var to = options.to === 0 ? 0 : (options.to || changes.length); + changes = changes.slice(options.from || 0, to); + } + _.each(changes, function (change) { + var relRecord; + if (change.id) { + relRecord = self.localData[change.id]; + } + switch (change.operation) { + case 'ADD': + list.count++; + var resID = relRecord ? relRecord.res_id : change.resID; + if (change.position === 'top' && (options ? options.position !== 'bottom' : true)) { + list.res_ids.unshift(resID); + } else { + list.res_ids.push(resID); + } + break; + case 'FORGET': + case 'DELETE': + list.count--; + // FIXME awa: there is no "relRecord" for o2m field + // seems like using change.id does the trick -> check with framework JS + var deletedResID = relRecord ? relRecord.res_id : change.id; + list.res_ids = _.without(list.res_ids, deletedResID); + break; + case 'REMOVE_ALL': + list.count = 0; + list.res_ids = []; + break; + case 'UPDATE': + // nothing to do for UPDATE commands + break; + } + }); + this._setDataInRange(list); + return list; + }, + /** + * Helper method to build a 'spec', that is a description of all fields in + * the view that have a onchange defined on them. + * + * An onchange spec is necessary as an argument to the /onchange route. It + * looks like this: { field: "1", anotherField: "", relation.subField: "1"} + * + * The first onchange call will fill up the record with default values, so + * we need to send every field name known to us in this case. + * + * @see _performOnChange + * + * @param {Object} record resource object of type 'record' + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {Object} with two keys + * - 'hasOnchange': true iff there is at least a field with onchange + * - 'onchangeSpec': the onchange spec + */ + _buildOnchangeSpecs: function (record, viewType) { + let hasOnchange = false; + const onchangeSpec = {}; + var fieldsInfo = record.fieldsInfo[viewType || record.viewType]; + generateSpecs(fieldsInfo, record.fields); + + // recursively generates the onchange specs for fields in fieldsInfo, + // and their subviews + function generateSpecs(fieldsInfo, fields, prefix) { + prefix = prefix || ''; + _.each(Object.keys(fieldsInfo), function (name) { + var field = fields[name]; + var fieldInfo = fieldsInfo[name]; + var key = prefix + name; + onchangeSpec[key] = (field.onChange) || ""; + if (field.onChange) { + hasOnchange = true; + } + if (field.type === 'one2many' || field.type === 'many2many') { + _.each(fieldInfo.views, function (view) { + generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.'); + }); + } + }); + } + return { hasOnchange, onchangeSpec }; + }, + /** + * Ensures that dataPoint ids are always synchronized between the main and + * sample models when being in sample mode. Here, we now that __id in the + * sample model is always greater than __id in the main model (as it + * contains strictly more datapoints). + * + * @override + */ + async _callSampleModel() { + await this._super(...arguments); + if (this._isInSampleMode) { + this.__id = this.sampleModel.__id; + } + }, + /** + * Compute the default value that the handle field should take. + * We need to compute this in order for new lines to be added at the correct position. + * + * @private + * @param {Object} listID + * @param {string} position + * @return {Object} empty object if no overrie has to be done, or: + * field: the name of the field to override, + * value: the value to use for that field + */ + _computeOverrideDefaultFields: function (listID, position) { + var list = this.localData[listID]; + var handleField; + + // Here listID is actually just parentID, it's not yet confirmed + // to be a list. + // If we are not in the case that interests us, + // listID will be undefined and this check will work. + if (!list) { + return {}; + } + + position = position || 'bottom'; + + // Let's find if there is a field with handle. + if (!list.fieldsInfo) { + return {}; + } + for (var field in list.fieldsInfo.list) { + if (list.fieldsInfo.list[field].widget === 'handle') { + handleField = field; + break; + // If there are 2 handle fields on the same list, + // we take the first one we find. + // And that will be alphabetically on the field name... + } + } + + if (!handleField) { + return {}; + } + + // We don't want to override the default value + // if the list is not ordered by the handle field. + var isOrderedByHandle = list.orderedBy + && list.orderedBy.length + && list.orderedBy[0].asc === true + && list.orderedBy[0].name === handleField; + + if (!isOrderedByHandle) { + return {}; + } + + // We compute the list (get) to apply the pending changes before doing our work, + // otherwise new lines might not be taken into account. + // We use raw: true because we only need to load the first level of relation. + var computedList = this.get(list.id, {raw: true}); + + // We don't need to worry about the position of a new line if the list is empty. + if (!computedList || !computedList.data || !computedList.data.length) { + return {}; + } + + // If there are less elements in the list than the limit of + // the page then take the index of the last existing line. + + // If the button is at the top, we want the new element on + // the first line of the page. + + // If the button is at the bottom, we want the new element + // after the last line of the page + // (= theorically it will be the first element of the next page). + + // We ignore list.offset because computedList.data + // will only have the current page elements. + + var index = Math.min( + computedList.data.length - 1, + position !== 'top' ? list.limit - 1 : 0 + ); + + // This positioning will almost be correct. There might just be + // an issue if several other lines have the same handleFieldValue. + + // TODO ideally: if there is an element with the same handleFieldValue, + // that one and all the following elements must be incremented + // by 1 (at least until there is a gap in the numbering). + + // We don't do it now because it's not an important case. + // However, we can for sure increment by 1 if we are on the last page. + var handleFieldValue = computedList.data[index].data[handleField]; + if (position === 'top') { + handleFieldValue--; + } else if (list.count <= list.offset + list.limit - (list.tempLimitIncrement || 0)) { + handleFieldValue++; + } + return { + field: handleField, + value: handleFieldValue, + }; + }, + /** + * Evaluate modifiers + * + * @private + * @param {Object} element a valid element object, which will serve as eval + * context. + * @param {Object} modifiers + * @returns {Object} + * @throws {Error} if one of the modifier domains is invalid + */ + _evalModifiers: function (element, modifiers) { + let evalContext = null; + const evaluated = {}; + for (const k of ['invisible', 'column_invisible', 'readonly', 'required']) { + const mod = modifiers[k]; + if (mod === undefined || mod === false || mod === true) { + if (k in modifiers) { + evaluated[k] = !!mod; + } + continue; + } + try { + evalContext = evalContext || this._getEvalContext(element); + evaluated[k] = new Domain(mod, evalContext).compute(evalContext); + } catch (e) { + throw new Error(_.str.sprintf('for modifier "%s": %s', k, e.message)); + } + } + return evaluated; + }, + /** + * Fetch name_get for a record datapoint. + * + * @param {Object} dataPoint + * @returns {Promise} + */ + _fetchNameGet: function (dataPoint) { + return this._rpc({ + model: dataPoint.model, + method: 'name_get', + args: [dataPoint.res_id], + context: dataPoint.getContext(), + }).then(function (result) { + dataPoint.data.display_name = result[0][1]; + }); + }, + /** + * Fetch name_get for a field of type Many2one or Reference + * + * @private + * @params {Object} list: must be a datapoint of type list + * (for example: a datapoint representing a x2many) + * @params {string} fieldName: the name of a field of type Many2one or Reference + * @returns {Promise} + */ + _fetchNameGets: function (list, fieldName) { + var self = this; + // We first get the model this way because if list.data is empty + // the _.each below will not make it. + var model = list.fields[fieldName].relation; + var records = []; + var ids = []; + list = this._applyX2ManyOperations(list); + + _.each(list.data, function (localId) { + var record = self.localData[localId]; + var data = record._changes || record.data; + var many2oneId = data[fieldName]; + if (!many2oneId) { return; } + var many2oneRecord = self.localData[many2oneId]; + records.push(many2oneRecord); + ids.push(many2oneRecord.res_id); + // We need to calculate the model this way too because + // field .relation is not set for a reference field. + model = many2oneRecord.model; + }); + + if (!ids.length) { + return Promise.resolve(); + } + return this._rpc({ + model: model, + method: 'name_get', + args: [_.uniq(ids)], + context: list.context, + }) + .then(function (name_gets) { + _.each(records, function (record) { + var nameGet = _.find(name_gets, function (nameGet) { + return nameGet[0] === record.data.id; + }); + record.data.display_name = nameGet[1]; + }); + }); + }, + /** + * For a given resource of type 'record', fetch all data. + * + * @param {Object} record local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the list of fields to fetch. If + * not given, fetch all the fields in record.fieldNames (+ display_name) + * @param {string} [options.viewType] the type of view for which the record + * is fetched (usefull to load the adequate fields), by defaults, uses + * record.viewType + * @returns {Promise<Object>} resolves to the record or is rejected in + * case no id given were valid ids + */ + _fetchRecord: function (record, options) { + var self = this; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + fieldNames = _.uniq(fieldNames.concat(['display_name'])); + return this._rpc({ + model: record.model, + method: 'read', + args: [[record.res_id], fieldNames], + context: _.extend({bin_size: true}, record.getContext()), + }) + .then(function (result) { + if (result.length === 0) { + return Promise.reject(); + } + result = result[0]; + record.data = _.extend({}, record.data, result); + }) + .then(function () { + self._parseServerData(fieldNames, record, record.data); + }) + .then(function () { + return Promise.all([ + self._fetchX2Manys(record, options), + self._fetchReferences(record, options) + ]).then(function () { + return self._postprocess(record, options); + }); + }); + }, + /** + * Fetch the `name_get` for a reference field. + * + * @private + * @param {Object} record + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReference: function (record, fieldName) { + var self = this; + var def; + var value = record._changes && record._changes[fieldName] || record.data[fieldName]; + var model = value && value.split(',')[0]; + var resID = value && parseInt(value.split(',')[1]); + if (model && model !== 'False' && resID) { + def = self._rpc({ + model: model, + method: 'name_get', + args: [resID], + context: record.getContext({fieldName: fieldName}), + }).then(function (result) { + return self._makeDataPoint({ + data: { + id: result[0][0], + display_name: result[0][1], + }, + modelName: model, + parentID: record.id, + }); + }); + } + return Promise.resolve(def); + }, + /** + * Fetches data for reference fields and assigns these data to newly + * created datapoint. + * Then places datapoint reference into parent record. + * + * @param {Object} datapoints a collection of ids classed by model, + * @see _getDataToFetchByModel + * @param {string} model + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceData: function (datapoints, model, fieldName) { + var self = this; + var ids = _.map(Object.keys(datapoints), function (id) { return parseInt(id); }); + // we need one parent for the context (they all have the same) + var parent = datapoints[ids[0]][0]; + var def = self._rpc({ + model: model, + method: 'name_get', + args: [ids], + context: self.localData[parent].getContext({fieldName: fieldName}), + }).then(function (result) { + _.each(result, function (el) { + var parentIDs = datapoints[el[0]]; + _.each(parentIDs, function (parentID) { + var parent = self.localData[parentID]; + var referenceDp = self._makeDataPoint({ + data: { + id: el[0], + display_name: el[1], + }, + modelName: model, + parentID: parent.id, + }); + parent.data[fieldName] = referenceDp.id; + }); + }); + }); + return def; + }, + /** + * Fetch the extra data (`name_get`) for the reference fields of the record + * model. + * + * @private + * @param {Object} record + * @returns {Promise} + */ + _fetchReferences: function (record, options) { + var self = this; + var defs = []; + var fieldNames = options && options.fieldNames || record.getFieldNames(); + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'reference') { + var def = self._fetchReference(record, fieldName).then(function (dataPoint) { + if (dataPoint) { + record.data[fieldName] = dataPoint.id; + } + }); + defs.push(def); + } + }); + return Promise.all(defs); + }, + /** + * Batch requests for one reference field in list (one request by different + * model in the field values). + * + * @see _fetchReferencesBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceBatched: function (list, fieldName) { + var self = this; + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetchByModel(list, fieldName); + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return Promise.all(defs); + }, + /** + * Batch requests for references for datapoint of type list. + * + * @param {Object} list + * @returns {Promise} + */ + _fetchReferencesBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceBatched(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * Batch reference requests for all records in list. + * + * @see _fetchReferencesSingleBatch + * @param {Object} list a valid resource object + * @param {string} fieldName + * @returns {Promise} + */ + _fetchReferenceSingleBatch: function (list, fieldName) { + var self = this; + + // collect ids by model + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + self._getDataToFetchByModel(group, fieldName, toFetch); + }); + + var defs = []; + // one name_get by model + _.each(toFetch, function (datapoints, model) { + defs.push(self._fetchReferenceData(datapoints, model, fieldName)); + }); + + return Promise.all(defs); + }, + /** + * Batch requests for all reference field in list's children. + * Called by _readGroup to make only one 'name_get' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Promise} + */ + _fetchReferencesSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var fIndex in fieldNames) { + var field = list.fields[fieldNames[fIndex]]; + if (field.type === 'reference') { + defs.push(this._fetchReferenceSingleBatch(list, fieldNames[fIndex])); + } + } + return Promise.all(defs); + }, + /** + * Fetch model data from server, relationally to fieldName and resulted + * field relation. For example, if fieldName is "tag_ids" and referred to + * project.tags, it will fetch project.tags' related fields where its id is + * contained in toFetch.ids array. + * + * @param {Object} list a valid resource object + * @param {Object} toFetch a list of records and res_ids, + * @see _getDataToFetch + * @param {string} fieldName + * @returns {Promise} + */ + _fetchRelatedData: function (list, toFetch, fieldName) { + var self = this; + var ids = _.keys(toFetch); + for (var i = 0; i < ids.length; i++) { + ids[i] = Number(ids[i]); + } + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + + if (!ids.length || fieldInfo.__no_fetch) { + return Promise.resolve(); + } + + var def; + var fieldNames = _.keys(fieldInfo.relatedFields); + if (fieldNames.length) { + var field = list.fields[fieldName]; + def = this._rpc({ + model: field.relation, + method: 'read', + args: [ids, fieldNames], + context: list.getContext() || {}, + }); + } else { + def = Promise.resolve(_.map(ids, function (id) { + return {id:id}; + })); + } + return def.then(function (result) { + var records = _.uniq(_.flatten(_.values(toFetch))); + self._updateRecordsData(records, fieldName, result); + }); + }, + /** + * Check the AbstractField specializations that are (will be) used by the + * given record and fetch the special data they will need. Special data are + * data that the rendering of the record won't need if it was not using + * particular widgets (example of these can be found at the methods which + * start with _fetchSpecial). + * + * @param {Object} record - an element from the localData + * @param {Object} options + * @returns {Promise<Array>} + * The promise is resolved with an array containing the names of + * the field whose special data has been changed. + */ + _fetchSpecialData: function (record, options) { + var self = this; + var specialFieldNames = []; + var fieldNames = (options && options.fieldNames) || record.getFieldNames(); + return Promise.all(_.map(fieldNames, function (name) { + var viewType = (options && options.viewType) || record.viewType; + var fieldInfo = record.fieldsInfo[viewType][name] || {}; + var Widget = fieldInfo.Widget; + if (Widget && Widget.prototype.specialData) { + return self[Widget.prototype.specialData](record, name, fieldInfo).then(function (data) { + if (data === undefined) { + return; + } + record.specialData[name] = data; + specialFieldNames.push(name); + }); + } + })).then(function () { + return specialFieldNames; + }); + }, + /** + * Fetches all the m2o records associated to the given fieldName. If the + * given fieldName is not a m2o field, nothing is done. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @param {string[]} [fieldsToRead] - the m2os fields to read (id and + * display_name are automatic). + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialMany2ones: function (record, fieldName, fieldInfo, fieldsToRead) { + var field = record.fields[fieldName]; + if (field.type !== "many2one") { + return Promise.resolve(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + if (domain.length) { + var localID = (record._changes && fieldName in record._changes) ? + record._changes[fieldName] : + record.data[fieldName]; + if (localID) { + var element = this.localData[localID]; + domain = ["|", ["id", "=", element.data.id]].concat(domain); + } + } + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domain: domain, + }); + if (!hasChanged) { + return Promise.resolve(); + } + + var self = this; + return this._rpc({ + model: field.relation, + method: 'search_read', + fields: ["id"].concat(fieldsToRead || []), + context: context, + domain: domain, + }) + .then(function (records) { + var ids = _.pluck(records, 'id'); + return self._rpc({ + model: field.relation, + method: 'name_get', + args: [ids], + context: context, + }) + .then(function (name_gets) { + _.each(records, function (rec) { + var name_get = _.find(name_gets, function (n) { + return n[0] === rec.id; + }); + rec.display_name = name_get[1]; + }); + return records; + }); + }); + }, + /** + * Fetches all the relation records associated to the given fieldName. If + * the given fieldName is not a relational field, nothing is done. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialRelation: function (record, fieldName) { + var field = record.fields[fieldName]; + if (!_.contains(["many2one", "many2many", "one2many"], field.type)) { + return Promise.resolve(); + } + + var context = record.getContext({fieldName: fieldName}); + var domain = record.getDomain({fieldName: fieldName}); + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domain: domain, + }); + if (!hasChanged) { + return Promise.resolve(); + } + + return this._rpc({ + model: field.relation, + method: 'name_search', + args: ["", domain], + context: context + }); + }, + /** + * Fetches the `name_get` associated to the reference widget if the field is + * a `char` (which is a supported case). + * + * @private + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @returns {Promise} + */ + _fetchSpecialReference: function (record, fieldName) { + var def; + var field = record.fields[fieldName]; + if (field.type === 'char') { + // if the widget reference is set on a char field, the name_get + // needs to be fetched a posteriori + def = this._fetchReference(record, fieldName); + } + return Promise.resolve(def); + }, + /** + * Fetches all the m2o records associated to the given fieldName. If the + * given fieldName is not a m2o field, nothing is done. The difference with + * _fetchSpecialMany2ones is that the field given by options.fold_field is + * also fetched. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialStatus: function (record, fieldName, fieldInfo) { + var foldField = fieldInfo.options.fold_field; + var fieldsToRead = foldField ? [foldField] : []; + return this._fetchSpecialMany2ones(record, fieldName, fieldInfo, fieldsToRead).then(function (m2os) { + _.each(m2os, function (m2o) { + m2o.fold = foldField ? m2o[foldField] : false; + }); + return m2os; + }); + }, + /** + * Fetches the number of records associated to the domain the value of the + * given field represents. + * + * @param {Object} record - an element from the localData + * @param {Object} fieldName - the name of the field + * @param {Object} fieldInfo + * @returns {Promise<any>} + * The promise is resolved with the fetched special data. If this + * data is the same as the previously fetched one (for the given + * parameters), no RPC is done and the promise is resolved with + * the undefined value. + */ + _fetchSpecialDomain: function (record, fieldName, fieldInfo) { + var self = this; + var context = record.getContext({fieldName: fieldName}); + + var domainModel = fieldInfo.options.model; + if (record.data.hasOwnProperty(domainModel)) { + domainModel = record._changes && record._changes[domainModel] || record.data[domainModel]; + } + var domainValue = record._changes && record._changes[fieldName] || record.data[fieldName] || []; + + // avoid rpc if not necessary + var hasChanged = this._saveSpecialDataCache(record, fieldName, { + context: context, + domainModel: domainModel, + domainValue: domainValue, + }); + if (!hasChanged) { + return Promise.resolve(); + } else if (!domainModel) { + return Promise.resolve({ + model: domainModel, + nbRecords: 0, + }); + } + + return new Promise(function (resolve) { + var evalContext = self._getEvalContext(record); + self._rpc({ + model: domainModel, + method: 'search_count', + args: [Domain.prototype.stringToArray(domainValue, evalContext)], + context: context + }) + .then(function (nbRecords) { + resolve({ + model: domainModel, + nbRecords: nbRecords, + }); + }) + .guardedCatch(function (reason) { + var e = reason.event; + e.preventDefault(); // prevent traceback (the search_count might be intended to break) + resolve({ + model: domainModel, + nbRecords: 0, + }); + }); + }); + }, + /** + * Fetch all data in a ungrouped list + * + * @param {Object} list a valid resource object + * @param {Object} [options] + * @param {boolean} [options.enableRelationalFetch=true] if false, will not + * fetch x2m and relational data (that will be done by _readGroup in this + * case). + * @returns {Promise<Object>} resolves to the fecthed list + */ + _fetchUngroupedList: function (list, options) { + options = _.defaults(options || {}, {enableRelationalFetch: true}); + var self = this; + var def; + if (list.static) { + def = this._readUngroupedList(list).then(function () { + if (list.parentID && self.isNew(list.parentID)) { + // list from a default_get, so fetch display_name for many2one fields + var many2ones = self._getMany2OneFieldNames(list); + var defs = _.map(many2ones, function (name) { + return self._fetchNameGets(list, name); + }); + return Promise.all(defs); + } + }); + } else { + def = this._searchReadUngroupedList(list); + } + return def.then(function () { + if (options.enableRelationalFetch) { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + } + }).then(function () { + return list; + }); + }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Promise} + */ + _fetchX2ManyBatched: function (list, fieldName) { + list = this._applyX2ManyOperations(list); + this._sortList(list); + + var toFetch = this._getDataToFetch(list, fieldName); + return this._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * X2Manys have to be fetched by separate rpcs (their data are stored on + * different models). This method takes a record, look at its x2many fields, + * then, if necessary, create a local resource and fetch the corresponding + * data. + * + * It also tries to reuse data, if it can find an existing list, to prevent + * useless rpcs. + * + * @param {Object} record local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the list of fields to fetch. + * If not given, fetch all the fields in record.fieldNames + * @param {string} [options.viewType] the type of view for which the main + * record is fetched (useful to load the adequate fields), by defaults, + * uses record.viewType + * @returns {Promise} + */ + _fetchX2Manys: function (record, options) { + var self = this; + var defs = []; + options = options || {}; + var fieldNames = options.fieldNames || record.getFieldNames(options); + var viewType = options.viewType || record.viewType; + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + var fieldInfo = record.fieldsInfo[viewType][fieldName]; + var rawContext = fieldInfo && fieldInfo.context; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : (fieldInfo.fieldsInfo || {}); + var ids = record.data[fieldName] || []; + var list = self._makeDataPoint({ + count: ids.length, + context: _.extend({}, record.context, field.context), + fieldsInfo: fieldsInfo, + fields: view ? view.fields : fieldInfo.relatedFields, + limit: fieldInfo.limit, + modelName: field.relation, + res_ids: ids, + static: true, + type: 'list', + orderedBy: fieldInfo.orderedBy, + parentID: record.id, + rawContext: rawContext, + relationField: field.relation_field, + viewType: view ? view.type : fieldInfo.viewType, + }); + record.data[fieldName] = list.id; + if (!fieldInfo.__no_fetch) { + var def = self._readUngroupedList(list).then(function () { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + }); + defs.push(def); + } + } + }); + return Promise.all(defs); + }, + /** + * batch request for x2ms for datapoint of type list + * + * @param {Object} list + * @returns {Promise} + */ + _fetchX2ManysBatched: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many') { + defs.push(this._fetchX2ManyBatched(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * For a non-static list, batches requests for all its sublists' records. + * Make only one rpc for all records on the concerned field. + * + * @see _fetchX2ManysSingleBatch + * @param {Object} list a valid resource object, its data must be another + * list containing records + * @param {string} fieldName + * @returns {Promise} + */ + _fetchX2ManySingleBatch: function (list, fieldName) { + var self = this; + var toFetch = {}; + _.each(list.data, function (groupIndex) { + var group = self.localData[groupIndex]; + var nextDataToFetch = self._getDataToFetch(group, fieldName); + _.each(_.keys(nextDataToFetch), function (id) { + if (toFetch[id]) { + toFetch[id] = toFetch[id].concat(nextDataToFetch[id]); + } else { + toFetch[id] = nextDataToFetch[id]; + } + }); + }); + return self._fetchRelatedData(list, toFetch, fieldName); + }, + /** + * Batch requests for all x2m in list's children. + * Called by _readGroup to make only one 'read' rpc by fieldName. + * + * @param {Object} list a valid resource object + * @returns {Promise} + */ + _fetchX2ManysSingleBatch: function (list) { + var defs = []; + var fieldNames = list.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var field = list.fields[fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many'){ + defs.push(this._fetchX2ManySingleBatch(list, fieldNames[i])); + } + } + return Promise.all(defs); + }, + /** + * Generates an object mapping field names to their changed value in a given + * record (i.e. maps to the new value for basic fields, to the res_id for + * many2ones and to commands for x2manys). + * + * @private + * @param {Object} record + * @param {Object} [options] + * @param {boolean} [options.changesOnly=true] if true, only generates + * commands for fields that have changed (concerns x2many fields only) + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record. Note that if an editionViewType is + * specified for a field, it will take the priority over the viewType arg. + * @returns {Object} a map from changed fields to their new value + */ + _generateChanges: function (record, options) { + options = options || {}; + var viewType = options.viewType || record.viewType; + var changes; + const changesOnly = 'changesOnly' in options ? !!options.changesOnly : true; + if (!changesOnly) { + changes = _.extend({}, record.data, record._changes); + } else { + changes = _.extend({}, record._changes); + } + var withReadonly = options.withReadonly || false; + var commands = this._generateX2ManyCommands(record, { + changesOnly: changesOnly, + withReadonly: withReadonly, + }); + for (var fieldName in record.fields) { + // remove readonly fields from the list of changes + if (!withReadonly && fieldName in changes || fieldName in commands) { + var editionViewType = record._editionViewType[fieldName] || viewType; + if (this._isFieldProtected(record, fieldName, editionViewType)) { + delete changes[fieldName]; + continue; + } + } + + // process relational fields and handle the null case + var type = record.fields[fieldName].type; + var value; + if (type === 'one2many' || type === 'many2many') { + if (!changesOnly || (commands[fieldName] && commands[fieldName].length)) { // replace localId by commands + changes[fieldName] = commands[fieldName]; + } else { // no command -> no change for that field + delete changes[fieldName]; + } + } else if (type === 'many2one' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? this.localData[value].res_id : false; + } else if (type === 'reference' && fieldName in changes) { + value = changes[fieldName]; + changes[fieldName] = value ? + this.localData[value].model + ',' + this.localData[value].res_id : + false; + } else if (type === 'char' && changes[fieldName] === '') { + changes[fieldName] = false; + } else if (changes[fieldName] === null) { + changes[fieldName] = false; + } + } + return changes; + }, + /** + * Generates an object mapping field names to their current value in a given + * record. If the record is inside a one2many, the returned object contains + * an additional key (the corresponding many2one field name) mapping to the + * current value of the parent record. + * + * @param {Object} record + * @param {Object} [options] This option object will be given to the private + * method _generateX2ManyCommands. In particular, it is useful to be able + * to send changesOnly:true to get all data, not only the current changes. + * @returns {Object} the data + */ + _generateOnChangeData: function (record, options) { + options = _.extend({}, options || {}, {withReadonly: true}); + var data = {}; + if (!options.firstOnChange) { + var commands = this._generateX2ManyCommands(record, options); + data = _.extend(this.get(record.id, {raw: true}).data, commands); + // 'display_name' is automatically added to the list of fields to fetch, + // when fetching a record, even if it doesn't appear in the view. However, + // only the fields in the view must be passed to the onchange RPC, so we + // remove it from the data sent by RPC if it isn't in the view. + var hasDisplayName = _.some(record.fieldsInfo, function (fieldsInfo) { + return 'display_name' in fieldsInfo; + }); + if (!hasDisplayName) { + delete data.display_name; + } + } + + // one2many records have a parentID + if (record.parentID) { + var parent = this.localData[record.parentID]; + // parent is the list element containing all the records in the + // one2many and parent.parentID is the ID of the main record + // if there is a relation field, this means that record is an elem + // in a one2many. The relation field is the corresponding many2one + if (parent.parentID && parent.relationField) { + var parentRecord = this.localData[parent.parentID]; + data[parent.relationField] = this._generateOnChangeData(parentRecord); + } + } + + return data; + }, + /** + * Read all x2many fields and generate the commands for the server to create + * or write them... + * + * @param {Object} record + * @param {Object} [options] + * @param {string} [options.fieldNames] if given, generates the commands for + * these fields only + * @param {boolean} [changesOnly=false] if true, only generates commands for + * fields that have changed + * @param {boolean} [options.withReadonly=false] if false, doesn't generate + * changes for readonly fields in commands + * @returns {Object} a map from some field names to commands + */ + _generateX2ManyCommands: function (record, options) { + var self = this; + options = options || {}; + var fields = record.fields; + if (options.fieldNames) { + fields = _.pick(fields, options.fieldNames); + } + var commands = {}; + var data = _.extend({}, record.data, record._changes); + var type; + for (var fieldName in fields) { + type = fields[fieldName].type; + + if (type === 'many2many' || type === 'one2many') { + if (!data[fieldName]) { + // skip if this field is empty + continue; + } + commands[fieldName] = []; + var list = this.localData[data[fieldName]]; + if (options.changesOnly && (!list._changes || !list._changes.length)) { + // if only changes are requested, skip if there is no change + continue; + } + var oldResIDs = list.res_ids.slice(0); + var relRecordAdded = []; + var relRecordUpdated = []; + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && change.id) { + relRecordAdded.push(self.localData[change.id]); + } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) { + // ignore new records that would have been updated + // afterwards, as all their changes would already + // be aggregated in the CREATE command + relRecordUpdated.push(self.localData[change.id]); + } + }); + list = this._applyX2ManyOperations(list); + this._sortList(list); + if (type === 'many2many' || list._forceM2MLink) { + var relRecordCreated = _.filter(relRecordAdded, function (rec) { + return typeof rec.res_id === 'string'; + }); + var realIDs = _.difference(list.res_ids, _.pluck(relRecordCreated, 'res_id')); + // deliberately generate a single 'replace' command instead + // of a 'delete' and a 'link' commands with the exact diff + // because 1) performance-wise it doesn't change anything + // and 2) to guard against concurrent updates (policy: force + // a complete override of the actual value of the m2m) + commands[fieldName].push(x2ManyCommands.replace_with(realIDs)); + _.each(relRecordCreated, function (relRecord) { + var changes = self._generateChanges(relRecord, options); + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + }); + // generate update commands for records that have been + // updated (it may happen with editable lists) + _.each(relRecordUpdated, function (relRecord) { + var changes = self._generateChanges(relRecord, options); + if (!_.isEmpty(changes)) { + var command = x2ManyCommands.update(relRecord.res_id, changes); + commands[fieldName].push(command); + } + }); + } else if (type === 'one2many') { + var removedIds = _.difference(oldResIDs, list.res_ids); + var addedIds = _.difference(list.res_ids, oldResIDs); + var keptIds = _.intersection(oldResIDs, list.res_ids); + + // the didChange variable keeps track of the fact that at + // least one id was updated + var didChange = false; + var changes, command, relRecord; + for (var i = 0; i < list.res_ids.length; i++) { + if (_.contains(keptIds, list.res_ids[i])) { + // this is an id that already existed + relRecord = _.findWhere(relRecordUpdated, {res_id: list.res_ids[i]}); + changes = relRecord ? this._generateChanges(relRecord, options) : {}; + if (!_.isEmpty(changes)) { + command = x2ManyCommands.update(relRecord.res_id, changes); + didChange = true; + } else { + command = x2ManyCommands.link_to(list.res_ids[i]); + } + commands[fieldName].push(command); + } else if (_.contains(addedIds, list.res_ids[i])) { + // this is a new id (maybe existing in DB, but new in JS) + relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); + if (!relRecord) { + commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i])); + continue; + } + changes = this._generateChanges(relRecord, options); + if (!this.isNew(relRecord.id)) { + // the subrecord already exists in db + commands[fieldName].push(x2ManyCommands.link_to(relRecord.res_id)); + if (this.isDirty(relRecord.id)) { + delete changes.id; + commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes)); + } + } else { + // the subrecord is new, so create it + + // we may have received values from an onchange for fields that are + // not in the view, and that we don't even know, as we don't have the + // fields_get of models of related fields. We save those values + // anyway, but for many2ones, we have to extract the id from the pair + // [id, display_name] + const rawChangesEntries = Object.entries(relRecord._rawChanges); + for (const [fieldName, value] of rawChangesEntries) { + const isMany2OneValue = Array.isArray(value) && + value.length === 2 && + Number.isInteger(value[0]) && + typeof value[1] === 'string'; + changes[fieldName] = isMany2OneValue ? value[0] : value; + } + + commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes)); + } + } + } + if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) { + // in this situation, we have no changed ids, no added + // ids and no removed ids, so we can safely ignore the + // last changes + commands[fieldName] = []; + } + // add delete commands + for (i = 0; i < removedIds.length; i++) { + if (list._forceM2MUnlink) { + commands[fieldName].push(x2ManyCommands.forget(removedIds[i])); + } else { + commands[fieldName].push(x2ManyCommands.delete(removedIds[i])); + } + } + } + } + } + return commands; + }, + /** + * Every RPC done by the model need to add some context, which is a + * combination of the context of the session, of the record/list, and/or of + * the concerned field. This method combines all these contexts and evaluate + * them with the proper evalcontext. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string|Object} [options.additionalContext] + * another context to evaluate and merge to the returned context + * @param {string} [options.fieldName] + * if given, this field's context is added to the context, instead of + * the element's context (except if options.full is true) + * @param {boolean} [options.full=false] + * if true or nor fieldName or additionalContext given in options, + * the element's context is added to the context + * @returns {Object} the evaluated context + */ + _getContext: function (element, options) { + options = options || {}; + var context = new Context(session.user_context); + context.set_eval_context(this._getEvalContext(element)); + + if (options.full || !(options.fieldName || options.additionalContext)) { + context.add(element.context); + } + if (options.fieldName) { + var viewType = options.viewType || element.viewType; + var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; + if (fieldInfo && fieldInfo.context) { + context.add(fieldInfo.context); + } else { + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.context) { + context.add(fieldParams.context); + } + } + } + if (options.additionalContext) { + context.add(options.additionalContext); + } + if (element.rawContext) { + var rawContext = new Context(element.rawContext); + var evalContext = this._getEvalContext(this.localData[element.parentID]); + evalContext.id = evalContext.id || false; + rawContext.set_eval_context(evalContext); + context.add(rawContext); + } + + return context.eval(); + }, + /** + * Collects from a record a list of ids to fetch, according to fieldName, + * and a list of records where to set the result of the fetch. + * + * @param {Object} list a list containing records we want to get the ids, + * it assumes _applyX2ManyOperations and _sort have been already called on + * this list + * @param {string} fieldName + * @return {Object} a list of records and res_ids + */ + _getDataToFetch: function (list, fieldName) { + var self = this; + var field = list.fields[fieldName]; + var fieldInfo = list.fieldsInfo[list.viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + var toFetch = {}; + + // flattens the list.data ids in a grouped case + let dataPointIds = list.data; + for (let i = 0; i < list.groupedBy.length; i++) { + dataPointIds = dataPointIds.reduce((acc, groupId) => + acc.concat(this.localData[groupId].data), []); + } + + dataPointIds.forEach(function (dataPoint) { + var record = self.localData[dataPoint]; + if (typeof record.data[fieldName] === 'string'){ + // in this case, the value is a local ID, which means that the + // record has already been processed. It can happen for example + // when a user adds a record in a m2m relation, or loads more + // records in a kanban column + return; + } + + _.each(record.data[fieldName], function (id) { + toFetch[id] = toFetch[id] || []; + toFetch[id].push(record); + }); + + var m2mList = self._makeDataPoint({ + fieldsInfo: fieldsInfo, + fields: fields, + modelName: field.relation, + parentID: record.id, + res_ids: record.data[fieldName], + static: true, + type: 'list', + viewType: viewType, + }); + record.data[fieldName] = m2mList.id; + }); + + return toFetch; + }, + /** + * Determines and returns from a list a collection of ids classed by + * their model. + * + * @param {Object} list a valid resource object + * @param {string} fieldName + * @param {Object} [toFetchAcc] an object to store fetching data. Used when + * batching reference across multiple groups. + * [modelName: string]: { + * [recordId: number]: datapointId[] + * } + * @returns {Object} each key represent a model and contain a sub-object + * where each key represent an id (res_id) containing an array of + * webclient id (referred to a datapoint, so not a res_id). + */ + _getDataToFetchByModel: function (list, fieldName, toFetchAcc) { + var self = this; + var toFetch = toFetchAcc || {}; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var value = record.data[fieldName]; + // if the reference field has already been fetched, the value is a + // datapoint ID, and in this case there's nothing to do + if (value && !self.localData[value]) { + var model = value.split(',')[0]; + var resID = value.split(',')[1]; + if (!(model in toFetch)) { + toFetch[model] = {}; + } + // there could be multiple datapoints with the same model/resID + if (toFetch[model][resID]) { + toFetch[model][resID].push(dataPoint); + } else { + toFetch[model][resID] = [dataPoint]; + } + } + }); + return toFetch; + }, + /** + * Given a dataPoint of type list (that may be a group), returns an object + * with 'default_' keys to be used to create new records in that group. + * + * @private + * @param {Object} dataPoint + * @returns {Object} + */ + _getDefaultContext: function (dataPoint) { + var defaultContext = {}; + while (dataPoint.parentID) { + var parent = this.localData[dataPoint.parentID]; + var groupByField = parent.groupedBy[0].split(':')[0]; + var value = viewUtils.getGroupValue(dataPoint, groupByField); + if (value) { + defaultContext['default_' + groupByField] = value; + } + dataPoint = parent; + } + return defaultContext; + }, + /** + * Some records are associated to a/some domain(s). This method allows to + * retrieve them, evaluated. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {string} [options.fieldName] + * the name of the field whose domain needs to be returned + * @returns {Array} the evaluated domain + */ + _getDomain: function (element, options) { + if (options && options.fieldName) { + if (element._domains[options.fieldName]) { + return Domain.prototype.stringToArray( + element._domains[options.fieldName], + this._getEvalContext(element, true) + ); + } + var viewType = options.viewType || element.viewType; + var fieldInfo = element.fieldsInfo[viewType][options.fieldName]; + if (fieldInfo && fieldInfo.domain) { + return Domain.prototype.stringToArray( + fieldInfo.domain, + this._getEvalContext(element, true) + ); + } + var fieldParams = element.fields[options.fieldName]; + if (fieldParams.domain) { + return Domain.prototype.stringToArray( + fieldParams.domain, + this._getEvalContext(element, true) + ); + } + return []; + } + + return Domain.prototype.stringToArray( + element.domain, + this._getEvalContext(element, true) + ); + }, + /** + * Returns the evaluation context that should be used when evaluating the + * context/domain associated to a given element from the localData. + * + * It is actually quite subtle. We need to add some magic keys: active_id + * and active_ids. Also, the session user context is added in the mix to be + * sure. This allows some domains to use the uid key for example + * + * @param {Object} element - an element from the localData + * @param {boolean} [forDomain=false] if true, evaluates x2manys as a list of + * ids instead of a list of commands + * @returns {Object} + */ + _getEvalContext: function (element, forDomain) { + var evalContext = element.type === 'record' ? this._getRecordEvalContext(element, forDomain) : {}; + + if (element.parentID) { + var parent = this.localData[element.parentID]; + if (parent.type === 'list' && parent.parentID) { + parent = this.localData[parent.parentID]; + } + if (parent.type === 'record') { + evalContext.parent = this._getRecordEvalContext(parent, forDomain); + } + } + // Uses "current_company_id" because "company_id" would conflict with all the company_id fields + // in general, the actual "company_id" field of the form should be used for m2o domains, not this fallback + let current_company_id; + if (session.user_context.allowed_company_ids) { + current_company_id = session.user_context.allowed_company_ids[0]; + } else { + current_company_id = session.user_companies ? + session.user_companies.current_company[0] : + false; + } + return Object.assign( + { + active_id: evalContext.id || false, + active_ids: evalContext.id ? [evalContext.id] : [], + active_model: element.model, + current_company_id, + id: evalContext.id || false, + }, + pyUtils.context(), + session.user_context, + element.context, + evalContext, + ); + }, + /** + * Returns the list of field names of the given element according to its + * default view type. + * + * @param {Object} element an element from the localData + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {string[]} the list of field names + */ + _getFieldNames: function (element, options) { + var fieldsInfo = element.fieldsInfo; + var viewType = options && options.viewType || element.viewType; + return Object.keys(fieldsInfo && fieldsInfo[viewType] || {}); + }, + /** + * Get many2one fields names in a datapoint. This is useful in order to + * fetch their names in the case of a default_get. + * + * @private + * @param {Object} datapoint a valid resource object + * @returns {string[]} list of field names that are many2one + */ + _getMany2OneFieldNames: function (datapoint) { + var many2ones = []; + _.each(datapoint.fields, function (field, name) { + if (field.type === 'many2one') { + many2ones.push(name); + } + }); + return many2ones; + }, + /** + * Evaluate the record evaluation context. This method is supposed to be + * called by _getEvalContext. It basically only generates a dictionary of + * current values for the record, with commands for x2manys fields. + * + * @param {Object} record an element of type 'record' + * @param {boolean} [forDomain=false] if true, x2many values are a list of + * ids instead of a list of commands + * @returns Object + */ + _getRecordEvalContext: function (record, forDomain) { + var self = this; + var relDataPoint; + var context = _.extend({}, record.data, record._changes); + + // calls _generateX2ManyCommands for a given field, and returns the array of commands + function _generateX2ManyCommands(fieldName) { + var commands = self._generateX2ManyCommands(record, {fieldNames: [fieldName]}); + return commands[fieldName]; + } + + for (var fieldName in context) { + var field = record.fields[fieldName]; + if (context[fieldName] === null) { + context[fieldName] = false; + } + if (!field || field.name === 'id') { + continue; + } + if (field.type === 'date' || field.type === 'datetime') { + if (context[fieldName]) { + context[fieldName] = JSON.parse(JSON.stringify(context[fieldName])); + } + continue; + } + if (field.type === 'many2one') { + relDataPoint = this.localData[context[fieldName]]; + context[fieldName] = relDataPoint ? relDataPoint.res_id : false; + continue; + } + if (field.type === 'one2many' || field.type === 'many2many') { + var ids; + if (!context[fieldName] || _.isArray(context[fieldName])) { // no dataPoint created yet + ids = context[fieldName] ? context[fieldName].slice(0) : []; + } else { + relDataPoint = this._applyX2ManyOperations(this.localData[context[fieldName]]); + ids = relDataPoint.res_ids.slice(0); + } + if (!forDomain) { + // when sent to the server, the x2manys values must be a list + // of commands in a context, but the list of ids in a domain + ids.toJSON = _generateX2ManyCommands.bind(null, fieldName); + } else if (field.type === 'one2many') { // Ids are evaluated as a list of ids + /* Filtering out virtual ids from the ids list + * The server will crash if there are virtual ids in there + * The webClient doesn't do literal id list comparison like ids == list + * Only relevant in o2m: m2m does create actual records in db + */ + ids = _.filter(ids, function (id) { + return typeof id !== 'string'; + }); + } + context[fieldName] = ids; + } + + } + return context; + }, + /** + * Invalidates the DataManager's cache if the main model (i.e. the model of + * its root parent) of the given dataPoint is a model in 'noCacheModels'. + * + * Reloads the currencies if the main model is 'res.currency'. + * Reloads the webclient if we modify a res.company, to (un)activate the + * multi-company environment if we are not in a tour test. + * + * @private + * @param {Object} dataPoint + */ + _invalidateCache: function (dataPoint) { + while (dataPoint.parentID) { + dataPoint = this.localData[dataPoint.parentID]; + } + if (dataPoint.model === 'res.currency') { + session.reloadCurrencies(); + } + if (dataPoint.model === 'res.company' && !localStorage.getItem('running_tour')) { + this.do_action('reload_context'); + } + if (_.contains(this.noCacheModels, dataPoint.model)) { + core.bus.trigger('clear_cache'); + } + }, + /** + * Returns true if the field is protected against changes, looking for a + * readonly modifier unless there is a force_save modifier (checking first + * in the modifiers, and if there is no readonly modifier, checking the + * readonly attribute of the field). + * + * @private + * @param {Object} record an element from the localData + * @param {string} fieldName + * @param {string} [viewType] current viewType. If not set, we will assume + * main viewType from the record + * @returns {boolean} + */ + _isFieldProtected: function (record, fieldName, viewType) { + viewType = viewType || record.viewType; + var fieldInfo = viewType && record.fieldsInfo && record.fieldsInfo[viewType][fieldName]; + if (fieldInfo) { + var rawModifiers = fieldInfo.modifiers || {}; + var modifiers = this._evalModifiers(record, _.pick(rawModifiers, 'readonly')); + return modifiers.readonly && !fieldInfo.force_save; + } else { + return false; + } + }, + /** + * Returns true iff value is considered to be set for the given field's type. + * + * @private + * @param {any} value a value for the field + * @param {string} fieldType a type of field + * @returns {boolean} + */ + _isFieldSet: function (value, fieldType) { + switch (fieldType) { + case 'boolean': + return true; + case 'one2many': + case 'many2many': + return value.length > 0; + default: + return value !== false; + } + }, + /** + * return true if a list element is 'valid'. Such an element is valid if it + * has no sub record with an unset required field. + * + * This method is meant to be used to check if a x2many change will trigger + * an onchange. + * + * @param {string} id id for a local resource of type 'list'. This is + * assumed to be a list element for an x2many + * @returns {boolean} + */ + _isX2ManyValid: function (id) { + var self = this; + var isValid = true; + var element = this.localData[id]; + _.each(element._changes, function (command) { + if (command.operation === 'DELETE' || + command.operation === 'FORGET' || + (command.operation === 'ADD' && !command.isNew)|| + command.operation === 'REMOVE_ALL') { + return; + } + var recordData = self.get(command.id, {raw: true}).data; + var record = self.localData[command.id]; + _.each(element.getFieldNames(), function (fieldName) { + var field = element.fields[fieldName]; + var fieldInfo = element.fieldsInfo[element.viewType][fieldName]; + var rawModifiers = fieldInfo.modifiers || {}; + var modifiers = self._evalModifiers(record, _.pick(rawModifiers, 'required')); + if (modifiers.required && !self._isFieldSet(recordData[fieldName], field.type)) { + isValid = false; + } + }); + }); + return isValid; + }, + /** + * Helper method for the load entry point. + * + * @see load + * + * @param {Object} dataPoint some local resource + * @param {Object} [options] + * @param {string[]} [options.fieldNames] the fields to fetch for a record + * @param {boolean} [options.onlyGroups=false] + * @param {boolean} [options.keepEmptyGroups=false] if set, the groups not + * present in the read_group anymore (empty groups) will stay in the + * datapoint (used to mimic the kanban renderer behaviour for example) + * @returns {Promise} + */ + _load: function (dataPoint, options) { + if (options && options.onlyGroups && + !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) { + return Promise.resolve(dataPoint); + } + + if (dataPoint.type === 'record') { + return this._fetchRecord(dataPoint, options); + } + if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { + return this._readGroup(dataPoint, options); + } + if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { + return this._fetchUngroupedList(dataPoint, options); + } + }, + /** + * Turns a bag of properties into a valid local resource. Also, register + * the resource in the localData object. + * + * @param {Object} params + * @param {Object} [params.aggregateValues={}] + * @param {Object} [params.context={}] context of the action + * @param {integer} [params.count=0] number of record being manipulated + * @param {Object|Object[]} [params.data={}|[]] data of the record + * @param {*[]} [params.domain=[]] + * @param {Object} params.fields contains the description of each field + * @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field + * @param {Object[]} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {string[]} [params.groupedBy=[]] + * @param {boolean} [params.isOpen] + * @param {integer} params.limit max number of records shown on screen (pager size) + * @param {string} params.modelName + * @param {integer} [params.offset] + * @param {boolean} [params.openGroupByDefault] + * @param {Object[]} [params.orderedBy=[]] + * @param {integer[]} [params.orderedResIDs] + * @param {string} [params.parentID] model name ID of the parent model + * @param {Object} [params.rawContext] + * @param {[type]} [params.ref] + * @param {string} [params.relationField] + * @param {integer|null} [params.res_id] actual id of record in the server + * @param {integer[]} [params.res_ids] context in which the data point is used, from a list of res_id + * @param {boolean} [params.static=false] + * @param {string} [params.type='record'|'list'] + * @param {[type]} [params.value] + * @param {string} [params.viewType] the type of the view, e.g. 'list' or 'form' + * @returns {Object} the resource created + */ + _makeDataPoint: function (params) { + var type = params.type || ('domain' in params && 'list') || 'record'; + var res_id, value; + var res_ids = params.res_ids || []; + var data = params.data || (type === 'record' ? {} : []); + var context = params.context; + if (type === 'record') { + res_id = params.res_id || (params.data && params.data.id); + if (res_id) { + data.id = res_id; + } else { + res_id = _.uniqueId('virtual_'); + } + // it doesn't make sense for a record datapoint to have those keys + // besides, it will mess up x2m and actions down the line + context = _.omit(context, ['orderedBy', 'group_by']); + } else { + var isValueArray = params.value instanceof Array; + res_id = isValueArray ? params.value[0] : undefined; + value = isValueArray ? params.value[1] : params.value; + } + + var fields = _.extend({ + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, params.fields); + + var dataPoint = { + _cache: type === 'list' ? {} : undefined, + _changes: null, + _domains: {}, + _rawChanges: {}, + aggregateValues: params.aggregateValues || {}, + context: context, + count: params.count || res_ids.length, + data: data, + domain: params.domain || [], + fields: fields, + fieldsInfo: params.fieldsInfo, + groupedBy: params.groupedBy || [], + groupsCount: 0, + groupsLimit: type === 'list' && params.groupsLimit || null, + groupsOffset: 0, + id: `${params.modelName}_${++this.__id}`, + isOpen: params.isOpen, + limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER), + loadMoreOffset: 0, + model: params.modelName, + offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0), + openGroupByDefault: params.openGroupByDefault, + orderedBy: params.orderedBy || [], + orderedResIDs: params.orderedResIDs, + parentID: params.parentID, + rawContext: params.rawContext, + ref: params.ref || res_id, + relationField: params.relationField, + res_id: res_id, + res_ids: res_ids, + specialData: {}, + _specialDataCache: {}, + static: params.static || false, + type: type, // 'record' | 'list' + value: value, + viewType: params.viewType, + }; + + // _editionViewType is a dict whose keys are field names and which is populated when a field + // is edited with the viewType as value. This is useful for one2manys to determine whether + // or not a field is readonly (using the readonly modifiers of the view in which the field + // has been edited) + dataPoint._editionViewType = {}; + + dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint); + dataPoint.getContext = this._getContext.bind(this, dataPoint); + dataPoint.getDomain = this._getDomain.bind(this, dataPoint); + dataPoint.getFieldNames = this._getFieldNames.bind(this, dataPoint); + dataPoint.isDirty = this.isDirty.bind(this, dataPoint.id); + + this.localData[dataPoint.id] = dataPoint; + + return dataPoint; + }, + /** + * When one needs to create a record from scratch, a not so simple process + * needs to be done: + * - call the /default_get route to get default values + * - fetch all relational data + * - apply all onchanges if necessary + * - fetch all relational data + * + * This method tries to optimize the process as much as possible. Also, + * it is quite horrible and should be refactored at some point. + * + * @private + * @param {any} params + * @param {string} modelName model name + * @param {boolean} [params.allowWarning=false] if true, the default record + * operation can complete, even if a warning is raised + * @param {Object} params.context the context for the new record + * @param {Object} params.fieldsInfo contains the fieldInfo of each view, + * for each field + * @param {Object} params.fields contains the description of each field + * @param {Object} params.context the context for the new record + * @param {string} params.viewType the key in fieldsInfo of the fields to load + * @returns {Promise<string>} resolves to the id for the created resource + */ + async _makeDefaultRecord(modelName, params) { + var targetView = params.viewType; + var fields = params.fields; + var fieldsInfo = params.fieldsInfo; + var fieldNames = Object.keys(fieldsInfo[targetView]); + + // Fields that are present in the originating view, that need to be initialized + // Hence preventing their value to crash when getting back to the originating view + var parentRecord = params.parentID && this.localData[params.parentID].type === 'list' ? this.localData[params.parentID] : null; + + if (parentRecord && parentRecord.viewType in parentRecord.fieldsInfo) { + var originView = parentRecord.viewType; + fieldNames = _.union(fieldNames, Object.keys(parentRecord.fieldsInfo[originView])); + fieldsInfo[targetView] = _.defaults({}, fieldsInfo[targetView], parentRecord.fieldsInfo[originView]); + fields = _.defaults({}, fields, parentRecord.fields); + } + + var record = this._makeDataPoint({ + modelName: modelName, + fields: fields, + fieldsInfo: fieldsInfo, + context: params.context, + parentID: params.parentID, + res_ids: params.res_ids, + viewType: targetView, + }); + + await this.generateDefaultValues(record.id, {}, { fieldNames }); + try { + await this._performOnChange(record, [], { firstOnChange: true }); + } finally { + if (record._warning && params.allowWarning) { + delete record._warning; + } + } + if (record._warning) { + return Promise.reject(); + } + + // We want to overwrite the default value of the handle field (if any), + // in order for new lines to be added at the correct position. + // -> This is a rare case where the defaul_get from the server + // will be ignored by the view for a certain field (usually "sequence"). + var overrideDefaultFields = this._computeOverrideDefaultFields(params.parentID, params.position); + if (overrideDefaultFields.field) { + record._changes[overrideDefaultFields.field] = overrideDefaultFields.value; + } + + // fetch additional data (special data and many2one namegets for "always_reload" fields) + await this._postprocess(record); + // save initial changes, so they can be restored later, if we need to discard + this.save(record.id, { savePoint: true }); + return record.id; + }, + /** + * parse the server values to javascript framwork + * + * @param {[string]} fieldNames + * @param {Object} element the dataPoint used as parent for the created + * dataPoints + * @param {Object} data the server data to parse + */ + _parseServerData: function (fieldNames, element, data) { + var self = this; + _.each(fieldNames, function (fieldName) { + var field = element.fields[fieldName]; + var val = data[fieldName]; + if (field.type === 'many2one') { + // process many2one: split [id, nameget] and create corresponding record + if (val !== false) { + // the many2one value is of the form [id, display_name] + var r = self._makeDataPoint({ + modelName: field.relation, + fields: { + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, + data: { + display_name: val[1], + id: val[0], + }, + parentID: element.id, + }); + data[fieldName] = r.id; + } else { + // no value for the many2one + data[fieldName] = false; + } + } else { + data[fieldName] = self._parseServerValue(field, val); + } + }); + }, + /** + * This method is quite important: it is supposed to perform the /onchange + * rpc and apply the result. + * + * The changes that triggered the onchange are assumed to have already been + * applied to the record. + * + * @param {Object} record + * @param {string[]} fields changed fields (empty list in the case of first + * onchange) + * @param {Object} [options={}] + * @param {string} [options.viewType] current viewType. If not set, we will assume + * main viewType from the record + * @param {boolean} [options.firstOnChange=false] set to true if this is the + * first onchange + * @returns {Promise} + */ + async _performOnChange(record, fields, options = {}) { + const firstOnChange = options.firstOnChange; + let { hasOnchange, onchangeSpec } = this._buildOnchangeSpecs(record, options.viewType); + if (!firstOnChange && !hasOnchange) { + return; + } + var idList = record.data.id ? [record.data.id] : []; + const ctxOptions = { + full: true, + }; + if (fields.length === 1) { + fields = fields[0]; + // if only one field changed, add its context to the RPC context + ctxOptions.fieldName = fields; + } + var context = this._getContext(record, ctxOptions); + var currentData = this._generateOnChangeData(record, { + changesOnly: false, + firstOnChange, + }); + + const result = await this._rpc({ + model: record.model, + method: 'onchange', + args: [idList, currentData, fields, onchangeSpec], + context: context, + }); + if (!record._changes) { + // if the _changes key does not exist anymore, it means that + // it was removed by discarding the changes after the rpc + // to onchange. So, in that case, the proper response is to + // ignore the onchange. + return; + } + if (result.warning) { + this.trigger_up('warning', result.warning); + record._warning = true; + } + if (result.domain) { + record._domains = Object.assign(record._domains, result.domain); + } + await this._applyOnChange(result.value, record, { firstOnChange }); + return result; + }, + /** + * This function accumulates RPC requests done in the same call stack, and + * performs them in the next micro task tick so that similar requests can be + * batched in a single RPC. + * + * For now, only 'read' calls are supported. + * + * @private + * @param {Object} params + * @returns {Promise} + */ + _performRPC: function (params) { + var self = this; + + // save the RPC request + var request = _.extend({}, params); + var prom = new Promise(function (resolve, reject) { + request.resolve = resolve; + request.reject = reject; + }); + this.batchedRPCsRequests.push(request); + + // empty the pool of RPC requests in the next micro tick + Promise.resolve().then(function () { + if (!self.batchedRPCsRequests.length) { + // pool has already been processed + return; + } + + // reset pool of RPC requests + var batchedRPCsRequests = self.batchedRPCsRequests; + self.batchedRPCsRequests = []; + + // batch similar requests + var batches = {}; + var key; + for (var i = 0; i < batchedRPCsRequests.length; i++) { + var request = batchedRPCsRequests[i]; + key = request.model + ',' + JSON.stringify(request.context); + if (!batches[key]) { + batches[key] = _.extend({}, request, {requests: [request]}); + } else { + batches[key].ids = _.uniq(batches[key].ids.concat(request.ids)); + batches[key].fieldNames = _.uniq(batches[key].fieldNames.concat(request.fieldNames)); + batches[key].requests.push(request); + } + } + + // perform batched RPCs + function onSuccess(batch, results) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + var fieldNames = request.fieldNames.concat(['id']); + var filteredResults = results.filter(function (record) { + return request.ids.indexOf(record.id) >= 0; + }).map(function (record) { + return _.pick(record, fieldNames); + }); + request.resolve(filteredResults); + } + } + function onFailure(batch, error) { + for (var i = 0; i < batch.requests.length; i++) { + var request = batch.requests[i]; + request.reject(error); + } + } + for (key in batches) { + var batch = batches[key]; + self._rpc({ + model: batch.model, + method: 'read', + args: [batch.ids, batch.fieldNames], + context: batch.context, + }).then(onSuccess.bind(null, batch)).guardedCatch(onFailure.bind(null, batch)); + } + }); + + return prom; + }, + /** + * Once a record is created and some data has been fetched, we need to do + * quite a lot of computations to determine what needs to be fetched. This + * method is doing that. + * + * @see _fetchRecord @see _makeDefaultRecord + * + * @param {Object} record + * @param {Object} [options] + * @param {Object} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise<Object>} resolves to the finished resource + */ + _postprocess: function (record, options) { + var self = this; + var viewType = options && options.viewType || record.viewType; + var defs = []; + + _.each(record.getFieldNames(options), function (name) { + var field = record.fields[name]; + var fieldInfo = record.fieldsInfo[viewType][name] || {}; + var options = fieldInfo.options || {}; + if (options.always_reload) { + if (record.fields[name].type === 'many2one') { + const _changes = record._changes || {}; + const relRecordId = _changes[name] || record.data[name]; + if (!relRecordId) { + return; // field is unset, no need to do the name_get + } + var relRecord = self.localData[relRecordId]; + defs.push(self._rpc({ + model: field.relation, + method: 'name_get', + args: [relRecord.data.id], + context: self._getContext(record, {fieldName: name, viewType: viewType}), + }) + .then(function (result) { + relRecord.data.display_name = result[0][1]; + })); + } + } + }); + + defs.push(this._fetchSpecialData(record, options)); + + return Promise.all(defs).then(function () { + return record; + }); + }, + /** + * Process x2many commands in a default record by transforming the list of + * commands in operations (pushed in _changes) and fetch the related + * records fields. + * + * Note that this method can be called recursively. + * + * @todo in master: factorize this code with the postprocessing of x2many in + * _applyOnChange + * + * @private + * @param {Object} record + * @param {string} fieldName + * @param {Array[Array]} commands + * @param {Object} [options] + * @param {string} [options.viewType] current viewType. If not set, we will + * assume main viewType from the record + * @returns {Promise} + */ + _processX2ManyCommands: function (record, fieldName, commands, options) { + var self = this; + options = options || {}; + var defs = []; + var field = record.fields[fieldName]; + var fieldInfo = record.fieldsInfo[options.viewType || record.viewType][fieldName] || {}; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + // remove default_* keys from parent context to avoid issue of same field name in x2m + var parentContext = _.omit(record.context, function (val, key) { + return _.str.startsWith(key, 'default_'); + }); + var x2manyList = self._makeDataPoint({ + context: parentContext, + fieldsInfo: fieldsInfo, + fields: fields, + limit: fieldInfo.limit, + modelName: field.relation, + parentID: record.id, + rawContext: fieldInfo && fieldInfo.context, + relationField: field.relation_field, + res_ids: [], + static: true, + type: 'list', + viewType: viewType, + }); + record._changes[fieldName] = x2manyList.id; + x2manyList._changes = []; + var many2ones = {}; + var r; + commands = commands || []; // handle false value + var isCommandList = commands.length && _.isArray(commands[0]); + if (!isCommandList) { + commands = [[6, false, commands]]; + } + _.each(commands, function (value) { + // value is a command + if (value[0] === 0) { + // CREATE + r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fieldsInfo: fieldsInfo, + fields: fields, + parentID: x2manyList.id, + viewType: viewType, + }); + r._noAbandon = true; + x2manyList._changes.push({operation: 'ADD', id: r.id}); + x2manyList._cache[r.res_id] = r.id; + + // this is necessary so the fields are initialized + _.each(r.getFieldNames(), function (fieldName) { + r.data[fieldName] = null; + }); + + r._changes = _.defaults(value[2], r.data); + for (var fieldName in r._changes) { + if (!r._changes[fieldName]) { + continue; + } + var isFieldInView = fieldName in r.fields; + if (isFieldInView) { + var field = r.fields[fieldName]; + var fieldType = field.type; + var rec; + if (fieldType === 'many2one') { + rec = self._makeDataPoint({ + context: r.context, + modelName: field.relation, + data: {id: r._changes[fieldName]}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; + } else if (fieldType === 'reference') { + var reference = r._changes[fieldName].split(','); + rec = self._makeDataPoint({ + context: r.context, + modelName: reference[0], + data: {id: parseInt(reference[1])}, + parentID: r.id, + }); + r._changes[fieldName] = rec.id; + many2ones[fieldName] = true; + } else if (_.contains(['one2many', 'many2many'], fieldType)) { + var x2mCommands = value[2][fieldName]; + defs.push(self._processX2ManyCommands(r, fieldName, x2mCommands)); + } else { + r._changes[fieldName] = self._parseServerValue(field, r._changes[fieldName]); + } + } + } + } + if (value[0] === 6) { + // REPLACE_WITH + _.each(value[2], function (res_id) { + x2manyList._changes.push({operation: 'ADD', resID: res_id}); + }); + var def = self._readUngroupedList(x2manyList).then(function () { + return Promise.all([ + self._fetchX2ManysBatched(x2manyList), + self._fetchReferencesBatched(x2manyList) + ]); + }); + defs.push(def); + } + }); + + // fetch many2ones display_name + _.each(_.keys(many2ones), function (name) { + defs.push(self._fetchNameGets(x2manyList, name)); + }); + + return Promise.all(defs); + }, + /** + * Reads data from server for all missing fields. + * + * @private + * @param {Object} list a valid resource object + * @param {interger[]} resIDs + * @param {string[]} fieldNames to check and read if missing + * @returns {Promise<Object>} + */ + _readMissingFields: function (list, resIDs, fieldNames) { + var self = this; + + var missingIDs = []; + for (var i = 0, len = resIDs.length; i < len; i++) { + var resId = resIDs[i]; + var dataPointID = list._cache[resId]; + if (!dataPointID) { + missingIDs.push(resId); + continue; + } + var record = self.localData[dataPointID]; + var data = _.extend({}, record.data, record._changes); + if (_.difference(fieldNames, _.keys(data)).length) { + missingIDs.push(resId); + } + } + + var def; + if (missingIDs.length && fieldNames.length) { + def = self._performRPC({ + context: list.getContext(), + fieldNames: fieldNames, + ids: missingIDs, + method: 'read', + model: list.model, + }); + } else { + def = Promise.resolve(_.map(missingIDs, function (id) { + return {id:id}; + })); + } + return def.then(function (records) { + _.each(resIDs, function (id) { + var dataPoint; + var data = _.findWhere(records, {id: id}); + if (id in list._cache) { + dataPoint = self.localData[list._cache[id]]; + if (data) { + self._parseServerData(fieldNames, dataPoint, data); + _.extend(dataPoint.data, data); + } + } else { + dataPoint = self._makeDataPoint({ + context: list.getContext(), + data: data, + fieldsInfo: list.fieldsInfo, + fields: list.fields, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + self._parseServerData(fieldNames, dataPoint, dataPoint.data); + + // add many2one records + list._cache[id] = dataPoint.id; + } + // set the dataPoint id in potential 'ADD' operation adding the current record + _.each(list._changes, function (change) { + if (change.operation === 'ADD' && !change.id && change.resID === id) { + change.id = dataPoint.id; + } + }); + }); + return list; + }); + }, + /** + * For a grouped list resource, this method fetches all group data by + * performing a /read_group. It also tries to read open subgroups if they + * were open before. + * + * @param {Object} list valid resource object + * @param {Object} [options] @see _load + * @returns {Promise<Object>} resolves to the fetched group object + */ + _readGroup: function (list, options) { + var self = this; + options = options || {}; + var groupByField = list.groupedBy[0]; + var rawGroupBy = groupByField.split(':')[0]; + var fields = _.uniq(list.getFieldNames().concat(rawGroupBy)); + var orderedBy = _.filter(list.orderedBy, function (order) { + return order.name === rawGroupBy || list.fields[order.name].group_operator !== undefined; + }); + var openGroupsLimit = list.groupsLimit || self.OPEN_GROUP_LIMIT; + var expand = list.openGroupByDefault && options.fetchRecordsWithGroups; + return this._rpc({ + model: list.model, + method: 'web_read_group', + fields: fields, + domain: list.domain, + context: list.context, + groupBy: list.groupedBy, + limit: list.groupsLimit, + offset: list.groupsOffset, + orderBy: orderedBy, + lazy: true, + expand: expand, + expand_limit: expand ? list.limit : null, + expand_orderby: expand ? list.orderedBy : null, + }) + .then(function (result) { + var groups = result.groups; + list.groupsCount = result.length; + var previousGroups = _.map(list.data, function (groupID) { + return self.localData[groupID]; + }); + list.data = []; + list.count = 0; + var defs = []; + var openGroupCount = 0; + + _.each(groups, function (group) { + var aggregateValues = {}; + _.each(group, function (value, key) { + if (_.contains(fields, key) && key !== groupByField && + AGGREGATABLE_TYPES.includes(list.fields[key].type)) { + aggregateValues[key] = value; + } + }); + // When a view is grouped, we need to display the name of each group in + // the 'title'. + var value = group[groupByField]; + if (list.fields[rawGroupBy].type === "selection") { + var choice = _.find(list.fields[rawGroupBy].selection, function (c) { + return c[0] === value; + }); + value = choice ? choice[1] : false; + } + var newGroup = self._makeDataPoint({ + modelName: list.model, + count: group[rawGroupBy + '_count'], + domain: group.__domain, + context: list.context, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + value: value, + aggregateValues: aggregateValues, + groupedBy: list.groupedBy.slice(1), + orderedBy: list.orderedBy, + orderedResIDs: list.orderedResIDs, + limit: list.limit, + openGroupByDefault: list.openGroupByDefault, + parentID: list.id, + type: 'list', + viewType: list.viewType, + }); + var oldGroup = _.find(previousGroups, function (g) { + return g.res_id === newGroup.res_id && g.value === newGroup.value; + }); + if (oldGroup) { + delete self.localData[newGroup.id]; + // restore the internal state of the group + var updatedProps = _.pick(oldGroup, 'isOpen', 'offset', 'id'); + if (options.onlyGroups || oldGroup.isOpen && newGroup.groupedBy.length) { + // If the group is opened and contains subgroups, + // also keep its data to keep internal state of + // sub-groups + // Also keep data if we only reload groups' own data + updatedProps.data = oldGroup.data; + if (options.onlyGroups) { + // keep count and res_ids as in this case the group + // won't be search_read again. This situation happens + // when using kanban quick_create where the record is manually + // added to the datapoint before getting here. + updatedProps.res_ids = oldGroup.res_ids; + updatedProps.count = oldGroup.count; + } + } + _.extend(newGroup, updatedProps); + // set the limit such that all previously loaded records + // (e.g. if we are coming back to the kanban view from a + // form view) are reloaded + newGroup.limit = oldGroup.limit + oldGroup.loadMoreOffset; + self.localData[newGroup.id] = newGroup; + } else if (!newGroup.openGroupByDefault || openGroupCount >= openGroupsLimit) { + newGroup.isOpen = false; + } else if ('__fold' in group) { + newGroup.isOpen = !group.__fold; + } else { + // open the group iff it is a first level group + newGroup.isOpen = !self.localData[newGroup.parentID].parentID; + } + list.data.push(newGroup.id); + list.count += newGroup.count; + if (newGroup.isOpen && newGroup.count > 0) { + openGroupCount++; + if (group.__data) { + // bypass the search_read when the group's records have been obtained + // by the call to 'web_read_group' (see @_searchReadUngroupedList) + newGroup.__data = group.__data; + } + options = _.defaults({enableRelationalFetch: false}, options); + defs.push(self._load(newGroup, options)); + } + }); + if (options.keepEmptyGroups) { + // Find the groups that were available in a previous + // readGroup but are not there anymore. + // Note that these groups are put after existing groups so + // the order is not conserved. A sort *might* be useful. + var emptyGroupsIDs = _.difference(_.pluck(previousGroups, 'id'), list.data); + _.each(emptyGroupsIDs, function (groupID) { + list.data.push(groupID); + var emptyGroup = self.localData[groupID]; + // this attribute hasn't been updated in the previous + // loop for empty groups + emptyGroup.aggregateValues = {}; + }); + } + + return Promise.all(defs).then(function (groups) { + if (!options.onlyGroups) { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(groups, function (group) { + return group ? group.res_ids : []; + })); + } + return list; + }).then(function () { + return Promise.all([ + self._fetchX2ManysSingleBatch(list), + self._fetchReferencesSingleBatch(list) + ]).then(function () { + return list; + }); + }); + }); + }, + /** + * For 'static' list, such as one2manys in a form view, we can do a /read + * instead of a /search_read. + * + * @param {Object} list a valid resource object + * @returns {Promise<Object>} resolves to the fetched list object + */ + _readUngroupedList: function (list) { + var self = this; + var def = Promise.resolve(); + + // generate the current count and res_ids list by applying the changes + list = this._applyX2ManyOperations(list); + + // for multi-pages list datapoints, we might need to read the + // order field first to apply the order on all pages + if (list.res_ids.length > list.limit && list.orderedBy.length) { + if (!list.orderedResIDs) { + var fieldNames = _.pluck(list.orderedBy, 'name'); + def = this._readMissingFields(list, _.filter(list.res_ids, _.isNumber), fieldNames); + } + def.then(function () { + self._sortList(list); + }); + } + return def.then(function () { + var resIDs = []; + var currentResIDs = list.res_ids; + // if new records have been added to the list, their virtual ids have + // been pushed at the end of res_ids (or at the beginning, depending + // on the editable property), ignoring completely the current page + // where the records have actually been created ; for that reason, + // we use orderedResIDs which is a freezed order with the virtual ids + // at the correct position where they were actually inserted ; however, + // when we use orderedResIDs, we must filter out ids that are not in + // res_ids, which correspond to records that have been removed from + // the relation (this information being taken into account in res_ids + // but not in orderedResIDs) + if (list.orderedResIDs) { + currentResIDs = list.orderedResIDs.filter(function (resID) { + return list.res_ids.indexOf(resID) >= 0; + }); + } + var currentCount = currentResIDs.length; + var upperBound = list.limit ? Math.min(list.offset + list.limit, currentCount) : currentCount; + var fieldNames = list.getFieldNames(); + for (var i = list.offset; i < upperBound; i++) { + var resId = currentResIDs[i]; + if (_.isNumber(resId)) { + resIDs.push(resId); + } + } + return self._readMissingFields(list, resIDs, fieldNames).then(function () { + if (list.res_ids.length <= list.limit) { + self._sortList(list); + } else { + // sortList has already been applied after first the read + self._setDataInRange(list); + } + return list; + }); + }); + }, + /** + * Reload all data for a given resource + * + * @private + * @param {string} id local id for a resource + * @param {Object} [options] + * @param {boolean} [options.keepChanges=false] if true, doesn't discard the + * changes on the record before reloading it + * @returns {Promise<string>} resolves to the id of the resource + */ + _reload: function (id, options) { + options = options || {}; + var element = this.localData[id]; + + if (element.type === 'record') { + if (!options.currentId && (('currentId' in options) || this.isNew(id))) { + var params = { + context: element.context, + fieldsInfo: element.fieldsInfo, + fields: element.fields, + viewType: element.viewType, + allowWarning: true, + }; + return this._makeDefaultRecord(element.model, params); + } + if (!options.keepChanges) { + this.discardChanges(id, {rollback: false}); + } + } else if (element._changes) { + delete element.tempLimitIncrement; + _.each(element._changes, function (change) { + delete change.isNew; + }); + } + + if (options.context !== undefined) { + element.context = options.context; + } + if (options.orderedBy !== undefined) { + element.orderedBy = (options.orderedBy.length && options.orderedBy) || element.orderedBy; + } + if (options.domain !== undefined) { + element.domain = options.domain; + } + if (options.groupBy !== undefined) { + element.groupedBy = options.groupBy; + } + if (options.limit !== undefined) { + element.limit = options.limit; + } + if (options.offset !== undefined) { + this._setOffset(element.id, options.offset); + } + if (options.groupsLimit !== undefined) { + element.groupsLimit = options.groupsLimit; + } + if (options.groupsOffset !== undefined) { + element.groupsOffset = options.groupsOffset; + } + if (options.loadMoreOffset !== undefined) { + element.loadMoreOffset = options.loadMoreOffset; + } else { + // reset if not specified + element.loadMoreOffset = 0; + } + if (options.currentId !== undefined) { + element.res_id = options.currentId; + } + if (options.ids !== undefined) { + element.res_ids = options.ids; + element.count = element.res_ids.length; + } + if (element.type === 'record') { + element.offset = _.indexOf(element.res_ids, element.res_id); + } + var loadOptions = _.pick(options, 'fieldNames', 'viewType'); + return this._load(element, loadOptions).then(function (result) { + return result.id; + }); + }, + /** + * Override to handle the case where we want sample data, and we are in a + * grouped kanban or list view with real groups, but all groups are empty. + * In this case, we use the result of the web_read_group rpc to tweak the + * data in the SampleServer instance of the sampleModel (so that calls to + * that server will return the same groups). + * + * @override + */ + async _rpc(params) { + const result = await this._super(...arguments); + if (this.sampleModel && params.method === 'web_read_group' && result.length) { + const sampleServer = this.sampleModel.sampleServer; + sampleServer.setExistingGroups(result.groups); + } + return result; + }, + /** + * Allows to save a value in the specialData cache associated to a given + * record and fieldName. If the value in the cache was already the given + * one, nothing is done and the method indicates it by returning false + * instead of true. + * + * @private + * @param {Object} record - an element from the localData + * @param {string} fieldName - the name of the field + * @param {*} value - the cache value to save + * @returns {boolean} false if the value was already the given one + */ + _saveSpecialDataCache: function (record, fieldName, value) { + if (_.isEqual(record._specialDataCache[fieldName], value)) { + return false; + } + record._specialDataCache[fieldName] = value; + return true; + }, + /** + * Do a /search_read to get data for a list resource. This does a + * /search_read because the data may not be static (for ex, a list view). + * + * @param {Object} list + * @returns {Promise} + */ + _searchReadUngroupedList: function (list) { + var self = this; + var fieldNames = list.getFieldNames(); + var prom; + if (list.__data) { + // the data have already been fetched (alonside the groups by the + // call to 'web_read_group'), so we can bypass the search_read + prom = Promise.resolve(list.__data); + } else { + prom = this._rpc({ + route: '/web/dataset/search_read', + model: list.model, + fields: fieldNames, + context: _.extend({}, list.getContext(), {bin_size: true}), + domain: list.domain || [], + limit: list.limit, + offset: list.loadMoreOffset + list.offset, + orderBy: list.orderedBy, + }); + } + return prom.then(function (result) { + delete list.__data; + list.count = result.length; + var ids = _.pluck(result.records, 'id'); + var data = _.map(result.records, function (record) { + var dataPoint = self._makeDataPoint({ + context: list.context, + data: record, + fields: list.fields, + fieldsInfo: list.fieldsInfo, + modelName: list.model, + parentID: list.id, + viewType: list.viewType, + }); + + // add many2one records + self._parseServerData(fieldNames, dataPoint, dataPoint.data); + return dataPoint.id; + }); + if (list.loadMoreOffset) { + list.data = list.data.concat(data); + list.res_ids = list.res_ids.concat(ids); + } else { + list.data = data; + list.res_ids = ids; + } + self._updateParentResIDs(list); + return list; + }); + }, + /** + * Set data in range, i.e. according to the list offset and limit. + * + * @param {Object} list + */ + _setDataInRange: function (list) { + var idsInRange; + if (list.limit) { + idsInRange = list.res_ids.slice(list.offset, list.offset + list.limit); + } else { + idsInRange = list.res_ids; + } + list.data = []; + _.each(idsInRange, function (id) { + if (list._cache[id]) { + list.data.push(list._cache[id]); + } + }); + + // display newly created record in addition to the displayed records + if (list.limit) { + for (var i = list.offset + list.limit; i < list.res_ids.length; i++) { + var id = list.res_ids[i]; + var dataPointID = list._cache[id]; + if (_.findWhere(list._changes, {isNew: true, id: dataPointID})) { + list.data.push(dataPointID); + } else { + break; + } + } + } + }, + /** + * Change the offset of a record. Note that this does not reload the data. + * The offset is used to load a different record in a list of record (for + * example, a form view with a pager. Clicking on next/previous actually + * changes the offset through this method). + * + * @param {string} elementId local id for the resource + * @param {number} offset + */ + _setOffset: function (elementId, offset) { + var element = this.localData[elementId]; + element.offset = offset; + if (element.type === 'record' && element.res_ids.length) { + element.res_id = element.res_ids[offset]; + } + }, + /** + * Do a in-memory sort of a list resource data points. This method assumes + * that the list data has already been fetched, and that the changes that + * need to be sorted have already been applied. Its intended use is for + * static datasets, such as a one2many in a form view. + * + * @param {Object} list list dataPoint on which (some) changes might have + * been applied; it is a copy of an internal dataPoint, not the result of + * get + */ + _sortList: function (list) { + if (!list.static) { + // only sort x2many lists + return; + } + var self = this; + + if (list.orderedResIDs) { + var orderedResIDs = {}; + for (var k = 0; k < list.orderedResIDs.length; k++) { + orderedResIDs[list.orderedResIDs[k]] = k; + } + utils.stableSort(list.res_ids, function compareResIdIndexes (resId1, resId2) { + if (!(resId1 in orderedResIDs) && !(resId2 in orderedResIDs)) { + return 0; + } + if (!(resId1 in orderedResIDs)) { + return Infinity; + } + if (!(resId2 in orderedResIDs)) { + return -Infinity; + } + return orderedResIDs[resId1] - orderedResIDs[resId2]; + }); + } else if (list.orderedBy.length) { + // sort records according to ordered_by[0] + var compareRecords = function (resId1, resId2, level) { + if(!level) { + level = 0; + } + if(list.orderedBy.length < level + 1) { + return 0; + } + var order = list.orderedBy[level]; + var record1ID = list._cache[resId1]; + var record2ID = list._cache[resId2]; + if (!record1ID && !record2ID) { + return 0; + } + if (!record1ID) { + return Infinity; + } + if (!record2ID) { + return -Infinity; + } + var r1 = self.localData[record1ID]; + var r2 = self.localData[record2ID]; + var data1 = _.extend({}, r1.data, r1._changes); + var data2 = _.extend({}, r2.data, r2._changes); + + // Default value to sort against: the value of the field + var orderData1 = data1[order.name]; + var orderData2 = data2[order.name]; + + // If the field is a relation, sort on the display_name of those records + if (list.fields[order.name].type === 'many2one') { + orderData1 = orderData1 ? self.localData[orderData1].data.display_name : ""; + orderData2 = orderData2 ? self.localData[orderData2].data.display_name : ""; + } + if (orderData1 < orderData2) { + return order.asc ? -1 : 1; + } + if (orderData1 > orderData2) { + return order.asc ? 1 : -1; + } + return compareRecords(resId1, resId2, level + 1); + }; + utils.stableSort(list.res_ids, compareRecords); + } + this._setDataInRange(list); + }, + /** + * Updates the res_ids of the parent of a given element of type list. + * + * After some operations (e.g. loading more records, folding/unfolding a + * group), the res_ids list of an element may be updated. When this happens, + * the res_ids of its ancestors need to be updated as well. This is the + * purpose of this function. + * + * @param {Object} element + */ + _updateParentResIDs: function (element) { + var self = this; + if (element.parentID) { + var parent = this.localData[element.parentID]; + parent.res_ids = _.flatten(_.map(parent.data, function (dataPointID) { + return self.localData[dataPointID].res_ids; + })); + this._updateParentResIDs(parent); + } + }, + /** + * Helper method to create datapoints and assign them values, then link + * those datapoints into records' data. + * + * @param {Object[]} records a list of record where datapoints will be + * assigned, it assumes _applyX2ManyOperations and _sort have been + * already called on this list + * @param {string} fieldName concerned field in records + * @param {Object[]} values typically a list of values got from a rpc + */ + _updateRecordsData: function (records, fieldName, values) { + if (!records.length || !values) { + return; + } + var self = this; + var field = records[0].fields[fieldName]; + var fieldInfo = records[0].fieldsInfo[records[0].viewType][fieldName]; + var view = fieldInfo.views && fieldInfo.views[fieldInfo.mode]; + var fieldsInfo = view ? view.fieldsInfo : fieldInfo.fieldsInfo; + var fields = view ? view.fields : fieldInfo.relatedFields; + var viewType = view ? view.type : fieldInfo.viewType; + + _.each(records, function (record) { + var x2mList = self.localData[record.data[fieldName]]; + x2mList.data = []; + _.each(x2mList.res_ids, function (res_id) { + var dataPoint = self._makeDataPoint({ + modelName: field.relation, + data: _.findWhere(values, {id: res_id}), + fields: fields, + fieldsInfo: fieldsInfo, + parentID: x2mList.id, + viewType: viewType, + }); + x2mList.data.push(dataPoint.id); + x2mList._cache[res_id] = dataPoint.id; + }); + }); + }, + /** + * Helper method. Recursively traverses the data, starting from the element + * record (or list), then following all relations. This is useful when one + * want to determine a property for the current record. + * + * For example, isDirty need to check all relations to find out if something + * has been modified, or not. + * + * Note that this method follows all the changes, so if a record has + * relational sub data, it will visit the new sub records and not the old + * ones. + * + * @param {Object} element a valid local resource + * @param {callback} fn a function to be called on each visited element + */ + _visitChildren: function (element, fn) { + var self = this; + fn(element); + if (element.type === 'record') { + for (var fieldName in element.data) { + var field = element.fields[fieldName]; + if (!field) { + continue; + } + if (_.contains(['one2many', 'many2one', 'many2many'], field.type)) { + var hasChange = element._changes && fieldName in element._changes; + var value = hasChange ? element._changes[fieldName] : element.data[fieldName]; + var relationalElement = this.localData[value]; + // relationalElement could be empty in the case of a many2one + if (relationalElement) { + self._visitChildren(relationalElement, fn); + } + } + } + } + if (element.type === 'list') { + element = this._applyX2ManyOperations(element); + _.each(element.data, function (elemId) { + var elem = self.localData[elemId]; + self._visitChildren(elem, fn); + }); + } + }, +}); + +return BasicModel; +}); diff --git a/addons/web/static/src/js/views/basic/basic_renderer.js b/addons/web/static/src/js/views/basic/basic_renderer.js new file mode 100644 index 00000000..a238ab2e --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -0,0 +1,926 @@ +odoo.define('web.BasicRenderer', function (require) { +"use strict"; + +/** + * The BasicRenderer is an abstract class designed to share code between all + * views that uses a BasicModel. The main goal is to keep track of all field + * widgets, and properly destroy them whenever a rerender is done. The widgets + * and modifiers updates mechanism is also shared in the BasicRenderer. + */ +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +const session = require('web.session'); +const utils = require('web.utils'); +var widgetRegistry = require('web.widget_registry'); + +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); +const FieldWrapper = require('web.FieldWrapper'); + +var qweb = core.qweb; +const _t = core._t; + +var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, { + custom_events: { + navigation_move: '_onNavigationMove', + }, + /** + * Basic renderers implements the concept of "mode", they can either be in + * readonly mode or editable mode. + * + * @override + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.activeActions = params.activeActions; + this.viewType = params.viewType; + this.mode = params.mode || 'readonly'; + this.widgets = []; + // This attribute lets us know if there is a handle widget on a field, + // and on which field it is set. + this.handleField = null; + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () { + this._isInDom = true; + // call on_attach_callback on field widgets + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } + // call on_attach_callback on widgets + this.widgets.forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + // call on_attach_callback on child components (including field components) + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the renderer is detached from the DOM. + */ + on_detach_callback: function () { + this._isInDom = false; + // call on_detach_callback on field widgets + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + } + // call on_detach_callback on widgets + this.widgets.forEach(widget => { + if (widget.on_detach_callback) { + widget.on_detach_callback(); + } + }); + // call on_detach_callback on child components (including field components) + WidgetAdapterMixin.on_detach_callback.call(this); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method has two responsabilities: find every invalid fields in the + * current view, and making sure that they are displayed as invalid, by + * toggling the o_form_invalid css class. It has to be done both on the + * widget, and on the label, if any. + * + * @param {string} recordID + * @returns {string[]} the list of invalid field names + */ + canBeSaved: function (recordID) { + var self = this; + var invalidFields = []; + _.each(this.allFieldWidgets[recordID], function (widget) { + var canBeSaved = self._canWidgetBeSaved(widget); + if (!canBeSaved) { + invalidFields.push(widget.name); + } + if (widget.el) { // widget may not be started yet + widget.$el.toggleClass('o_field_invalid', !canBeSaved); + widget.$el.attr('aria-invalid', !canBeSaved); + } + }); + return invalidFields; + }, + /** + * Calls 'commitChanges' on all field widgets, so that they can notify the + * environment with their current value (useful for widgets that can't + * detect when their value changes or that have to validate their changes + * before notifying them). + * + * @param {string} recordID + * @return {Promise} + */ + commitChanges: function (recordID) { + var defs = _.map(this.allFieldWidgets[recordID], function (widget) { + return widget.commitChanges(); + }); + return Promise.all(defs); + }, + /** + * Updates the internal state of the renderer to the new state. By default, + * this also implements the recomputation of the modifiers and their + * application to the DOM and the reset of the field widgets if needed. + * + * In case the given record is not found anymore, a whole re-rendering is + * completed (possible if a change in a record caused an onchange which + * erased the current record). + * + * We could always rerender the view from scratch, but then it would not be + * as efficient, and we might lose some local state, such as the input focus + * cursor, or the scrolling position. + * + * @param {Object} state + * @param {string} id + * @param {string[]} fields + * @param {OdooEvent} ev + * @returns {Promise<AbstractField[]>} resolved with the list of widgets + * that have been reset + */ + confirmChange: function (state, id, fields, ev) { + var self = this; + this._setState(state); + var record = this._getRecord(id); + if (!record) { + return this._render().then(_.constant([])); + } + + // reset all widgets (from the <widget> tag) if any: + _.invoke(this.widgets, 'updateState', state); + + var defs = []; + + // Reset all the field widgets that are marked as changed and the ones + // which are configured to always be reset on any change + _.each(this.allFieldWidgets[id], function (widget) { + var fieldChanged = _.contains(fields, widget.name); + if (fieldChanged || widget.resetOnAnyFieldChange) { + defs.push(widget.reset(record, ev, fieldChanged)); + } + }); + + // The modifiers update is done after widget resets as modifiers + // associated callbacks need to have all the widgets with the proper + // state before evaluation + defs.push(this._updateAllModifiers(record)); + + return Promise.all(defs).then(function () { + return _.filter(self.allFieldWidgets[id], function (widget) { + var fieldChanged = _.contains(fields, widget.name); + return fieldChanged || widget.resetOnAnyFieldChange; + }); + }); + }, + /** + * Activates the widget and move the cursor to the given offset + * + * @param {string} id + * @param {string} fieldName + * @param {integer} offset + */ + focusField: function (id, fieldName, offset) { + this.editRecord(id); + if (typeof offset === "number") { + var field = _.findWhere(this.allFieldWidgets[id], {name: fieldName}); + dom.setSelectionRange(field.getFocusableElement().get(0), {start: offset, end: offset}); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activates the widget at the given index for the given record if possible + * or the "next" possible one. Usually, a widget can be activated if it is + * in edit mode, and if it is visible. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {Object} [options={}] + * @param {integer} [options.inc=1] - the increment to use when searching for the + * "next" possible one + * @param {boolean} [options.noAutomaticCreate=false] + * @param {boolean} [options.wrap=false] if true, when we arrive at the end of the + * list of widget, we wrap around and try to activate widgets starting at + * the beginning. Otherwise, we just stop trying and return -1 + * @returns {integer} the index of the widget that was activated or -1 if + * none was possible to activate + */ + _activateFieldWidget: function (record, currentIndex, options) { + options = options || {}; + _.defaults(options, {inc: 1, wrap: false}); + currentIndex = Math.max(0,currentIndex); // do not allow negative currentIndex + + var recordWidgets = this.allFieldWidgets[record.id] || []; + for (var i = 0 ; i < recordWidgets.length ; i++) { + var activated = recordWidgets[currentIndex].activate( + { + event: options.event, + noAutomaticCreate: options.noAutomaticCreate || false + }); + if (activated) { + return currentIndex; + } + + currentIndex += options.inc; + if (currentIndex >= recordWidgets.length) { + if (options.wrap) { + currentIndex -= recordWidgets.length; + } else { + return -1; + } + } else if (currentIndex < 0) { + if (options.wrap) { + currentIndex += recordWidgets.length; + } else { + return -1; + } + } + } + return -1; + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the next possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @param {Object|undefined} options + * @return {integer} + */ + _activateNextFieldWidget: function (record, currentIndex, options) { + currentIndex = (currentIndex + 1) % (this.allFieldWidgets[record.id] || []).length; + return this._activateFieldWidget(record, currentIndex, _.extend({inc: 1}, options)); + }, + /** + * This is a wrapper of the {@see _activateFieldWidget} function to select + * the previous possible widget instead of the given one. + * + * @private + * @param {Object} record + * @param {integer} currentIndex + * @return {integer} + */ + _activatePreviousFieldWidget: function (record, currentIndex) { + currentIndex = currentIndex ? (currentIndex - 1) : ((this.allFieldWidgets[record.id] || []).length - 1); + return this._activateFieldWidget(record, currentIndex, {inc:-1}); + }, + /** + * Add a tooltip on a $node, depending on a field description + * + * @param {FieldWidget} widget + * @param {$node} $node + */ + _addFieldTooltip: function (widget, $node) { + // optional argument $node, the jQuery element on which the tooltip + // should be attached if not given, the tooltip is attached on the + // widget's $el + $node = $node.length ? $node : widget.$el; + $node.tooltip(this._getTooltipOptions(widget)); + }, + /** + * Does the necessary DOM updates to match the given modifiers data. The + * modifiers data is supposed to contain the properly evaluated modifiers + * associated to the given records and elements. + * + * @param {Object} modifiersData + * @param {Object} record + * @param {Object} [element] - do the update only on this element if given + */ + _applyModifiers: function (modifiersData, record, element) { + var self = this; + var modifiers = modifiersData.evaluatedModifiers[record.id] || {}; + + if (element) { + _apply(element); + } else { + // Clone is necessary as the list might change during _.each + _.each(_.clone(modifiersData.elementsByRecord[record.id]), _apply); + } + + function _apply(element) { + // If the view is in edit mode and that a widget have to switch + // its "readonly" state, we have to re-render it completely + if ('readonly' in modifiers && element.widget) { + var mode = modifiers.readonly ? 'readonly' : modifiersData.baseModeByRecord[record.id]; + if (mode !== element.widget.mode) { + self._rerenderFieldWidget(element.widget, record, { + keepBaseMode: true, + mode: mode, + }); + return; // Rerendering already applied the modifiers, no need to go further + } + } + + // Toggle modifiers CSS classes if necessary + element.$el.toggleClass("o_invisible_modifier", !!modifiers.invisible); + element.$el.toggleClass("o_readonly_modifier", !!modifiers.readonly); + element.$el.toggleClass("o_required_modifier", !!modifiers.required); + + if (element.widget && element.widget.updateModifiersValue) { + element.widget.updateModifiersValue(modifiers); + } + + // Call associated callback + if (element.callback) { + element.callback(element, modifiers, record); + } + } + }, + /** + * Determines if a given field widget value can be saved. For this to be + * true, the widget must be valid (properly parsed value) and have a value + * if the associated view field is required. + * + * @private + * @param {AbstractField} widget + * @returns {boolean|Promise<boolean>} @see AbstractField.isValid + */ + _canWidgetBeSaved: function (widget) { + var modifiers = this._getEvaluatedModifiers(widget.__node, widget.record); + return widget.isValid() && (widget.isSet() || !modifiers.required); + }, + /** + * Destroys a given widget associated to the given record and removes it + * from internal referencing. + * + * @private + * @param {string} recordID id of the local resource + * @param {AbstractField} widget + * @returns {integer} the index of the removed widget + */ + _destroyFieldWidget: function (recordID, widget) { + var recordWidgets = this.allFieldWidgets[recordID]; + var index = recordWidgets.indexOf(widget); + if (index >= 0) { + recordWidgets.splice(index, 1); + } + this._unregisterModifiersElement(widget.__node, recordID, widget); + widget.destroy(); + return index; + }, + /** + * Searches for the last evaluation of the modifiers associated to the given + * data (modifiers evaluation are supposed to always be up-to-date as soon + * as possible). + * + * @private + * @param {Object} node + * @param {Object} record + * @returns {Object} the evaluated modifiers associated to the given node + * and record (not recomputed by the call) + */ + _getEvaluatedModifiers: function (node, record) { + var element = this._getModifiersData(node); + if (!element) { + return {}; + } + return element.evaluatedModifiers[record.id] || {}; + }, + /** + * Searches through the registered modifiers data for the one which is + * related to the given node. + * + * @private + * @param {Object} node + * @returns {Object|undefined} related modifiers data if any + * undefined otherwise + */ + _getModifiersData: function (node) { + return _.findWhere(this.allModifiersData, {node: node}); + }, + /** + * This function is meant to be overridden in renderers. It takes a dataPoint + * id (for a dataPoint of type record), and should return the corresponding + * dataPoint. + * + * @abstract + * @private + * @param {string} [recordId] + * @returns {Object|null} + */ + _getRecord: function (recordId) { + return null; + }, + /** + * Get the options for the tooltip. This allow to change this options in another module. + * @param widget + * @return {{}} + * @private + */ + _getTooltipOptions: function (widget) { + return { + title: function () { + let help = widget.attrs.help || widget.field.help || ''; + if (session.display_switch_company_menu && widget.field.company_dependent) { + help += (help ? '\n\n' : '') + _t('Values set here are company-specific.'); + } + const debug = config.isDebug(); + if (help || debug) { + return qweb.render('WidgetLabel.tooltip', { debug, help, widget }); + } + } + }; + }, + /** + * @private + * @param {jQueryElement} $el + * @param {Object} node + */ + _handleAttributes: function ($el, node) { + if ($el.is('button')) { + return; + } + if (node.attrs.class) { + $el.addClass(node.attrs.class); + } + if (node.attrs.style) { + $el.attr('style', node.attrs.style); + } + if (node.attrs.placeholder) { + $el.attr('placeholder', node.attrs.placeholder); + } + }, + /** + * Used by list and kanban renderers to determine whether or not to display + * the no content helper (if there is no data in the state to display) + * + * @private + * @returns {boolean} + */ + _hasContent: function () { + return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample); + }, + /** + * Force the resequencing of the records after moving one of them to a given + * index. + * + * @private + * @param {string} recordId datapoint id of the moved record + * @param {integer} toIndex new index of the moved record + */ + _moveRecord: function (recordId, toIndex) { + var self = this; + var records = this.state.data; + var record = _.findWhere(records, {id: recordId}); + var fromIndex = records.indexOf(record); + var lowerIndex = Math.min(fromIndex, toIndex); + var upperIndex = Math.max(fromIndex, toIndex) + 1; + var order = _.findWhere(this.state.orderedBy, {name: this.handleField}); + var asc = !order || order.asc; + var reorderAll = false; + var sequence = (asc ? -1 : 1) * Infinity; + + // determine if we need to reorder all records + _.each(records, function (record, index) { + if (((index < lowerIndex || index >= upperIndex) && + ((asc && sequence >= record.data[self.handleField]) || + (!asc && sequence <= record.data[self.handleField]))) || + (index >= lowerIndex && index < upperIndex && sequence === record.data[self.handleField])) { + reorderAll = true; + } + sequence = record.data[self.handleField]; + }); + + if (reorderAll) { + records = _.without(records, record); + records.splice(toIndex, 0, record); + } else { + records = records.slice(lowerIndex, upperIndex); + records = _.without(records, record); + if (fromIndex > toIndex) { + records.unshift(record); + } else { + records.push(record); + } + } + + var sequences = _.pluck(_.pluck(records, 'data'), this.handleField); + var recordIds = _.pluck(records, 'id'); + if (!asc) { + recordIds.reverse(); + } + + this.trigger_up('resequence_records', { + handleField: this.handleField, + offset: _.min(sequences), + recordIds: recordIds, + }); + }, + /** + * This function is called each time a field widget is created, when it is + * ready (after its willStart and Start methods are complete). This is the + * place where work having to do with $el should be done. + * + * @private + * @param {Widget} widget the field widget instance + * @param {Object} node the attrs coming from the arch + */ + _postProcessField: function (widget, node) { + this._handleAttributes(widget.$el, node); + }, + /** + * Registers or updates the modifiers data associated to the given node. + * This method is quiet complex as it handles all the needs of the basic + * renderers: + * + * - On first registration, the modifiers are evaluated thanks to the given + * record. This allows nodes that will produce an AbstractField instance + * to have their modifiers registered before this field creation as we + * need the readonly modifier to be able to instantiate the AbstractField. + * + * - On additional registrations, if the node was already registered but the + * record is different, we evaluate the modifiers for this record and + * saves them in the same object (without reparsing the modifiers). + * + * - On additional registrations, the modifiers are not reparsed (or + * reevaluated for an already seen record) but the given widget or DOM + * element is associated to the node modifiers. + * + * - The new elements are immediately adapted to match the modifiers and the + * given associated callback is called even if there is no modifiers on + * the node (@see _applyModifiers). This is indeed necessary as the + * callback is a description of what to do when a modifier changes. Even + * if there is no modifiers, this action must be performed on first + * rendering to avoid code duplication. If there is no modifiers, they + * will however not be registered for modifiers updates. + * + * - When a new element is given, it does not replace the old one, it is + * added as an additional element. This is indeed useful for nodes that + * will produce multiple DOM (as a list cell and its internal widget or + * a form field and its associated label). + * (@see _unregisterModifiersElement for removing an associated element.) + * + * Note: also on view rerendering, all the modifiers are forgotten so that + * the renderer only keeps the ones associated to the current DOM state. + * + * @private + * @param {Object} node + * @param {Object} record + * @param {jQuery|AbstractField} [element] + * @param {Object} [options] + * @param {Object} [options.callback] the callback to call on registration + * and on modifiers updates + * @param {boolean} [options.keepBaseMode=false] this function registers the + * 'baseMode' of the node on a per record basis; + * this is a field widget specific settings which + * represents the generic mode of the widget, regardless of its modifiers + * (the interesting case is the list view: all widgets are supposed to be + * in the baseMode 'readonly', except the ones that are in the line that + * is currently being edited). + * With option 'keepBaseMode' set to true, the baseMode of the record's + * node isn't overridden (this is particularily useful when a field widget + * is re-rendered because its readonly modifier changed, as in this case, + * we don't want to change its base mode). + * @param {string} [options.mode] the 'baseMode' of the record's node is set to this + * value (if not given, it is set to this.mode, the mode of the renderer) + * @returns {Object} for code efficiency, returns the last evaluated + * modifiers for the given node and record. + * @throws {Error} if one of the modifier domains is not valid + */ + _registerModifiers: function (node, record, element, options) { + options = options || {}; + // Check if we already registered the modifiers for the given node + // If yes, this is simply an update of the related element + // If not, check the modifiers to see if it needs registration + var modifiersData = this._getModifiersData(node); + if (!modifiersData) { + var modifiers = node.attrs.modifiers || {}; + modifiersData = { + node: node, + modifiers: modifiers, + evaluatedModifiers: {}, + elementsByRecord: {}, + baseModeByRecord : {}, + }; + if (!_.isEmpty(modifiers)) { // Register only if modifiers might change (TODO condition might be improved here) + this.allModifiersData.push(modifiersData); + } + } + + // Compute the record's base mode + if (!modifiersData.baseModeByRecord[record.id] || !options.keepBaseMode) { + modifiersData.baseModeByRecord[record.id] = options.mode || this.mode; + } + + // Evaluate if necessary + if (!modifiersData.evaluatedModifiers[record.id]) { + try { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + } catch (e) { + throw new Error(_.str.sprintf( + "While parsing modifiers for %s%s: %s", + node.tag, node.tag === 'field' ? ' ' + node.attrs.name : '', + e.message + )); + } + } + + // Element might not be given yet (a second call to the function can + // update the registration with the element) + if (element) { + var newElement = {}; + if (element instanceof jQuery) { + newElement.$el = element; + } else { + newElement.widget = element; + newElement.$el = element.$el; + } + if (options && options.callback) { + newElement.callback = options.callback; + } + + if (!modifiersData.elementsByRecord[record.id]) { + modifiersData.elementsByRecord[record.id] = []; + } + modifiersData.elementsByRecord[record.id].push(newElement); + + this._applyModifiers(modifiersData, record, newElement); + } + + return modifiersData.evaluatedModifiers[record.id]; + }, + /** + * @override + */ + async _render() { + const oldAllFieldWidgets = this.allFieldWidgets; + this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great + this.allModifiersData = []; + const oldWidgets = this.widgets; + this.widgets = []; + + await this._super(...arguments); + + for (const id in oldAllFieldWidgets) { + for (const widget of oldAllFieldWidgets[id]) { + widget.destroy(); + } + } + for (const widget of oldWidgets) { + widget.destroy(); + } + if (this._isInDom) { + for (const handle in this.allFieldWidgets) { + this.allFieldWidgets[handle].forEach(widget => { + if (!utils.isComponent(widget.constructor) && widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + } + this.widgets.forEach(widget => { + if (widget.on_attach_callback) { + widget.on_attach_callback(); + } + }); + // call on_attach_callback on child components (including field components) + WidgetAdapterMixin.on_attach_callback.call(this); + } + }, + /** + * Instantiates the appropriate AbstractField specialization for the given + * node and prepares its rendering and addition to the DOM. Indeed, the + * rendering of the widget will be started and the associated promise will + * be added to the 'defs' attribute. This is supposed to be created and + * deleted by the calling code if necessary. + * + * Note: we always return a $el. If the field widget is asynchronous, this + * $el will be replaced by the real $el, whenever the widget is ready (start + * method is done). This means that this is not the correct place to make + * changes on the widget $el. For this, @see _postProcessField method + * + * @private + * @param {Object} node + * @param {Object} record + * @param {Object} [options] passed to @_registerModifiers + * @param {string} [options.mode] either 'edit' or 'readonly' (defaults to + * this.mode, the mode of the renderer) + * @returns {jQueryElement} + */ + _renderFieldWidget: function (node, record, options) { + options = options || {}; + var fieldName = node.attrs.name; + // Register the node-associated modifiers + var mode = options.mode || this.mode; + var modifiers = this._registerModifiers(node, record, null, options); + // Initialize and register the widget + // Readonly status is known as the modifiers have just been registered + var Widget = record.fieldsInfo[this.viewType][fieldName].Widget; + const legacy = !(Widget.prototype instanceof owl.Component); + const widgetOptions = { + mode: modifiers.readonly ? 'readonly' : mode, + viewType: this.viewType, + }; + let widget; + if (legacy) { + widget = new Widget(this, fieldName, record, widgetOptions); + } else { + widget = new FieldWrapper(this, Widget, { + fieldName, + record, + options: widgetOptions, + }); + } + + // Register the widget so that it can easily be found again + if (this.allFieldWidgets[record.id] === undefined) { + this.allFieldWidgets[record.id] = []; + } + this.allFieldWidgets[record.id].push(widget); + + widget.__node = node; // TODO get rid of this if possible one day + + // Prepare widget rendering and save the related promise + var $el = $('<div>'); + let def; + if (legacy) { + def = widget._widgetRenderAndInsert(function () {}); + } else { + def = widget.mount(document.createDocumentFragment()); + } + + this.defs.push(def); + + // Update the modifiers registration by associating the widget and by + // giving the modifiers options now (as the potential callback is + // associated to new widget) + var self = this; + def.then(function () { + // when the caller of renderFieldWidget uses something like + // this.renderFieldWidget(...).addClass(...), the class is added on + // the temporary div and not on the actual element that will be + // rendered. As we do not return a promise and some callers cannot + // wait for this.defs, we copy those classnames to the final element. + widget.$el.addClass($el.attr('class')); + + $el.replaceWith(widget.$el); + self._registerModifiers(node, record, widget, { + callback: function (element, modifiers, record) { + element.$el.toggleClass('o_field_empty', !!( + record.data.id && + (modifiers.readonly || mode === 'readonly') && + element.widget.isEmpty() + )); + }, + keepBaseMode: !!options.keepBaseMode, + mode: mode, + }); + self._postProcessField(widget, node); + }); + + return $el; + }, + /** + * Instantiate custom widgets + * + * @private + * @param {Object} record + * @param {Object} node + * @returns {jQueryElement} + */ + _renderWidget: function (record, node) { + var Widget = widgetRegistry.get(node.attrs.name); + var widget = new Widget(this, record, node); + + this.widgets.push(widget); + + // Prepare widget rendering and save the related promise + var def = widget._widgetRenderAndInsert(function () {}); + this.defs.push(def); + var $el = $('<div>'); + + var self = this; + def.then(function () { + self._handleAttributes(widget.$el, node); + self._registerModifiers(node, record, widget); + widget.$el.addClass('o_widget'); + $el.replaceWith(widget.$el); + }); + + return $el; + }, + /** + * Rerenders a given widget and make sure the associated data which + * referenced the old one is updated. + * + * @private + * @param {Widget} widget + * @param {Object} record + * @param {Object} [options] options passed to @_renderFieldWidget + */ + _rerenderFieldWidget: function (widget, record, options) { + // Render the new field widget + var $el = this._renderFieldWidget(widget.__node, record, options); + // get the new widget that has just been pushed in allFieldWidgets + const recordWidgets = this.allFieldWidgets[record.id]; + const newWidget = recordWidgets[recordWidgets.length - 1]; + const def = this.defs[this.defs.length - 1]; // this is the widget's def, resolved when it is ready + const $div = $('<div>'); + $div.append($el); // $el will be replaced when widget is ready (see _renderFieldWidget) + def.then(() => { + widget.$el.replaceWith($div.children()); + + // Destroy the old widget and position the new one at the old one's + // (it has been temporarily inserted at the end of the list) + recordWidgets.splice(recordWidgets.indexOf(newWidget), 1); + var oldIndex = this._destroyFieldWidget(record.id, widget); + recordWidgets.splice(oldIndex, 0, newWidget); + + // Mount new widget if necessary (mainly for Owl components) + if (this._isInDom && newWidget.on_attach_callback) { + newWidget.on_attach_callback(); + } + }); + }, + /** + * Unregisters an element of the modifiers data associated to the given + * node and record. + * + * @param {Object} node + * @param {string} recordID id of the local resource + * @param {jQuery|AbstractField} element + */ + _unregisterModifiersElement: function (node, recordID, element) { + var modifiersData = this._getModifiersData(node); + if (modifiersData) { + var elements = modifiersData.elementsByRecord[recordID]; + var index = _.findIndex(elements, function (oldElement) { + return oldElement.widget === element + || oldElement.$el[0] === element[0]; + }); + if (index >= 0) { + elements.splice(index, 1); + } + } + }, + /** + * Does two actions, for each registered modifiers: + * 1) Recomputes the modifiers associated to the given record and saves them + * (as boolean values) in the appropriate modifiers data. + * 2) Updates the rendering of the view elements associated to the given + * record to match the new modifiers. + * + * @see _applyModifiers + * + * @private + * @param {Object} record + * @returns {Promise} resolved once finished + */ + _updateAllModifiers: function (record) { + var self = this; + + var defs = []; + this.defs = defs; // Potentially filled by widget rerendering + _.each(this.allModifiersData, function (modifiersData) { + // `allModifiersData` might contain modifiers registered for other + // records than the given record (e.g. <groupby> in list) + if (record.id in modifiersData.evaluatedModifiers) { + modifiersData.evaluatedModifiers[record.id] = record.evalModifiers(modifiersData.modifiers); + self._applyModifiers(modifiersData, record); + } + }); + delete this.defs; + + return Promise.all(defs); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When someone presses the TAB/UP/DOWN/... key in a widget, it is nice to + * be able to navigate in the view (default browser behaviors are disabled + * by Odoo). + * + * @abstract + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) {}, +}); + +return BasicRenderer; +}); diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js new file mode 100644 index 00000000..5e3938fb --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -0,0 +1,454 @@ +odoo.define('web.BasicView', function (require) { +"use strict"; + +/** + * The BasicView is an abstract class designed to share code between views that + * want to use a basicModel. As of now, it is the form view, the list view and + * the kanban view. + * + * The main focus of this class is to process the arch and extract field + * attributes, as well as some other useful informations. + */ + +var AbstractView = require('web.AbstractView'); +var BasicController = require('web.BasicController'); +var BasicModel = require('web.BasicModel'); +var config = require('web.config'); +var fieldRegistry = require('web.field_registry'); +var fieldRegistryOwl = require('web.field_registry_owl'); +var pyUtils = require('web.py_utils'); +var utils = require('web.utils'); + +var BasicView = AbstractView.extend({ + config: _.extend({}, AbstractView.prototype.config, { + Model: BasicModel, + Controller: BasicController, + }), + viewType: undefined, + /** + * process the fields_view to find all fields appearing in the views. + * list those fields' name in this.fields_name, which will be the list + * of fields read when data is fetched. + * this.fields is the list of all field's description (the result of + * the fields_get), where the fields appearing in the fields_view are + * augmented with their attrs and some flags if they require a + * particular handling. + * + * @param {Object} viewInfo + * @param {Object} params + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + this.fieldsInfo = {}; + this.fieldsInfo[this.viewType] = this.fieldsView.fieldsInfo[this.viewType]; + + this.rendererParams.viewType = this.viewType; + + this.controllerParams.confirmOnDelete = true; + this.controllerParams.archiveEnabled = 'active' in this.fields || 'x_active' in this.fields; + this.controllerParams.hasButtons = + 'action_buttons' in params ? params.action_buttons : true; + this.controllerParams.viewId = viewInfo.view_id; + + this.loadParams.fieldsInfo = this.fieldsInfo; + this.loadParams.fields = this.fields; + this.loadParams.limit = parseInt(this.arch.attrs.limit, 10) || params.limit; + this.loadParams.parentID = params.parentID; + this.loadParams.viewType = this.viewType; + this.recordID = params.recordID; + + this.model = params.model; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the AbstractField specialization that should be used for the + * given field informations. If there is no mentioned specific widget to + * use, determines one according the field type. + * + * @private + * @param {string} viewType + * @param {Object} field + * @param {Object} attrs + * @returns {function|null} AbstractField specialization Class + */ + _getFieldWidgetClass: function (viewType, field, attrs) { + var FieldWidget; + if (attrs.widget) { + FieldWidget = fieldRegistryOwl.getAny([viewType + "." + attrs.widget, attrs.widget]) || + fieldRegistry.getAny([viewType + "." + attrs.widget, attrs.widget]); + if (!FieldWidget) { + console.warn("Missing widget: ", attrs.widget, " for field", attrs.name, "of type", field.type); + } + } else if (viewType === 'kanban' && field.type === 'many2many') { + // we want to display the widget many2manytags in kanban even if it + // is not specified in the view + FieldWidget = fieldRegistry.get('kanban.many2many_tags'); + } + return FieldWidget || + fieldRegistryOwl.getAny([viewType + "." + field.type, field.type, "abstract"]) || + fieldRegistry.getAny([viewType + "." + field.type, field.type, "abstract"]); + }, + /** + * In some cases, we already have a preloaded record + * + * @override + * @private + * @returns {Promise} + */ + _loadData: async function (model) { + if (this.recordID) { + // Add the fieldsInfo of the current view to the given recordID, + // as it will be shared between two views, and it must be able to + // handle changes on fields that are only on this view. + await model.addFieldsInfo(this.recordID, { + fields: this.fields, + fieldInfo: this.fieldsInfo[this.viewType], + viewType: this.viewType, + }); + + var record = model.get(this.recordID); + var viewType = this.viewType; + var viewFields = Object.keys(record.fieldsInfo[viewType]); + var fieldNames = _.difference(viewFields, Object.keys(record.data)); + var fieldsInfo = record.fieldsInfo[viewType]; + + // Suppose that in a form view, there is an x2many list view with + // a field F, and that F is also displayed in the x2many form view. + // In this case, F is represented in record.data (as it is known by + // the x2many list view), but the loaded information may not suffice + // in the form view (e.g. if field is a many2many list in the form + // view, or if it is displayed by a widget requiring specialData). + // So when this happens, F is added to the list of fieldNames to fetch. + _.each(viewFields, (name) => { + if (!_.contains(fieldNames, name)) { + var fieldType = record.fields[name].type; + var fieldInfo = fieldsInfo[name]; + + // SpecialData case: field requires specialData that haven't + // been fetched yet. + if (fieldInfo.Widget) { + var requiresSpecialData = fieldInfo.Widget.prototype.specialData; + if (requiresSpecialData && !(name in record.specialData)) { + fieldNames.push(name); + return; + } + } + + // X2Many case: field is an x2many displayed as a list or + // kanban view, but the related fields haven't been loaded yet. + if ((fieldType === 'one2many' || fieldType === 'many2many')) { + if (!('fieldsInfo' in record.data[name])) { + fieldNames.push(name); + } else { + var x2mFieldInfo = record.fieldsInfo[this.viewType][name]; + var viewType = x2mFieldInfo.viewType || x2mFieldInfo.mode; + var knownFields = Object.keys(record.data[name].fieldsInfo[record.data[name].viewType] || {}); + var newFields = Object.keys(record.data[name].fieldsInfo[viewType] || {}); + if (_.difference(newFields, knownFields).length) { + fieldNames.push(name); + } + + if (record.data[name].viewType === 'default') { + // Use case: x2many (tags) in x2many list views + // When opening the x2many record form view, the + // x2many will be reloaded but it may not have + // the same fields (ex: tags in list and list in + // form) so we need to merge the fieldsInfo to + // avoid losing the initial fields (display_name) + var fieldViews = fieldInfo.views || fieldInfo.fieldsInfo || {}; + var defaultFieldInfo = record.data[name].fieldsInfo.default; + _.each(fieldViews, function (fieldView) { + _.each(fieldView.fieldsInfo, function (x2mFieldInfo) { + _.defaults(x2mFieldInfo, defaultFieldInfo); + }); + }); + } + } + } + // Many2one: context is not the same between the different views + // this means the result of a name_get could differ + if (fieldType === 'many2one') { + if (JSON.stringify(record.data[name].context) !== + JSON.stringify(fieldInfo.context)) { + fieldNames.push(name); + } + } + } + }); + + var def; + if (fieldNames.length) { + if (model.isNew(record.id)) { + def = model.generateDefaultValues(record.id, { + fieldNames: fieldNames, + viewType: viewType, + }); + } else { + def = model.reload(record.id, { + fieldNames: fieldNames, + keepChanges: true, + viewType: viewType, + }); + } + } + return Promise.resolve(def).then(function () { + const handle = record.id; + return { state: model.get(handle), handle }; + }); + } + return this._super.apply(this, arguments); + }, + /** + * Traverses the arch and calls '_processNode' on each of its nodes. + * + * @private + * @param {Object} arch a parsed arch + * @param {Object} fv the fieldsView Object, in which _processNode can + * access and add information (like the fields' attributes in the arch) + */ + _processArch: function (arch, fv) { + var self = this; + utils.traverse(arch, function (node) { + return self._processNode(node, fv); + }); + }, + /** + * Processes a field node, in particular, put a flag on the field to give + * special directives to the BasicModel. + * + * @private + * @param {string} viewType + * @param {Object} field - the field properties + * @param {Object} attrs - the field attributes (from the xml) + * @returns {Object} attrs + */ + _processField: function (viewType, field, attrs) { + var self = this; + attrs.Widget = this._getFieldWidgetClass(viewType, field, attrs); + + // process decoration attributes + _.each(attrs, function (value, key) { + if (key.startsWith('decoration-')) { + attrs.decorations = attrs.decorations || []; + attrs.decorations.push({ + name: key, + expression: pyUtils._getPyJSAST(value), + }); + } + }); + + if (!_.isObject(attrs.options)) { // parent arch could have already been processed (TODO this should not happen) + attrs.options = attrs.options ? pyUtils.py_eval(attrs.options) : {}; + } + + if (attrs.on_change && attrs.on_change !== "0" && !field.onChange) { + field.onChange = "1"; + } + + // the relational data of invisible relational fields should not be + // fetched (e.g. name_gets of invisible many2ones), at least those that + // are always invisible. + // the invisible attribute of a field is supposed to be static ("1" in + // general), but not totally as it may use keys of the context + // ("context.get('some_key')"). It is evaluated server-side, and the + // result is put inside the modifiers as a value of the '(column_)invisible' + // key, and the raw value is left in the invisible attribute (it is used + // in debug mode for informational purposes). + // this should change, for instance the server might set the evaluated + // value in invisible, which could then be seen as static by the client, + // and add another key in debug mode containing the raw value. + // for now, we look inside the modifiers and consider the value only if + // it is static (=== true), + if (attrs.modifiers.invisible === true || attrs.modifiers.column_invisible === true) { + attrs.__no_fetch = true; + } + + if (!_.isEmpty(field.views)) { + // process the inner fields_view as well to find the fields they use. + // register those fields' description directly on the view. + // for those inner views, the list of all fields isn't necessary, so + // basically the field_names will be the keys of the fields obj. + // don't use _ to iterate on fields in case there is a 'length' field, + // as _ doesn't behave correctly when there is a length key in the object + attrs.views = {}; + _.each(field.views, function (innerFieldsView, viewType) { + viewType = viewType === 'tree' ? 'list' : viewType; + attrs.views[viewType] = self._processFieldsView(innerFieldsView, viewType); + }); + } + + attrs.views = attrs.views || {}; + + // Keep compatibility with 'tree' syntax + attrs.mode = attrs.mode === 'tree' ? 'list' : attrs.mode; + if (!attrs.views.list && attrs.views.tree) { + attrs.views.list = attrs.views.tree; + } + + if (field.type === 'one2many' || field.type === 'many2many') { + if (attrs.Widget.prototype.useSubview) { + var mode = attrs.mode; + if (!mode) { + if (attrs.views.list && !attrs.views.kanban) { + mode = 'list'; + } else if (!attrs.views.list && attrs.views.kanban) { + mode = 'kanban'; + } else { + mode = 'list,kanban'; + } + } + if (mode.indexOf(',') !== -1) { + mode = config.device.isMobile ? 'kanban' : 'list'; + } + attrs.mode = mode; + if (mode in attrs.views) { + var view = attrs.views[mode]; + this._processSubViewAttrs(view, attrs); + } + } + if (attrs.Widget.prototype.fieldsToFetch) { + attrs.viewType = 'default'; + attrs.relatedFields = _.extend({}, attrs.Widget.prototype.fieldsToFetch); + attrs.fieldsInfo = { + default: _.mapObject(attrs.Widget.prototype.fieldsToFetch, function () { + return {}; + }), + }; + if (attrs.options.color_field) { + // used by m2m tags + attrs.relatedFields[attrs.options.color_field] = { type: 'integer' }; + attrs.fieldsInfo.default[attrs.options.color_field] = {}; + } + } + } + + if (attrs.Widget.prototype.fieldDependencies) { + attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies; + } + + return attrs; + }, + /** + * Overrides to process the fields, and generate fieldsInfo which contains + * the description of the fields in view, with their attrs in the arch. + * + * @override + * @private + * @param {Object} fieldsView + * @param {string} fieldsView.arch + * @param {Object} fieldsView.fields + * @param {string} [viewType] by default, this.viewType + * @returns {Object} the processed fieldsView with extra key 'fieldsInfo' + */ + _processFieldsView: function (fieldsView, viewType) { + var fv = this._super.apply(this, arguments); + + viewType = viewType || this.viewType; + fv.type = viewType; + fv.fieldsInfo = Object.create(null); + fv.fieldsInfo[viewType] = Object.create(null); + + this._processArch(fv.arch, fv); + + return fv; + }, + /** + * Processes a node of the arch (mainly nodes with tagname 'field'). Can + * be overridden to handle other tagnames. + * + * @private + * @param {Object} node + * @param {Object} fv the fieldsView + * @param {Object} fv.fieldsInfo + * @param {Object} fv.fieldsInfo[viewType] fieldsInfo of the current viewType + * @param {Object} fv.viewFields the result of a fields_get extend with the + * fields returned with the fields_view_get for the current viewType + * @param {string} fv.viewType + * @returns {boolean} false iff subnodes must not be visited. + */ + _processNode: function (node, fv) { + if (typeof node === 'string') { + return false; + } + if (!_.isObject(node.attrs.modifiers)) { + node.attrs.modifiers = node.attrs.modifiers ? JSON.parse(node.attrs.modifiers) : {}; + } + if (!_.isObject(node.attrs.options) && node.tag === 'button') { + node.attrs.options = node.attrs.options ? JSON.parse(node.attrs.options) : {}; + } + if (node.tag === 'field') { + var viewType = fv.type; + var fieldsInfo = fv.fieldsInfo[viewType]; + var fields = fv.viewFields; + fieldsInfo[node.attrs.name] = this._processField(viewType, + fields[node.attrs.name], node.attrs ? _.clone(node.attrs) : {}); + + if (fieldsInfo[node.attrs.name].fieldDependencies) { + var deps = fieldsInfo[node.attrs.name].fieldDependencies; + for (var dependency_name in deps) { + var dependency_dict = {name: dependency_name, type: deps[dependency_name].type}; + if (!(dependency_name in fieldsInfo)) { + fieldsInfo[dependency_name] = _.extend({}, dependency_dict, { + options: deps[dependency_name].options || {}, + }); + } + if (!(dependency_name in fields)) { + fields[dependency_name] = dependency_dict; + } + + if (fv.fields && !(dependency_name in fv.fields)) { + fv.fields[dependency_name] = dependency_dict; + } + } + } + return false; + } + return node.tag !== 'arch'; + }, + /** + * Processes in place the subview attributes (in particular, + * `default_order``and `column_invisible`). + * + * @private + * @param {Object} view - the field subview + * @param {Object} attrs - the field attributes (from the xml) + */ + _processSubViewAttrs: function (view, attrs) { + var defaultOrder = view.arch.attrs.default_order; + if (defaultOrder) { + // process the default_order, which is like 'name,id desc' + // but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}] + attrs.orderedBy = _.map(defaultOrder.split(','), function (order) { + order = order.trim().split(' '); + return {name: order[0], asc: order[1] !== 'desc'}; + }); + } else { + // if there is a field with widget `handle`, the x2many + // needs to be ordered by this field to correctly display + // the records + var handleField = _.find(view.arch.children, function (child) { + return child.attrs && child.attrs.widget === 'handle'; + }); + if (handleField) { + attrs.orderedBy = [{name: handleField.attrs.name, asc: true}]; + } + } + + attrs.columnInvisibleFields = {}; + _.each(view.arch.children, function (child) { + if (child.attrs && child.attrs.modifiers) { + attrs.columnInvisibleFields[child.attrs.name] = + child.attrs.modifiers.column_invisible || false; + } + }); + }, +}); + +return BasicView; + +}); diff --git a/addons/web/static/src/js/views/basic/widget_registry.js b/addons/web/static/src/js/views/basic/widget_registry.js new file mode 100644 index 00000000..470127bd --- /dev/null +++ b/addons/web/static/src/js/views/basic/widget_registry.js @@ -0,0 +1,27 @@ +odoo.define('web.widget_registry', function (require) { + "use strict"; + + // This registry is supposed to contain all custom widgets that will be + // available in the basic views, with the tag <widget/>. There are + // currently no such widget in the web client, but the functionality is + // certainly useful to be able to cleanly add custom behaviour in basic + // views (and most notably, the form view) + // + // The way custom widgets work is that they register themselves to this + // registry: + // + // widgetRegistry.add('some_name', MyWidget); + // + // Then, they are available with the <widget/> tag (in the arch): + // + // <widget name="some_name"/> + // + // Widgets will be then properly instantiated, rendered and destroyed at the + // appropriate time, with the current state in second argument. + // + // For more examples, look at the tests (grep '<widget' in the test folder) + + var Registry = require('web.Registry'); + + return new Registry(); +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js new file mode 100644 index 00000000..0ab74b34 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -0,0 +1,477 @@ +odoo.define('web.CalendarController', function (require) { +"use strict"; + +/** + * Calendar Controller + * + * This is the controller in the Model-Renderer-Controller architecture of the + * calendar view. Its role is to coordinate the data from the calendar model + * with the renderer, and with the outside world (such as a search view input) + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); +var QuickCreate = require('web.CalendarQuickCreate'); + +var _t = core._t; +var QWeb = core.qweb; + +function dateToServer (date, fieldType) { + date = date.clone().locale('en'); + if (fieldType === "date") { + return date.local().format('YYYY-MM-DD'); + } + return date.utc().format('YYYY-MM-DD HH:mm:ss'); +} + +var CalendarController = AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + changeDate: '_onChangeDate', + changeFilter: '_onChangeFilter', + deleteRecord: '_onDeleteRecord', + dropRecord: '_onDropRecord', + next: '_onNext', + openCreate: '_onOpenCreate', + openEvent: '_onOpenEvent', + prev: '_onPrev', + quickCreate: '_onQuickCreate', + updateRecord: '_onUpdateRecord', + viewUpdated: '_onViewUpdated', + }), + events: _.extend({}, AbstractController.prototype.events, { + 'click button.o_calendar_button_new': '_onButtonNew', + 'click button.o_calendar_button_prev': '_onButtonNavigation', + 'click button.o_calendar_button_today': '_onButtonNavigation', + 'click button.o_calendar_button_next': '_onButtonNavigation', + 'click button.o_calendar_button_day': '_onButtonScale', + 'click button.o_calendar_button_week': '_onButtonScale', + 'click button.o_calendar_button_month': '_onButtonScale', + 'click button.o_calendar_button_year': '_onButtonScale', + }), + /** + * @override + * @param {Widget} parent + * @param {AbstractModel} model + * @param {AbstractRenderer} renderer + * @param {Object} params + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.current_start = null; + this.displayName = params.displayName; + this.quickAddPop = params.quickAddPop; + this.disableQuickCreate = params.disableQuickCreate; + this.eventOpenPopup = params.eventOpenPopup; + this.showUnusualDays = params.showUnusualDays; + this.formViewId = params.formViewId; + this.readonlyFormViewId = params.readonlyFormViewId; + this.mapping = params.mapping; + this.context = params.context; + this.previousOpen = null; + // The quickCreating attribute ensures that we don't do several create + this.quickCreating = false; + this.scales = params.scales; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Render the buttons according to the CalendarView.buttons template and + * add listeners on it. Set this.$buttons with the produced jQuery element + * + * @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 + */ + renderButtons: function ($node) { + this.$buttons = $(QWeb.render('CalendarView.buttons', this._renderButtonsParameters())); + + this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); + + if ($node) { + this.$buttons.appendTo($node); + } else { + this.$('.o_calendar_buttons').replaceWith(this.$buttons); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Find a className in an array using the start of this class and + * return the last part of a string + * @private + * @param {string} startClassName start of string to find in the "array" + * @param {array|DOMTokenList} classList array of all class + * @return {string|undefined} + */ + _extractLastPartOfClassName(startClassName, classList) { + var result; + classList.forEach(function (value) { + if (value && value.indexOf(startClassName) === 0) { + result = value.substring(startClassName.length); + } + }); + return result; + }, + /** + * Move to the requested direction and reload the view + * + * @private + * @param {string} to either 'prev', 'next' or 'today' + * @returns {Promise} + */ + _move: function (to) { + this.model[to](); + return this.reload(); + }, + /** + * Parameter send to QWeb to render the template of Buttons + * + * @private + * @return {{}} + */ + _renderButtonsParameters() { + return { + scales: this.scales, + }; + }, + /** + * @override + * @private + */ + _update: function () { + var self = this; + if (!this.showUnusualDays) { + return this._super.apply(this, arguments); + } + return this._super.apply(this, arguments).then(function () { + self._rpc({ + model: self.modelName, + method: 'get_unusual_days', + args: [dateToServer(self.model.data.start_date, 'date'), dateToServer(self.model.data.end_date, 'date')], + context: self.context, + }).then(function (data) { + _.each(self.$el.find('td.fc-day'), function (td) { + var $td = $(td); + if (data[$td.data('date')]) { + $td.addClass('o_calendar_disabled'); + } + }); + }); + }); + }, + /** + * @private + * @param {Object} record + * @param {integer} record.id + * @returns {Promise} + */ + _updateRecord: function (record) { + var reload = this.reload.bind(this, {}); + return this.model.updateRecord(record).then(reload, reload); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handler when a user clicks on button to create event + * + * @private + */ + _onButtonNew() { + this.trigger_up('switch_view', {view_type: 'form'}); + }, + /** + * Handler when a user click on navigation button like prev, next, ... + * + * @private + * @param {Event|jQueryEvent} jsEvent + */ + _onButtonNavigation(jsEvent) { + const action = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList); + if (action) { + this._move(action); + } + }, + /** + * Handler when a user click on scale button like day, month, ... + * + * @private + * @param {Event|jQueryEvent} jsEvent + */ + _onButtonScale(jsEvent) { + const scale = this._extractLastPartOfClassName('o_calendar_button_', jsEvent.currentTarget.classList); + if (scale) { + this.model.setScale(scale); + this.reload(); + } + }, + + /** + * @private + * @param {OdooEvent} event + */ + _onChangeDate: function (event) { + var modelData = this.model.get(); + if (modelData.target_date.format('YYYY-MM-DD') === event.data.date.format('YYYY-MM-DD')) { + // When clicking on same date, toggle between the two views + switch (modelData.scale) { + case 'month': this.model.setScale('week'); break; + case 'week': this.model.setScale('day'); break; + case 'day': this.model.setScale('month'); break; + } + } else if (modelData.target_date.week() === event.data.date.week()) { + // When clicking on a date in the same week, switch to day view + this.model.setScale('day'); + } else { + // When clicking on a random day of a random other week, switch to week view + this.model.setScale('week'); + } + this.model.setDate(event.data.date); + this.reload(); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onChangeFilter: function (event) { + if (this.model.changeFilter(event.data) && !event.data.no_reload) { + this.reload(); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onDeleteRecord: function (event) { + var self = this; + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: function () { + self.model.deleteRecords([event.data.id], self.modelName).then(function () { + self.reload(); + }); + } + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onDropRecord: function (event) { + this._updateRecord(_.extend({}, event.data, { + 'drop': true, + })); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onNext: function (event) { + event.stopPropagation(); + this._move('next'); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onOpenCreate: function (event) { + var self = this; + if (["year", "month"].includes(this.model.get().scale)) { + event.data.allDay = true; + } + var data = this.model.calendarEventToRecord(event.data); + + var context = _.extend({}, this.context, event.options && event.options.context); + // context default has more priority in default_get so if data.name is false then it may + // lead to error/warning while saving record in form view as name field can be required + if (data.name) { + context.default_name = data.name; + } + context['default_' + this.mapping.date_start] = data[this.mapping.date_start] || null; + if (this.mapping.date_stop) { + context['default_' + this.mapping.date_stop] = data[this.mapping.date_stop] || null; + } + if (this.mapping.date_delay) { + context['default_' + this.mapping.date_delay] = data[this.mapping.date_delay] || null; + } + if (this.mapping.all_day) { + context['default_' + this.mapping.all_day] = data[this.mapping.all_day] || null; + } + + for (var k in context) { + if (context[k] && context[k]._isAMomentObject) { + context[k] = dateToServer(context[k]); + } + } + + var options = _.extend({}, this.options, event.options, { + context: context, + title: _.str.sprintf(_t('Create: %s'), (this.displayName || this.renderer.arch.attrs.string)) + }); + + if (this.quick != null) { + this.quick.destroy(); + this.quick = null; + } + + if (!options.disableQuickCreate && !event.data.disableQuickCreate && this.quickAddPop) { + this.quick = new QuickCreate(this, true, options, data, event.data); + this.quick.open(); + this.quick.opened(function () { + self.quick.focus(); + }); + return; + } + + var title = _t("Create"); + if (this.renderer.arch.attrs.string) { + title += ': ' + this.renderer.arch.attrs.string; + } + if (this.eventOpenPopup) { + if (this.previousOpen) { this.previousOpen.close(); } + this.previousOpen = new dialogs.FormViewDialog(self, { + res_model: this.modelName, + context: context, + title: title, + view_id: this.formViewId || false, + disable_multiple_selection: true, + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }); + this.previousOpen.open(); + } else { + this.do_action({ + type: 'ir.actions.act_window', + res_model: this.modelName, + views: [[this.formViewId || false, 'form']], + target: 'current', + context: context, + }); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onOpenEvent: function (event) { + var self = this; + var id = event.data._id; + id = id && parseInt(id).toString() === id ? parseInt(id) : id; + + if (!this.eventOpenPopup) { + this._rpc({ + model: self.modelName, + method: 'get_formview_id', + //The event can be called by a view that can have another context than the default one. + args: [[id]], + context: event.context || self.context, + }).then(function (viewId) { + self.do_action({ + type:'ir.actions.act_window', + res_id: id, + res_model: self.modelName, + views: [[viewId || false, 'form']], + target: 'current', + context: event.context || self.context, + }); + }); + return; + } + + var options = { + res_model: self.modelName, + res_id: id || null, + context: event.context || self.context, + title: _t("Open: ") + _.escape(event.data.title), + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }; + if (this.formViewId) { + options.view_id = parseInt(this.formViewId); + } + new dialogs.FormViewDialog(this, options).open(); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onPrev: function () { + event.stopPropagation(); + this._move('prev'); + }, + + /** + * Handles saving data coming from quick create box + * + * @private + * @param {OdooEvent} event + */ + _onQuickCreate: function (event) { + var self = this; + if (this.quickCreating) { + return; + } + this.quickCreating = true; + this.model.createRecord(event) + .then(function () { + self.quick.destroy(); + self.quick = null; + self.reload(); + self.quickCreating = false; + }) + .guardedCatch(function (result) { + var errorEvent = result.event; + // This will occurs if there are some more fields required + // Preventdefaulting the error event will prevent the traceback window + errorEvent.preventDefault(); + event.data.options.disableQuickCreate = true; + event.data.data.on_save = self.quick.destroy.bind(self.quick); + self._onOpenCreate(event.data); + self.quickCreating = false; + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onUpdateRecord: function (event) { + this._updateRecord(event.data); + }, + /** + * The internal state of the calendar (mode, period displayed) has changed, + * so update the control panel buttons and breadcrumbs accordingly. + * + * @private + * @param {OdooEvent} event + */ + _onViewUpdated: function (event) { + this.mode = event.data.mode; + if (this.$buttons) { + this.$buttons.find('.active').removeClass('active'); + this.$buttons.find('.o_calendar_button_' + this.mode).addClass('active'); + } + const title = `${this.displayName} (${event.data.title})`; + return this.updateControlPanel({ title }); + }, +}); + +return CalendarController; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js new file mode 100644 index 00000000..93999bee --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -0,0 +1,777 @@ +odoo.define('web.CalendarModel', function (require) { +"use strict"; + +var AbstractModel = require('web.AbstractModel'); +var Context = require('web.Context'); +var core = require('web.core'); +var fieldUtils = require('web.field_utils'); +var session = require('web.session'); + +var _t = core._t; + +function dateToServer (date) { + return date.clone().utc().locale('en').format('YYYY-MM-DD HH:mm:ss'); +} + +return AbstractModel.extend({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.end_date = null; + var week_start = _t.database.parameters.week_start; + // calendar uses index 0 for Sunday but Odoo stores it as 7 + this.week_start = week_start !== undefined && week_start !== false ? week_start % 7 : moment().startOf('week').day(); + this.week_stop = this.week_start + 6; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Transform fullcalendar event object to OpenERP Data object + */ + calendarEventToRecord: function (event) { + // Normalize event_end without changing fullcalendars event. + var data = {'name': event.title}; + var start = event.start.clone(); + var end = event.end && event.end.clone(); + + // Set end date if not existing + if (!end || end.diff(start) < 0) { // undefined or invalid end date + if (event.allDay) { + end = start.clone(); + } else { + // in week mode or day mode, convert allday event to event + end = start.clone().add(2, 'h'); + } + } else if (event.allDay) { + // For an "allDay", FullCalendar gives the end day as the + // next day at midnight (instead of 23h59). + end.add(-1, 'days'); + } + + var isDateEvent = this.fields[this.mapping.date_start].type === 'date'; + // An "allDay" event without the "all_day" option is not considered + // as a 24h day. It's just a part of the day (by default: 7h-19h). + if (event.allDay) { + if (!this.mapping.all_day && !isDateEvent) { + if (event.r_start) { + start.hours(event.r_start.hours()) + .minutes(event.r_start.minutes()) + .seconds(event.r_start.seconds()) + .utc(); + end.hours(event.r_end.hours()) + .minutes(event.r_end.minutes()) + .seconds(event.r_end.seconds()) + .utc(); + } else { + // default hours in the user's timezone + start.hours(7); + end.hours(19); + } + start.add(-this.getSession().getTZOffset(start), 'minutes'); + end.add(-this.getSession().getTZOffset(end), 'minutes'); + } + } else { + start.add(-this.getSession().getTZOffset(start), 'minutes'); + end.add(-this.getSession().getTZOffset(end), 'minutes'); + } + + if (this.mapping.all_day) { + if (event.record) { + data[this.mapping.all_day] = + (this.data.scale !== 'month' && event.allDay) || + event.record[this.mapping.all_day] && + end.diff(start) < 10 || + false; + } else { + data[this.mapping.all_day] = event.allDay; + } + } + + data[this.mapping.date_start] = start; + if (this.mapping.date_stop) { + data[this.mapping.date_stop] = end; + } + + if (this.mapping.date_delay) { + if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } + } + + return data; + }, + /** + * @param {Object} filter + * @returns {boolean} + */ + changeFilter: function (filter) { + var Filter = this.data.filters[filter.fieldName]; + if (filter.value === 'all') { + Filter.all = filter.active; + } + var f = _.find(Filter.filters, function (f) { + return f.value === filter.value; + }); + if (f) { + if (f.active !== filter.active) { + f.active = filter.active; + } else { + return false; + } + } else if (filter.active) { + Filter.filters.push({ + value: filter.value, + active: true, + }); + } + return true; + }, + /** + * @param {OdooEvent} event + */ + createRecord: function (event) { + var data = this.calendarEventToRecord(event.data.data); + for (var k in data) { + if (data[k] && data[k]._isAMomentObject) { + data[k] = dateToServer(data[k]); + } + } + return this._rpc({ + model: this.modelName, + method: 'create', + args: [data], + context: event.data.options.context, + }); + }, + /** + * @todo I think this is dead code + * + * @param {any} ids + * @param {any} model + * @returns + */ + deleteRecords: function (ids, model) { + return this._rpc({ + model: model, + method: 'unlink', + args: [ids], + context: session.user_context, // todo: combine with view context + }); + }, + /** + * @override + * @returns {Object} + */ + __get: function () { + return _.extend({}, this.data, { + fields: this.fields + }); + }, + /** + * @override + * @param {any} params + * @returns {Promise} + */ + __load: function (params) { + var self = this; + this.modelName = params.modelName; + this.fields = params.fields; + this.fieldNames = params.fieldNames; + this.fieldsInfo = params.fieldsInfo; + this.mapping = params.mapping; + this.mode = params.mode; // one of month, week or day + this.scales = params.scales; // one of month, week or day + this.scalesInfo = params.scalesInfo; + + // Check whether the date field is editable (i.e. if the events can be + // dragged and dropped) + this.editable = params.editable; + this.creatable = params.creatable; + + // display more button when there are too much event on one day + this.eventLimit = params.eventLimit; + + // fields to display color, e.g.: user_id.partner_id + this.fieldColor = params.fieldColor; + if (!this.preloadPromise) { + this.preloadPromise = new Promise(function (resolve, reject) { + Promise.all([ + self._rpc({model: self.modelName, method: 'check_access_rights', args: ["write", false]}), + self._rpc({model: self.modelName, method: 'check_access_rights', args: ["create", false]}) + ]).then(function (result) { + var write = result[0]; + var create = result[1]; + self.write_right = write; + self.create_right = create; + resolve(); + }).guardedCatch(reject); + }); + } + + this.data = { + domain: params.domain, + context: params.context, + // get in arch the filter to display in the sidebar and the field to read + filters: params.filters, + }; + + this.setDate(params.initialDate); + // Use mode attribute in xml file to specify zoom timeline (day,week,month) + // by default month. + this.setScale(params.mode); + + _.each(this.data.filters, function (filter) { + if (filter.avatar_field && !filter.avatar_model) { + filter.avatar_model = self.modelName; + } + }); + + return this.preloadPromise.then(this._loadCalendar.bind(this)); + }, + /** + * Move the current date range to the next period + */ + next: function () { + this.setDate(this.data.target_date.clone().add(1, this.data.scale)); + }, + /** + * Move the current date range to the previous period + */ + prev: function () { + this.setDate(this.data.target_date.clone().add(-1, this.data.scale)); + }, + /** + * @override + * @param {Object} [params.context] + * @param {Array} [params.domain] + * @returns {Promise} + */ + __reload: function (handle, params) { + if (params.domain) { + this.data.domain = params.domain; + } + if (params.context) { + this.data.context = params.context; + } + return this._loadCalendar(); + }, + /** + * @param {Moment} start. in local TZ + */ + setDate: function (start) { + // keep highlight/target_date in localtime + this.data.highlight_date = this.data.target_date = start.clone(); + this.data.start_date = this.data.end_date = start; + switch (this.data.scale) { + case 'year': { + const yearStart = this.data.start_date.clone().startOf('year'); + let yearStartDay = this.week_start; + if (yearStart.day() < yearStartDay) { + // the 1st of January is before our week start (e.g. week start is Monday, and + // 01/01 is Sunday), so we go one week back + yearStartDay -= 7; + } + this.data.start_date = yearStart.day(yearStartDay).startOf('day'); + this.data.end_date = this.data.end_date.clone() + .endOf('year').day(this.week_stop).endOf('day'); + break; + } + case 'month': + var monthStart = this.data.start_date.clone().startOf('month'); + + var monthStartDay; + if (monthStart.day() >= this.week_start) { + // the month's first day is after our week start + // Then we are in the right week + monthStartDay = this.week_start; + } else { + // The month's first day is before our week start + // Then we should go back to the the previous week + monthStartDay = this.week_start - 7; + } + + this.data.start_date = monthStart.day(monthStartDay).startOf('day'); + this.data.end_date = this.data.start_date.clone().add(5, 'week').day(this.week_stop).endOf('day'); + break; + case 'week': + var weekStart = this.data.start_date.clone().startOf('week'); + var weekStartDay = this.week_start; + if (this.data.start_date.day() < this.week_start) { + // The week's first day is after our current day + // Then we should go back to the previous week + weekStartDay -= 7; + } + this.data.start_date = this.data.start_date.clone().day(weekStartDay).startOf('day'); + this.data.end_date = this.data.end_date.clone().day(weekStartDay + 6).endOf('day'); + break; + default: + this.data.start_date = this.data.start_date.clone().startOf('day'); + this.data.end_date = this.data.end_date.clone().endOf('day'); + } + // We have set start/stop datetime as definite begin/end boundaries of a period (month, week, day) + // in local TZ (what is the begining of the week *I am* in ?) + // The following code: + // - converts those to UTC using our homemade method (testable) + // - sets the moment UTC flag to true, to ensure compatibility with third party libs + var manualUtcDateStart = this.data.start_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes'); + var formattedUtcDateStart = manualUtcDateStart.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; + this.data.start_date = moment.utc(formattedUtcDateStart); + + var manualUtcDateEnd = this.data.end_date.clone().add(-this.getSession().getTZOffset(this.data.start_date), 'minutes'); + var formattedUtcDateEnd = manualUtcDateEnd.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; + this.data.end_date = moment.utc(formattedUtcDateEnd); + }, + /** + * @param {string} scale the scale to set + */ + setScale: function (scale) { + if (!_.contains(this.scales, scale)) { + scale = "week"; + } + this.data.scale = scale; + this.setDate(this.data.target_date); + }, + /** + * Move the current date range to the period containing today + */ + today: function () { + this.setDate(moment(new Date())); + }, + /** + * @param {Object} record + * @param {integer} record.id + * @returns {Promise} + */ + updateRecord: function (record) { + // Cannot modify actual name yet + var data = _.omit(this.calendarEventToRecord(record), 'name'); + for (var k in data) { + if (data[k] && data[k]._isAMomentObject) { + data[k] = dateToServer(data[k]); + } + } + var context = new Context(this.data.context, {from_ui: true}); + return this._rpc({ + model: this.modelName, + method: 'write', + args: [[parseInt(record.id, 10)], data], + context: context + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Converts this.data.filters into a domain + * + * @private + * @returns {Array} + */ + _getFilterDomain: function () { + // List authorized values for every field + // fields with an active 'all' filter are skipped + var authorizedValues = {}; + var avoidValues = {}; + + _.each(this.data.filters, function (filter) { + // Skip 'all' filters because they do not affect the domain + if (filter.all) return; + + // Loop over subfilters to complete authorizedValues + _.each(filter.filters, function (f) { + if (filter.write_model) { + if (!authorizedValues[filter.fieldName]) + authorizedValues[filter.fieldName] = []; + + if (f.active) { + authorizedValues[filter.fieldName].push(f.value); + } + } else { + if (!f.active) { + if (!avoidValues[filter.fieldName]) + avoidValues[filter.fieldName] = []; + + avoidValues[filter.fieldName].push(f.value); + } + } + }); + }); + + // Compute the domain + var domain = []; + for (var field in authorizedValues) { + domain.push([field, 'in', authorizedValues[field]]); + } + for (var field in avoidValues) { + if (avoidValues[field].length > 0) { + domain.push([field, 'not in', avoidValues[field]]); + } + } + + return domain; + }, + /** + * @private + * @returns {Object} + */ + _getFullCalendarOptions: function () { + var format12Hour = { + hour: 'numeric', + minute: '2-digit', + omitZeroMinute: true, + meridiem: 'short' + }; + var format24Hour = { + hour: 'numeric', + minute: '2-digit', + hour12: false, + }; + return { + defaultView: this.scalesInfo[this.mode || 'week'], + header: false, + selectable: this.creatable && this.create_right, + selectMirror: true, + editable: this.editable, + droppable: true, + navLinks: false, + eventLimit: this.eventLimit, // allow "more" link when too many events + snapMinutes: 15, + longPressDelay: 500, + eventResizableFromStart: true, + nowIndicator: true, + weekNumbers: true, + weekNumbersWithinDays: true, + weekNumberCalculation: function (date) { + // Since FullCalendar v4 ISO 8601 week date is preferred so we force the old system + return moment(date).week(); + }, + weekLabel: _t("Week"), + allDayText: _t("All day"), + monthNames: moment.months(), + monthNamesShort: moment.monthsShort(), + dayNames: moment.weekdays(), + dayNamesShort: moment.weekdaysShort(), + firstDay: this.week_start, + slotLabelFormat: _t.database.parameters.time_format.search("%H") !== -1 ? format24Hour : format12Hour, + allDaySlot: this.mapping.all_day || this.fields[this.mapping.date_start].type === 'date', + }; + }, + /** + * Return a domain from the date range + * + * @private + * @returns {Array} A domain containing datetimes start and stop in UTC + * those datetimes are formatted according to server's standards + */ + _getRangeDomain: function () { + // Build OpenERP Domain to filter object by this.mapping.date_start field + // between given start, end dates. + var domain = [[this.mapping.date_start, '<=', dateToServer(this.data.end_date)]]; + if (this.mapping.date_stop) { + domain.push([this.mapping.date_stop, '>=', dateToServer(this.data.start_date)]); + } else if (!this.mapping.date_delay) { + domain.push([this.mapping.date_start, '>=', dateToServer(this.data.start_date)]); + } + return domain; + }, + /** + * @private + * @returns {Promise} + */ + _loadCalendar: function () { + var self = this; + this.data.fc_options = this._getFullCalendarOptions(); + + var defs = _.map(this.data.filters, this._loadFilter.bind(this)); + + return Promise.all(defs).then(function () { + return self._rpc({ + model: self.modelName, + method: 'search_read', + context: self.data.context, + fields: self.fieldNames, + domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()) + }) + .then(function (events) { + self._parseServerData(events); + self.data.data = _.map(events, self._recordToCalendarEvent.bind(self)); + return Promise.all([ + self._loadColors(self.data, self.data.data), + self._loadRecordsToFilters(self.data, self.data.data) + ]); + }); + }); + }, + /** + * @private + * @param {any} element + * @param {any} events + * @returns {Promise} + */ + _loadColors: function (element, events) { + if (this.fieldColor) { + var fieldName = this.fieldColor; + _.each(events, function (event) { + var value = event.record[fieldName]; + event.color_index = _.isArray(value) ? value[0] % 30 : value % 30; + }); + this.model_color = this.fields[fieldName].relation || element.model; + } + return Promise.resolve(); + }, + /** + * @private + * @param {any} filter + * @returns {Promise} + */ + _loadFilter: function (filter) { + if (!filter.write_model) { + return Promise.resolve(); + } + + var field = this.fields[filter.fieldName]; + return this._rpc({ + model: filter.write_model, + method: 'search_read', + domain: [["user_id", "=", session.uid]], + fields: [filter.write_field], + }) + .then(function (res) { + var records = _.map(res, function (record) { + var _value = record[filter.write_field]; + var value = _.isArray(_value) ? _value[0] : _value; + var f = _.find(filter.filters, function (f) {return f.value === value;}); + var formater = fieldUtils.format[_.contains(['many2many', 'one2many'], field.type) ? 'many2one' : field.type]; + return { + 'id': record.id, + 'value': value, + 'label': formater(_value, field), + 'active': !f || f.active, + }; + }); + records.sort(function (f1,f2) { + return _.string.naturalCmp(f2.label, f1.label); + }); + + // add my profile + if (field.relation === 'res.partner' || field.relation === 'res.users') { + var value = field.relation === 'res.partner' ? session.partner_id : session.uid; + var me = _.find(records, function (record) { + return record.value === value; + }); + if (me) { + records.splice(records.indexOf(me), 1); + } else { + var f = _.find(filter.filters, function (f) {return f.value === value;}); + me = { + 'value': value, + 'label': session.name + _t(" [Me]"), + 'active': !f || f.active, + }; + } + records.unshift(me); + } + // add all selection + records.push({ + 'value': 'all', + 'label': field.relation === 'res.partner' || field.relation === 'res.users' ? _t("Everybody's calendars") : _t("Everything"), + 'active': filter.all, + }); + + filter.filters = records; + }); + }, + /** + * @private + * @param {any} element + * @param {any} events + * @returns {Promise} + */ + _loadRecordsToFilters: function (element, events) { + var self = this; + var new_filters = {}; + var to_read = {}; + var defs = []; + var color_filter = {}; + + _.each(this.data.filters, function (filter, fieldName) { + var field = self.fields[fieldName]; + + new_filters[fieldName] = filter; + if (filter.write_model) { + if (field.relation === self.model_color) { + _.each(filter.filters, function (f) { + f.color_index = f.value; + }); + } + return; + } + + _.each(filter.filters, function (filter) { + filter.display = !filter.active; + }); + + var fs = []; + var undefined_fs = []; + _.each(events, function (event) { + var data = event.record[fieldName]; + if (!_.contains(['many2many', 'one2many'], field.type)) { + data = [data]; + } else { + to_read[field.relation] = (to_read[field.relation] || []).concat(data); + } + _.each(data, function (_value) { + var value = _.isArray(_value) ? _value[0] : _value; + var f = { + 'color_index': self.model_color === (field.relation || element.model) ? value % 30 : false, + 'value': value, + 'label': fieldUtils.format[field.type](_value, field) || _t("Undefined"), + 'avatar_model': field.relation || element.model, + }; + // if field used as color does not have value then push filter in undefined_fs, + // such filters should come last in filter list with Undefined string, later merge it with fs + value ? fs.push(f) : undefined_fs.push(f); + }); + }); + _.each(_.union(fs, undefined_fs), function (f) { + var f1 = _.findWhere(filter.filters, _.omit(f, 'color_index')); + if (f1) { + f1.display = true; + } else { + f.display = f.active = true; + filter.filters.push(f); + } + }); + + if (filter.color_model && filter.field_color) { + var ids = filter.filters.reduce((acc, f) => { + if (!f.color_index && f.value) { + acc.push(f.value); + } + return acc; + }, []); + if (!color_filter[filter.color_model]) { + color_filter[filter.color_model] = {}; + } + if (ids.length) { + defs.push(self._rpc({ + model: filter.color_model, + method: 'read', + args: [_.uniq(ids), [filter.field_color]], + }) + .then(function (res) { + _.each(res, function (c) { + color_filter[filter.color_model][c.id] = c[filter.field_color]; + }); + })); + } + } + }); + + _.each(to_read, function (ids, model) { + defs.push(self._rpc({ + model: model, + method: 'name_get', + args: [_.uniq(ids)], + }) + .then(function (res) { + to_read[model] = _.object(res); + })); + }); + return Promise.all(defs).then(function () { + _.each(self.data.filters, function (filter) { + if (filter.write_model) { + return; + } + if (filter.filters.length && (filter.filters[0].avatar_model in to_read)) { + _.each(filter.filters, function (f) { + f.label = to_read[f.avatar_model][f.value]; + }); + } + if (filter.color_model && filter.field_color) { + _.each(filter.filters, function (f) { + if (!f.color_index) { + f.color_index = color_filter[filter.color_model] && color_filter[filter.color_model][f.value]; + } + }); + } + }); + }); + }, + /** + * parse the server values to javascript framwork + * + * @private + * @param {Object} data the server data to parse + */ + _parseServerData: function (data) { + var self = this; + _.each(data, function(event) { + _.each(self.fieldNames, function (fieldName) { + event[fieldName] = self._parseServerValue(self.fields[fieldName], event[fieldName]); + }); + }); + }, + /** + * Transform OpenERP event object to fullcalendar event object + * + * @private + * @param {Object} evt + */ + _recordToCalendarEvent: function (evt) { + var date_start; + var date_stop; + var date_delay = evt[this.mapping.date_delay] || 1.0, + all_day = this.fields[this.mapping.date_start].type === 'date' || + this.mapping.all_day && evt[this.mapping.all_day] || false, + the_title = '', + attendees = []; + + if (!all_day) { + date_start = evt[this.mapping.date_start].clone(); + date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone() : null; + } else { + date_start = evt[this.mapping.date_start].clone().startOf('day'); + date_stop = this.mapping.date_stop ? evt[this.mapping.date_stop].clone().startOf('day') : null; + } + + if (!date_stop && date_delay) { + date_stop = date_start.clone().add(date_delay,'hours'); + } + + if (!all_day) { + date_start.add(this.getSession().getTZOffset(date_start), 'minutes'); + date_stop.add(this.getSession().getTZOffset(date_stop), 'minutes'); + } + + if (this.mapping.all_day && evt[this.mapping.all_day]) { + date_stop.add(1, 'days'); + } + var r = { + 'record': evt, + 'start': date_start.local(true).toDate(), + 'end': date_stop.local(true).toDate(), + 'r_start': date_start.clone().local(true).toDate(), + 'r_end': date_stop.clone().local(true).toDate(), + 'title': the_title, + 'allDay': all_day, + 'id': evt.id, + 'attendees':attendees, + }; + + if (!(this.mapping.all_day && evt[this.mapping.all_day]) && this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { + r.showTime = true; + } + + return r; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_popover.js b/addons/web/static/src/js/views/calendar/calendar_popover.js new file mode 100644 index 00000000..18a3d1c2 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_popover.js @@ -0,0 +1,220 @@ +odoo.define('web.CalendarPopover', function (require) { +"use strict"; + +var fieldRegistry = require('web.field_registry'); +const fieldRegistryOwl = require('web.field_registry_owl'); +const FieldWrapper = require('web.FieldWrapper'); +var StandaloneFieldManagerMixin = require('web.StandaloneFieldManagerMixin'); +var Widget = require('web.Widget'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); + +var CalendarPopover = Widget.extend(WidgetAdapterMixin, StandaloneFieldManagerMixin, { + template: 'CalendarView.event.popover', + events: { + 'click .o_cw_popover_edit': '_onClickPopoverEdit', + 'click .o_cw_popover_delete': '_onClickPopoverDelete', + }, + /** + * @constructor + * @param {Widget} parent + * @param {Object} eventInfo + */ + init: function (parent, eventInfo) { + this._super.apply(this, arguments); + StandaloneFieldManagerMixin.init.call(this); + this.hideDate = eventInfo.hideDate; + this.hideTime = eventInfo.hideTime; + this.eventTime = eventInfo.eventTime; + this.eventDate = eventInfo.eventDate; + this.displayFields = eventInfo.displayFields; + this.fields = eventInfo.fields; + this.event = eventInfo.event; + this.modelName = eventInfo.modelName; + this._canDelete = eventInfo.canDelete; + }, + /** + * @override + */ + willStart: function () { + return Promise.all([this._super.apply(this, arguments), this._processFields()]); + }, + /** + * @override + */ + start: function () { + var self = this; + _.each(this.$fieldsList, function ($field) { + $field.appendTo(self.$('.o_cw_popover_fields_secondary')); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + WidgetAdapterMixin.destroy.call(this); + }, + /** + * Called each time the widget is attached into the DOM. + */ + on_attach_callback: function () { + WidgetAdapterMixin.on_attach_callback.call(this); + }, + /** + * Called each time the widget is detached from the DOM. + */ + on_detach_callback: function () { + WidgetAdapterMixin.on_detach_callback.call(this); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @return {boolean} + */ + isEventDeletable() { + return this._canDelete;; + }, + /** + * @return {boolean} + */ + isEventDetailsVisible() { + return true; + }, + /** + * @return {boolean} + */ + isEventEditable() { + return true; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Generate fields to render into popover + * + * @private + * @returns {Promise} + */ + _processFields: function () { + var self = this; + var fieldsToGenerate = []; + var fields = _.keys(this.displayFields); + for (var i=0; i<fields.length; i++) { + var fieldName = fields[i]; + var displayFieldInfo = self.displayFields[fieldName] || {attrs: {invisible: 1}}; + var fieldInfo = self.fields[fieldName]; + var field = { + name: fieldName, + string: displayFieldInfo.attrs.string || fieldInfo.string, + value: self.event.extendedProps.record[fieldName], + type: fieldInfo.type, + invisible: displayFieldInfo.attrs.invisible, + }; + if (field.type === 'selection') { + field.selection = fieldInfo.selection; + } + if (field.type === 'monetary') { + var currencyField = field.currency_field || 'currency_id'; + if (!fields.includes(currencyField) && _.has(self.event.extendedProps.record, currencyField)) { + fields.push(currencyField); + } + } + if (fieldInfo.relation) { + field.relation = fieldInfo.relation; + } + if (displayFieldInfo.attrs.widget) { + field.widget = displayFieldInfo.attrs.widget; + } else if (_.contains(['many2many', 'one2many'], field.type)) { + field.widget = 'many2many_tags'; + } + if (_.contains(['many2many', 'one2many'], field.type)) { + field.fields = [{ + name: 'id', + type: 'integer', + }, { + name: 'display_name', + type: 'char', + }]; + } + fieldsToGenerate.push(field); + }; + + this.$fieldsList = []; + return this.model.makeRecord(this.modelName, fieldsToGenerate).then(function (recordID) { + var defs = []; + + var record = self.model.get(recordID); + _.each(fieldsToGenerate, function (field) { + if (field.invisible) return; + let isLegacy = true; + let fieldWidget; + let FieldClass = fieldRegistryOwl.getAny([field.widget, field.type]); + if (FieldClass) { + isLegacy = false; + fieldWidget = new FieldWrapper(this, FieldClass, { + fieldName: field.name, + record, + options: self.displayFields[field.name], + }); + } else { + FieldClass = fieldRegistry.getAny([field.widget, field.type]); + fieldWidget = new FieldClass(self, field.name, record, self.displayFields[field.name]); + } + if (fieldWidget.attrs && !_.isObject(fieldWidget.attrs.modifiers)) { + fieldWidget.attrs.modifiers = fieldWidget.attrs.modifiers ? JSON.parse(fieldWidget.attrs.modifiers) : {}; + } + self._registerWidget(recordID, field.name, fieldWidget); + + var $field = $('<li>', {class: 'list-group-item flex-shrink-0 d-flex flex-wrap'}); + var $fieldLabel = $('<strong>', {class: 'mr-2', text: _.str.sprintf('%s : ', field.string)}); + $fieldLabel.appendTo($field); + var $fieldContainer = $('<div>', {class: 'flex-grow-1'}); + $fieldContainer.appendTo($field); + + let def; + if (isLegacy) { + def = fieldWidget.appendTo($fieldContainer); + } else { + def = fieldWidget.mount($fieldContainer[0]); + } + self.$fieldsList.push($field); + defs.push(def); + }); + return Promise.all(defs); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQueryEvent} ev + */ + _onClickPopoverEdit: function (ev) { + ev.preventDefault(); + this.trigger_up('edit_event', { + id: this.event.id, + title: this.event.extendedProps.record.display_name, + }); + }, + /** + * @private + * @param {jQueryEvent} ev + */ + _onClickPopoverDelete: function (ev) { + ev.preventDefault(); + this.trigger_up('delete_event', {id: this.event.id}); + }, +}); + +return CalendarPopover; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_quick_create.js b/addons/web/static/src/js/views/calendar/calendar_quick_create.js new file mode 100644 index 00000000..0f6f8bd6 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js @@ -0,0 +1,114 @@ +odoo.define('web.CalendarQuickCreate', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +var _t = core._t; +var QWeb = core.qweb; + +/** + * Quick creation view. + * + * Triggers a single event "added" with a single parameter "name", which is the + * name entered by the user + * + * @class + * @type {*} + */ +var QuickCreate = Dialog.extend({ + events: _.extend({}, Dialog.events, { + 'keyup input': '_onkeyup', + }), + + /** + * @constructor + * @param {Widget} parent + * @param {Object} buttons + * @param {Object} options + * @param {Object} dataTemplate + * @param {Object} dataCalendar + */ + init: function (parent, buttons, options, dataTemplate, dataCalendar) { + this._buttons = buttons || false; + this.options = options; + + // Can hold data pre-set from where you clicked on agenda + this.dataTemplate = dataTemplate || {}; + this.dataCalendar = dataCalendar; + + var self = this; + this._super(parent, { + title: options.title, + size: 'small', + buttons: this._buttons ? [ + {text: _t("Create"), classes: 'btn-primary', click: function () { + if (!self._quickAdd(dataCalendar)) { + self.focus(); + } + }}, + {text: _t("Edit"), click: function () { + dataCalendar.disableQuickCreate = true; + dataCalendar.title = self.$('input').val().trim(); + dataCalendar.on_save = self.destroy.bind(self); + self.trigger_up('openCreate', dataCalendar); + }}, + {text: _t("Cancel"), close: true}, + ] : [], + $content: QWeb.render('CalendarView.quick_create', {widget: this}) + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + focus: function () { + this.$('input').focus(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Gathers data from the quick create dialog a launch quick_create(data) method + */ + _quickAdd: function (dataCalendar) { + dataCalendar = $.extend({}, this.dataTemplate, dataCalendar); + var val = this.$('input').val().trim(); + if (!val) { + this.$('label, input').addClass('o_field_invalid'); + var warnings = _.str.sprintf('<ul><li>%s</li></ul>', _t("Summary")); + this.do_warn(_t("Invalid fields:"), warnings); + } + dataCalendar.title = val; + return (val)? this.trigger_up('quickCreate', {data: dataCalendar, options: this.options}) : false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {keyEvent} event + */ + _onkeyup: function (event) { + if (this._flagEnter) { + return; + } + if(event.keyCode === $.ui.keyCode.ENTER) { + this._flagEnter = true; + if (!this._quickAdd(this.dataCalendar)){ + this._flagEnter = false; + } + } else if (event.keyCode === $.ui.keyCode.ESCAPE && this._buttons) { + this.close(); + } + }, +}); + +return QuickCreate; + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js new file mode 100644 index 00000000..4ab750f6 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -0,0 +1,1006 @@ +odoo.define('web.CalendarRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var CalendarPopover = require('web.CalendarPopover'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var field_utils = require('web.field_utils'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var relational_fields = require('web.relational_fields'); +var session = require('web.session'); +var Widget = require('web.Widget'); +const { createYearCalendarView } = require('/web/static/src/js/libs/fullcalendar.js'); + +var _t = core._t; +var qweb = core.qweb; + +var SidebarFilterM2O = relational_fields.FieldMany2One.extend({ + _getSearchBlacklist: function () { + return this._super.apply(this, arguments).concat(this.filter_ids || []); + }, +}); + +var SidebarFilter = Widget.extend(FieldManagerMixin, { + template: 'CalendarView.sidebar.filter', + custom_events: _.extend({}, FieldManagerMixin.custom_events, { + field_changed: '_onFieldChanged', + }), + /** + * @constructor + * @param {Widget} parent + * @param {Object} options + * @param {string} options.fieldName + * @param {Object[]} options.filters A filter is an object with the + * following keys: id, value, label, active, avatar_model, color, + * can_be_removed + * @param {Object} [options.favorite] this is an object with the following + * keys: fieldName, model, fieldModel + */ + init: function (parent, options) { + this._super.apply(this, arguments); + FieldManagerMixin.init.call(this); + + this.title = options.title; + this.fields = options.fields; + this.fieldName = options.fieldName; + this.write_model = options.write_model; + this.write_field = options.write_field; + this.avatar_field = options.avatar_field; + this.avatar_model = options.avatar_model; + this.filters = options.filters; + this.label = options.label; + this.getColor = options.getColor; + }, + /** + * @override + */ + willStart: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + + if (this.write_model || this.write_field) { + var def = this.model.makeRecord(this.write_model, [{ + name: this.write_field, + relation: this.fields[this.fieldName].relation, + type: 'many2one', + }]).then(function (recordID) { + self.many2one = new SidebarFilterM2O(self, + self.write_field, + self.model.get(recordID), + { + mode: 'edit', + attrs: { + string: _t(self.fields[self.fieldName].string), + placeholder: "+ " + _.str.sprintf(_t("Add %s"), self.title), + can_create: false + }, + }); + }); + defs.push(def); + } + return Promise.all(defs); + + }, + /** + * @override + */ + start: function () { + this._super(); + if (this.many2one) { + this.many2one.appendTo(this.$el); + this.many2one.filter_ids = _.without(_.pluck(this.filters, 'value'), 'all'); + } + this.$el.on('click', '.o_remove', this._onFilterRemove.bind(this)); + this.$el.on('click', '.o_calendar_filter_items input', this._onFilterActive.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var self = this; + event.stopPropagation(); + var createValues = {'user_id': session.uid}; + var value = event.data.changes[this.write_field].id; + createValues[this.write_field] = value; + this._rpc({ + model: this.write_model, + method: 'create', + args: [createValues], + }) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'value': value, + 'active': true, + }); + }); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onFilterActive: function (e) { + var $input = $(e.currentTarget); + this.trigger_up('changeFilter', { + 'fieldName': this.fieldName, + 'value': $input.closest('.o_calendar_filter_item').data('value'), + 'active': $input.prop('checked'), + }); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onFilterRemove: function (e) { + var self = this; + var $filter = $(e.currentTarget).closest('.o_calendar_filter_item'); + Dialog.confirm(this, _t("Do you really want to delete this filter from favorites ?"), { + confirm_callback: function () { + self._rpc({ + model: self.write_model, + method: 'unlink', + args: [[$filter.data('id')]], + }) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'id': $filter.data('id'), + 'active': false, + 'value': $filter.data('value'), + }); + }); + }, + }); + }, +}); + +return AbstractRenderer.extend({ + template: "CalendarView", + config: { + CalendarPopover: CalendarPopover, + }, + custom_events: _.extend({}, AbstractRenderer.prototype.custom_events || {}, { + edit_event: '_onEditEvent', + delete_event: '_onDeleteEvent', + }), + + /** + * @constructor + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.displayFields = params.displayFields; + this.model = params.model; + this.filters = []; + this.color_map = {}; + this.hideDate = params.hideDate; + this.hideTime = params.hideTime; + this.canDelete = params.canDelete; + this.canCreate = params.canCreate; + this.scalesInfo = params.scalesInfo; + this._isInDOM = false; + }, + /** + * @override + * @returns {Promise} + */ + start: function () { + this._initSidebar(); + this._initCalendar(); + return this._super(); + }, + /** + * @override + */ + on_attach_callback: function () { + this._super(...arguments); + this._isInDOM = true; + // BUG Test ???? + // this.$el.height($(window).height() - this.$el.offset().top); + this.calendar.render(); + this._renderCalendar(); + window.addEventListener('click', this._onWindowClick.bind(this)); + }, + /** + * Called when the field is detached from the DOM. + */ + on_detach_callback: function () { + this._super(...arguments); + this._isInDOM = false; + window.removeEventListener('click', this._onWindowClick); + }, + /** + * @override + */ + destroy: function () { + if (this.calendar) { + this.calendar.destroy(); + } + if (this.$small_calendar) { + this.$small_calendar.datepicker('destroy'); + $('#ui-datepicker-div:empty').remove(); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Note: this is not dead code, it is called by the calendar-box template + * + * @param {any} record + * @param {any} fieldName + * @param {any} imageField + * @returns {string[]} + */ + getAvatars: function (record, fieldName, imageField) { + var field = this.state.fields[fieldName]; + + if (!record[fieldName]) { + return []; + } + if (field.type === 'one2many' || field.type === 'many2many') { + return _.map(record[fieldName], function (id) { + return '<img src="/web/image/'+field.relation+'/'+id+'/'+imageField+'" />'; + }); + } else if (field.type === 'many2one') { + return ['<img src="/web/image/'+field.relation+'/'+record[fieldName][0]+'/'+imageField+'" />']; + } else { + var value = this._format(record, fieldName); + var color = this.getColor(value); + if (isNaN(color)) { + return ['<span class="o_avatar_square" style="background-color:'+color+';"/>']; + } + else { + return ['<span class="o_avatar_square o_calendar_color_'+color+'"/>']; + } + } + }, + /** + * Note: this is not dead code, it is called by two template + * + * @param {any} key + * @returns {integer} + */ + getColor: function (key) { + if (!key) { + return; + } + if (this.color_map[key]) { + return this.color_map[key]; + } + // check if the key is a css color + if (typeof key === 'string' && key.match(/^((#[A-F0-9]{3})|(#[A-F0-9]{6})|((hsl|rgb)a?\(\s*(?:(\s*\d{1,3}%?\s*),?){3}(\s*,[0-9.]{1,4})?\))|)$/i)) { + return this.color_map[key] = key; + } + if (typeof key === 'number' && !(key in this.color_map)) { + return this.color_map[key] = key; + } + var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1; + this.color_map[key] = index; + return index; + }, + /** + * @override + */ + getLocalState: function () { + var fcScroller = this.calendarElement.querySelector('.fc-scroller'); + return { + scrollPosition: fcScroller.scrollTop, + }; + }, + /** + * @override + */ + setLocalState: function (localState) { + if (localState.scrollPosition) { + var fcScroller = this.calendarElement.querySelector('.fc-scroller'); + fcScroller.scrollTop = localState.scrollPosition; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Convert the new format of Event from FullCalendar V4 to a Event FullCalendar V3 + * @param fc4Event + * @return {Object} FullCalendar V3 Object Event + * @private + */ + _convertEventToFC3Event: function (fc4Event) { + var event = fc4Event; + if (!moment.isMoment(fc4Event.start)) { + event = { + id: fc4Event.id, + title: fc4Event.title, + start: moment(fc4Event.start).utcOffset(0, true), + end: fc4Event.end && moment(fc4Event.end).utcOffset(0, true), + allDay: fc4Event.allDay, + color: fc4Event.color, + }; + if (fc4Event.extendedProps) { + event = Object.assign({}, event, { + r_start: fc4Event.extendedProps.r_start && moment(fc4Event.extendedProps.r_start).utcOffset(0, true), + r_end: fc4Event.extendedProps.r_end && moment(fc4Event.extendedProps.r_end).utcOffset(0, true), + record: fc4Event.extendedProps.record, + attendees: fc4Event.extendedProps.attendees, + }); + } + } + return event; + }, + /** + * @param {any} event + * @returns {string} the html for the rendered event + */ + _eventRender: function (event) { + var qweb_context = { + event: event, + record: event.extendedProps.record, + color: this.getColor(event.extendedProps.color_index), + showTime: !self.hideTime && event.extendedProps.showTime, + }; + this.qweb_context = qweb_context; + if (_.isEmpty(qweb_context.record)) { + return ''; + } else { + return qweb.render("calendar-box", qweb_context); + } + }, + /** + * @private + * @param {any} record + * @param {any} fieldName + * @returns {string} + */ + _format: function (record, fieldName) { + var field = this.state.fields[fieldName]; + if (field.type === "one2many" || field.type === "many2many") { + return field_utils.format[field.type]({data: record[fieldName]}, field); + } else { + return field_utils.format[field.type](record[fieldName], field, {forceString: true}); + } + }, + /** + * Return the Object options for FullCalendar + * + * @private + * @param {Object} fcOptions + * @return {Object} + */ + _getFullCalendarOptions: function (fcOptions) { + var self = this; + const options = Object.assign({}, this.state.fc_options, { + plugins: [ + 'moment', + 'interaction', + 'dayGrid', + 'timeGrid' + ], + eventDrop: function (eventDropInfo) { + var event = self._convertEventToFC3Event(eventDropInfo.event); + self.trigger_up('dropRecord', event); + }, + eventResize: function (eventResizeInfo) { + self._unselectEvent(); + var event = self._convertEventToFC3Event(eventResizeInfo.event); + self.trigger_up('updateRecord', event); + }, + eventClick: function (eventClickInfo) { + eventClickInfo.jsEvent.preventDefault(); + eventClickInfo.jsEvent.stopPropagation(); + var eventData = eventClickInfo.event; + self._unselectEvent(); + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', eventData.id)).addClass('o_cw_custom_highlight'); + self._renderEventPopover(eventData, $(eventClickInfo.el)); + }, + yearDateClick: function (info) { + self._unselectEvent(); + info.view.unselect(); + if (!info.events.length) { + if (info.selectable) { + const data = { + start: info.date, + allDay: true, + }; + if (self.state.context.default_name) { + data.title = self.state.context.default_name; + } + self.trigger_up('openCreate', self._convertEventToFC3Event(data)); + } + } else { + self._renderYearEventPopover(info.date, info.events, $(info.dayEl)); + } + }, + select: function (selectionInfo) { + // Clicking on the view, dispose any visible popover. Otherwise create a new event. + if (self.$('.o_cw_popover').length) { + self._unselectEvent(); + } + var data = {start: selectionInfo.start, end: selectionInfo.end, allDay: selectionInfo.allDay}; + if (self.state.context.default_name) { + data.title = self.state.context.default_name; + } + self.trigger_up('openCreate', self._convertEventToFC3Event(data)); + if (self.state.scale === 'year') { + self.calendar.view.unselect(); + } else { + self.calendar.unselect(); + } + }, + eventRender: function (info) { + var event = info.event; + var element = $(info.el); + var view = info.view; + element.attr('data-event-id', event.id); + if (view.type === 'dayGridYear') { + const color = this.getColor(event.extendedProps.color_index); + if (typeof color === 'string') { + element.css({ + backgroundColor: color, + }); + } else if (typeof color === 'number') { + element.addClass(`o_calendar_color_${color}`); + } else { + element.addClass('o_calendar_color_1'); + } + } else { + var $render = $(self._eventRender(event)); + element.find('.fc-content').html($render.html()); + element.addClass($render.attr('class')); + + // Add background if doesn't exist + if (!element.find('.fc-bg').length) { + element.find('.fc-content').after($('<div/>', {class: 'fc-bg'})); + } + + if (view.type === 'dayGridMonth' && event.extendedProps.record) { + var start = event.extendedProps.r_start || event.start; + var end = event.extendedProps.r_end || event.end; + // Detect if the event occurs in just one day + // note: add & remove 1 min to avoid issues with 00:00 + var isSameDayEvent = moment(start).clone().add(1, 'minute').isSame(moment(end).clone().subtract(1, 'minute'), 'day'); + if (!event.extendedProps.record.allday && isSameDayEvent) { + // For month view: do not show background for non allday, single day events + element.addClass('o_cw_nobg'); + if (event.extendedProps.showTime && !self.hideTime) { + const displayTime = moment(start).clone().format(self._getDbTimeFormat()); + element.find('.fc-content .fc-time').text(displayTime); + } + } + } + + // On double click, edit the event + element.on('dblclick', function () { + self.trigger_up('edit_event', {id: event.id}); + }); + } + }, + datesRender: function (info) { + const viewToMode = Object.fromEntries( + Object.entries(self.scalesInfo).map(([k, v]) => [v, k]) + ); + self.trigger_up('viewUpdated', { + mode: viewToMode[info.view.type], + title: info.view.title, + }); + }, + // Add/Remove a class on hover to style multiple days events. + // The css ":hover" selector can't be used because these events + // are rendered using multiple elements. + eventMouseEnter: function (mouseEnterInfo) { + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseEnterInfo.event.id)).addClass('o_cw_custom_hover'); + }, + eventMouseLeave: function (mouseLeaveInfo) { + if (!mouseLeaveInfo.event.id) { + return; + } + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseLeaveInfo.event.id)).removeClass('o_cw_custom_hover'); + }, + eventDragStart: function (mouseDragInfo) { + mouseDragInfo.el.classList.add(mouseDragInfo.view.type); + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseDragInfo.event.id)).addClass('o_cw_custom_hover'); + self._unselectEvent(); + }, + eventResizeStart: function (mouseResizeInfo) { + $(self.calendarElement).find(_.str.sprintf('[data-event-id=%s]', mouseResizeInfo.event.id)).addClass('o_cw_custom_hover'); + self._unselectEvent(); + }, + eventLimitClick: function () { + self._unselectEvent(); + return 'popover'; + }, + windowResize: function () { + self._render(); + }, + views: { + timeGridDay: { + columnHeaderFormat: 'LL' + }, + timeGridWeek: { + columnHeaderFormat: 'ddd D' + }, + dayGridMonth: { + columnHeaderFormat: 'dddd' + } + }, + height: 'parent', + unselectAuto: false, + dir: _t.database.parameters.direction, + events: (info, successCB) => { + successCB(self.state.data); + }, + }, fcOptions); + options.plugins.push(createYearCalendarView(FullCalendar, options)); + return options; + }, + /** + * Initialize the main calendar + * + * @private + */ + _initCalendar: function () { + this.calendarElement = this.$(".o_calendar_widget")[0]; + var locale = moment.locale(); + + var fcOptions = this._getFullCalendarOptions({ + locale: locale, // reset locale when fullcalendar has already been instanciated before now + }); + + this.calendar = new FullCalendar.Calendar(this.calendarElement, fcOptions); + }, + /** + * Initialize the mini calendar in the sidebar + * + * @private + */ + _initCalendarMini: function () { + var self = this; + this.$small_calendar = this.$(".o_calendar_mini"); + this.$small_calendar.datepicker({ + 'onSelect': function (datum, obj) { + self.trigger_up('changeDate', { + date: moment(new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay)) + }); + }, + 'showOtherMonths': true, + 'dayNamesMin' : this.state.fc_options.dayNamesShort.map(x => x[0]), + 'monthNames': this.state.fc_options.monthNamesShort, + 'firstDay': this.state.fc_options.firstDay, + }); + }, + /** + * Initialize the sidebar + * + * @private + */ + _initSidebar: function () { + this.$sidebar = this.$('.o_calendar_sidebar'); + this.$sidebar_container = this.$(".o_calendar_sidebar_container"); + this._initCalendarMini(); + }, + /** + * Finalise the popover + * + * @param {jQueryElement} $popoverElement + * @param {web.CalendarPopover} calendarPopover + * @private + */ + _onPopoverShown: function ($popoverElement, calendarPopover) { + var $popover = $($popoverElement.data('bs.popover').tip); + $popover.find('.o_cw_popover_close').on('click', this._unselectEvent.bind(this)); + $popover.find('.o_cw_body').replaceWith(calendarPopover.$el); + }, + /** + * Render the calendar view, this is the main entry point. + * + * @override + */ + async _renderView() { + this.$('.o_calendar_view')[0].prepend(this.calendarElement); + if (this._isInDOM) { + this._renderCalendar(); + } + this.$small_calendar.datepicker("setDate", this.state.highlight_date.toDate()) + .find('.o_selected_range') + .removeClass('o_color o_selected_range'); + var $a; + switch (this.state.scale) { + case 'year': $a = this.$small_calendar.find('td'); break; + case 'month': $a = this.$small_calendar.find('td'); break; + case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active)'); break; + case 'day': $a = this.$small_calendar.find('a.ui-state-active'); break; + } + $a.addClass('o_selected_range'); + setTimeout(function () { + $a.not('.ui-state-active').addClass('o_color'); + }); + + await this._renderFilters(); + }, + /** + * Render the specific code for the FullCalendar when it's in the DOM + * + * @private + */ + _renderCalendar() { + this.calendar.unselect(); + + if (this.scalesInfo[this.state.scale] !== this.calendar.view.type) { + this.calendar.changeView(this.scalesInfo[this.state.scale]); + } + + if (this.target_date !== this.state.target_date.toString()) { + this.calendar.gotoDate(moment(this.state.target_date).toDate()); + this.target_date = this.state.target_date.toString(); + } else { + // this.calendar.gotoDate already renders events when called + // so render events only when domain changes + this._renderEvents(); + } + + this._unselectEvent(); + // this._scrollToScrollTime(); + }, + /** + * Render all events + * + * @private + */ + _renderEvents: function () { + this.calendar.refetchEvents(); + }, + /** + * Render all filters + * + * @private + * @returns {Promise} resolved when all filters have been rendered + */ + _renderFilters: function () { + // Dispose of filter popover + this.$('.o_calendar_filter_item').popover('dispose'); + _.each(this.filters || (this.filters = []), function (filter) { + filter.destroy(); + }); + if (this.state.fullWidth) { + return Promise.resolve(); + } + return this._renderFiltersOneByOne(); + }, + /** + * Renders each filter one by one, waiting for the first filter finished to + * be rendered and appended to render the next one. + * We need to do like this since render a filter is asynchronous, we don't + * know which one will be appened at first and we want tp force them to be + * rendered in order. + * + * @param {number} filterIndex if not set, 0 by default + * @returns {Promise} resolved when all filters have been rendered + */ + _renderFiltersOneByOne: function (filterIndex) { + filterIndex = filterIndex || 0; + var arrFilters = _.toArray(this.state.filters); + var prom; + if (filterIndex < arrFilters.length) { + var options = arrFilters[filterIndex]; + if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) { + return this._renderFiltersOneByOne(filterIndex + 1); + } + + var self = this; + options.getColor = this.getColor.bind(this); + options.fields = this.state.fields; + var sidebarFilter = new SidebarFilter(self, options); + prom = sidebarFilter.appendTo(this.$sidebar).then(function () { + // Show filter popover + if (options.avatar_field) { + _.each(options.filters, function (filter) { + if (!['all', false].includes(filter.value)) { + var selector = _.str.sprintf('.o_calendar_filter_item[data-value=%s]', filter.value); + sidebarFilter.$el.find(selector).popover({ + animation: false, + trigger: 'hover', + html: true, + placement: 'top', + title: filter.label, + delay: {show: 300, hide: 0}, + content: function () { + return $('<img>', { + src: _.str.sprintf('/web/image/%s/%s/%s', options.avatar_model, filter.value, options.avatar_field), + class: 'mx-auto', + }); + }, + }); + } + }); + } + return self._renderFiltersOneByOne(filterIndex + 1); + }); + this.filters.push(sidebarFilter); + } + return Promise.resolve(prom); + }, + /** + * Returns the time format from database parameters (only hours and minutes). + * FIXME: this looks like a weak heuristic... + * + * @private + * @returns {string} + */ + _getDbTimeFormat: function () { + return _t.database.parameters.time_format.search('%H') !== -1 ? 'HH:mm' : 'hh:mm a'; + }, + /** + * Returns event's formatted date for popovers. + * + * @private + * @param {moment} start + * @param {moment} end + * @param {boolean} showDayName + * @param {boolean} allDay + */ + _getFormattedDate: function (start, end, showDayName, allDay) { + const isSameDayEvent = start.clone().add(1, 'minute') + .isSame(end.clone().subtract(1, 'minute'), 'day'); + if (allDay) { + // cancel correction done in _recordToCalendarEvent + end = end.clone().subtract(1, 'day'); + } + if (!isSameDayEvent && start.isSame(end, 'month')) { + // Simplify date-range if an event occurs into the same month (eg. '4-5 August 2019') + return start.clone().format('MMMM D') + '-' + end.clone().format('D, YYYY'); + } else { + return isSameDayEvent ? + start.clone().format(showDayName ? 'dddd, LL' : 'LL') : + start.clone().format('LL') + ' - ' + end.clone().format('LL'); + } + }, + /** + * Prepare context to display in the popover. + * + * @private + * @param {Object} eventData + * @returns {Object} context + */ + _getPopoverContext: function (eventData) { + var context = { + hideDate: this.hideDate, + hideTime: this.hideTime, + eventTime: {}, + eventDate: {}, + fields: this.state.fields, + displayFields: this.displayFields, + event: eventData, + modelName: this.model, + canDelete: this.canDelete, + }; + + var start = moment((eventData.extendedProps && eventData.extendedProps.r_start) || eventData.start); + var end = moment((eventData.extendedProps && eventData.extendedProps.r_end) || eventData.end); + var isSameDayEvent = start.clone().add(1, 'minute').isSame(end.clone().subtract(1, 'minute'), 'day'); + + // Do not display timing if the event occur across multiple days. Otherwise use user's timing preferences + if (!this.hideTime && !eventData.extendedProps.record.allday && isSameDayEvent) { + var dbTimeFormat = this._getDbTimeFormat(); + + context.eventTime.time = start.clone().format(dbTimeFormat) + ' - ' + end.clone().format(dbTimeFormat); + + // Calculate duration and format text + var durationHours = moment.duration(end.diff(start)).hours(); + var durationHoursKey = (durationHours === 1) ? 'h' : 'hh'; + var durationMinutes = moment.duration(end.diff(start)).minutes(); + var durationMinutesKey = (durationMinutes === 1) ? 'm' : 'mm'; + + var localeData = moment.localeData(); // i18n for 'hours' and "minutes" strings + context.eventTime.duration = (durationHours > 0 ? localeData.relativeTime(durationHours, true, durationHoursKey) : '') + + (durationHours > 0 && durationMinutes > 0 ? ', ' : '') + + (durationMinutes > 0 ? localeData.relativeTime(durationMinutes, true, durationMinutesKey) : ''); + } + + if (!this.hideDate) { + + if (eventData.extendedProps.record.allday && isSameDayEvent) { + context.eventDate.duration = _t("All day"); + } else if (eventData.extendedProps.record.allday && !isSameDayEvent) { + var daysLocaleData = moment.localeData(); + var days = moment.duration(end.diff(start)).days(); + context.eventDate.duration = daysLocaleData.relativeTime(days, true, 'dd'); + } + + context.eventDate.date = this._getFormattedDate(start, end, true, eventData.extendedProps.record.allday); + } + + return context; + }, + /** + * Prepare the parameters for the popover. + * This allow the parameters to be extensible. + * + * @private + * @param {Object} eventData + */ + _getPopoverParams: function (eventData) { + return { + animation: false, + delay: { + show: 50, + hide: 100 + }, + trigger: 'manual', + html: true, + title: eventData.extendedProps.record.display_name, + template: qweb.render('CalendarView.event.popover.placeholder', {color: this.getColor(eventData.extendedProps.color_index)}), + container: eventData.allDay ? '.fc-view' : '.fc-scroller', + } + }, + /** + * Render event popover + * + * @private + * @param {Object} eventData + * @param {jQueryElement} $eventElement + */ + _renderEventPopover: function (eventData, $eventElement) { + var self = this; + + // Initialize popover widget + var calendarPopover = new self.config.CalendarPopover(self, self._getPopoverContext(eventData)); + calendarPopover.appendTo($('<div>')).then(() => { + $eventElement.popover( + self._getPopoverParams(eventData) + ).on('shown.bs.popover', function () { + self._onPopoverShown($(this), calendarPopover); + }).popover('show'); + }); + }, + /** + * Render year event popover + * + * @private + * @param {Date} date + * @param {Object[]} events + * @param {jQueryElement} $el + */ + _renderYearEventPopover: function (date, events, $el) { + const groupKeys = []; + const groupedEvents = {}; + for (const event of events) { + const start = moment(event.extendedProps.r_start); + const end = moment(event.extendedProps.r_end); + const key = this._getFormattedDate(start, end, false, event.extendedProps.record.allday); + if (!(key in groupedEvents)) { + groupedEvents[key] = []; + groupKeys.push({ + key: key, + start: event.extendedProps.r_start, + end: event.extendedProps.r_end, + isSameDayEvent: start.clone().add(1, 'minute') + .isSame(end.clone().subtract(1, 'minute'), 'day'), + }); + } + groupedEvents[key].push(event); + } + + const popoverContent = qweb.render('CalendarView.yearEvent.popover', { + groupedEvents, + groupKeys: groupKeys + .sort((a, b) => { + if (a.isSameDayEvent) { + // if isSameDayEvent then put it before the others + return Number.MIN_SAFE_INTEGER; + } else if (b.isSameDayEvent) { + return Number.MAX_SAFE_INTEGER; + } else if (a.start.getTime() - b.start.getTime() === 0) { + return a.end.getTime() - b.end.getTime(); + } + return a.start.getTime() - b.start.getTime(); + }) + .map(x => x.key), + canCreate: this.canCreate, + }); + + $el.popover({ + animation: false, + delay: { + show: 50, + hide: 100 + }, + trigger: 'manual', + html: true, + content: popoverContent, + template: qweb.render('CalendarView.yearEvent.popover.placeholder'), + container: '.fc-dayGridYear-view', + }).on('shown.bs.popover', () => { + $('.o_cw_popover .o_cw_popover_close').on('click', () => this._unselectEvent()); + $('.o_cw_popover .o_cw_popover_create').on('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._unselectEvent(); + const data = { + start: date, + allDay: true, + }; + if (this.state.context.default_name) { + data.title = this.state.context.default_name; + } + this.trigger_up('openCreate', this._convertEventToFC3Event(data)); + }); + $('.o_cw_popover .o_cw_popover_link').on('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._unselectEvent(); + this.trigger_up('openEvent', { + _id: parseInt(e.target.dataset.id, 10), + title: e.target.dataset.title, + }); + }); + }).popover('show'); + }, + /** + * Scroll to the time set in the FullCalendar parameter + * @private + */ + _scrollToScrollTime: function () { + var scrollTime = this.calendar.getOption('scrollTime'); + this.calendar.scrollToTime(scrollTime); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Remove highlight classes and dispose of popovers + * + * @private + */ + _unselectEvent: function () { + this.$('.fc-event').removeClass('o_cw_custom_highlight'); + this.$('.o_cw_popover').popover('dispose'); + }, + /** + * @private + * @param {MouseEvent} e + */ + _onWindowClick: function (e) { + const popover = this.el.querySelector('.o_cw_popover'); + if (popover && !popover.contains(e.target)) { + this._unselectEvent(); + } + }, + /** + * @private + * @param {OdooEvent} event + */ + _onEditEvent: function (event) { + this._unselectEvent(); + this.trigger_up('openEvent', { + _id: event.data.id, + title: event.data.title, + }); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onDeleteEvent: function (event) { + this._unselectEvent(); + this.trigger_up('deleteRecord', {id: parseInt(event.data.id, 10)}); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/calendar/calendar_view.js b/addons/web/static/src/js/views/calendar/calendar_view.js new file mode 100644 index 00000000..b83b8072 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -0,0 +1,204 @@ +odoo.define('web.CalendarView', function (require) { +"use strict"; + +var AbstractView = require('web.AbstractView'); +var CalendarModel = require('web.CalendarModel'); +var CalendarController = require('web.CalendarController'); +var CalendarRenderer = require('web.CalendarRenderer'); +var core = require('web.core'); +var pyUtils = require('web.py_utils'); +var utils = require('web.utils'); + +var _lt = core._lt; + +// gather the fields to get +var fieldsToGather = [ + "date_start", + "date_delay", + "date_stop", + "all_day", + "recurrence_update" +]; + +const scalesInfo = { + day: 'timeGridDay', + week: 'timeGridWeek', + month: 'dayGridMonth', + year: 'dayGridYear', +}; + +var CalendarView = AbstractView.extend({ + display_name: _lt('Calendar'), + icon: 'fa-calendar', + jsLibs: [ + '/web/static/lib/fullcalendar/core/main.js', + '/web/static/lib/fullcalendar/interaction/main.js', + '/web/static/lib/fullcalendar/moment/main.js', + '/web/static/lib/fullcalendar/daygrid/main.js', + '/web/static/lib/fullcalendar/timegrid/main.js', + '/web/static/lib/fullcalendar/list/main.js' + ], + cssLibs: [ + '/web/static/lib/fullcalendar/core/main.css', + '/web/static/lib/fullcalendar/daygrid/main.css', + '/web/static/lib/fullcalendar/timegrid/main.css', + '/web/static/lib/fullcalendar/list/main.css' + ], + config: _.extend({}, AbstractView.prototype.config, { + Model: CalendarModel, + Controller: CalendarController, + Renderer: CalendarRenderer, + }), + viewType: 'calendar', + searchMenuTypes: ['filter', 'favorite'], + + /** + * @override + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + var arch = this.arch; + var fields = this.fields; + var attrs = arch.attrs; + + if (!attrs.date_start) { + throw new Error(_lt("Calendar view has not defined 'date_start' attribute.")); + } + + var mapping = {}; + var fieldNames = fields.display_name ? ['display_name'] : []; + var displayFields = {}; + + _.each(fieldsToGather, function (field) { + if (arch.attrs[field]) { + var fieldName = attrs[field]; + mapping[field] = fieldName; + fieldNames.push(fieldName); + } + }); + + var filters = {}; + + var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit); + + var modelFilters = []; + _.each(arch.children, function (child) { + if (child.tag !== 'field') return; + var fieldName = child.attrs.name; + fieldNames.push(fieldName); + if (!child.attrs.invisible || child.attrs.filters) { + child.attrs.options = child.attrs.options ? pyUtils.py_eval(child.attrs.options) : {}; + if (!child.attrs.invisible) { + displayFields[fieldName] = {attrs: child.attrs}; + } + + if (params.sidebar === false) return; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers" + + if (child.attrs.avatar_field) { + filters[fieldName] = filters[fieldName] || { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + filters[fieldName].avatar_field = child.attrs.avatar_field; + filters[fieldName].avatar_model = fields[fieldName].relation; + } + if (child.attrs.write_model) { + filters[fieldName] = filters[fieldName] || { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + filters[fieldName].write_model = child.attrs.write_model; + filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields + + modelFilters.push(fields[fieldName].relation); + } + if (child.attrs.filters) { + filters[fieldName] = filters[fieldName] || { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + if (child.attrs.color) { + filters[fieldName].field_color = child.attrs.color; + filters[fieldName].color_model = fields[fieldName].relation; + } + if (!child.attrs.avatar_field && fields[fieldName].relation) { + if (fields[fieldName].relation.includes(['res.users', 'res.partner', 'hr.employee'])) { + filters[fieldName].avatar_field = 'image_128'; + } + filters[fieldName].avatar_model = fields[fieldName].relation; + } + } + } + }); + + if (attrs.color) { + var fieldName = attrs.color; + fieldNames.push(fieldName); + } + + //if quick_add = False, we don't allow quick_add + //if quick_add = not specified in view, we use the default widgets.QuickCreate + //if quick_add = is NOT False and IS specified in view, we this one for widgets.QuickCreate' + this.controllerParams.quickAddPop = (!('quick_add' in attrs) || utils.toBoolElse(attrs.quick_add+'', true)); + this.controllerParams.disableQuickCreate = params.disable_quick_create || !this.controllerParams.quickAddPop; + + // If form_view_id is set, then the calendar view will open a form view + // with this id, when it needs to edit or create an event. + this.controllerParams.formViewId = + attrs.form_view_id ? parseInt(attrs.form_view_id, 10) : false; + if (!this.controllerParams.formViewId && params.action) { + var formViewDescr = _.find(params.action.views, function (v) { + return v.type === 'form'; + }); + if (formViewDescr) { + this.controllerParams.formViewId = formViewDescr.viewID; + } + } + + let scales; + const allowedScales = Object.keys(scalesInfo); + if (arch.attrs.scales) { + scales = arch.attrs.scales.split(',') + .filter(x => allowedScales.includes(x)); + } else { + scales = allowedScales; + } + + this.controllerParams.eventOpenPopup = utils.toBoolElse(attrs.event_open_popup || '', false); + this.controllerParams.showUnusualDays = utils.toBoolElse(attrs.show_unusual_days || '', false); + this.controllerParams.mapping = mapping; + this.controllerParams.context = params.context || {}; + this.controllerParams.displayName = params.action && params.action.name; + this.controllerParams.scales = scales; + + this.rendererParams.displayFields = displayFields; + this.rendererParams.model = viewInfo.model; + this.rendererParams.hideDate = utils.toBoolElse(attrs.hide_date || '', false); + this.rendererParams.hideTime = utils.toBoolElse(attrs.hide_time || '', false); + this.rendererParams.canDelete = this.controllerParams.activeActions.delete; + this.rendererParams.canCreate = this.controllerParams.activeActions.create; + this.rendererParams.scalesInfo = scalesInfo; + + this.loadParams.fieldNames = _.uniq(fieldNames); + this.loadParams.mapping = mapping; + this.loadParams.fields = fields; + this.loadParams.fieldsInfo = viewInfo.fieldsInfo; + this.loadParams.editable = !fields[mapping.date_start].readonly; + this.loadParams.creatable = this.controllerParams.activeActions.create; + this.loadParams.eventLimit = eventLimit; + this.loadParams.fieldColor = attrs.color; + + this.loadParams.filters = filters; + this.loadParams.mode = (params.context && params.context.default_mode) || attrs.mode; + this.loadParams.scales = scales; + this.loadParams.initialDate = moment(params.initialDate || new Date()); + this.loadParams.scalesInfo = scalesInfo; + }, +}); + +return CalendarView; + +}); diff --git a/addons/web/static/src/js/views/field_manager_mixin.js b/addons/web/static/src/js/views/field_manager_mixin.js new file mode 100644 index 00000000..aab8dd14 --- /dev/null +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -0,0 +1,166 @@ +odoo.define('web.FieldManagerMixin', function (require) { +"use strict"; + +/** + * The FieldManagerMixin is a mixin, designed to do the plumbing between field + * widgets and a basicmodel. Field widgets can be used outside of a view. In + * that case, someone needs to listen to events bubbling up from the widgets and + * calling the correct methods on the model. This is the field_manager's job. + */ + +var BasicModel = require('web.BasicModel'); +var concurrency = require('web.concurrency'); + +var FieldManagerMixin = { + custom_events: { + field_changed: '_onFieldChanged', + load: '_onLoad', + mutexify: '_onMutexify', + }, + /** + * A FieldManagerMixin can be initialized with an instance of a basicModel. + * If not, it will simply uses its own. + * + * @param {BasicModel} [model] + */ + init: function (model) { + this.model = model || new BasicModel(this); + this.mutex = new concurrency.Mutex(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply changes by notifying the basic model, then saving the data if + * necessary, and finally, confirming the changes to the UI. + * + * @todo find a way to remove ugly 3rd argument... + * + * @param {string} dataPointID + * @param {Object} changes + * @param {OdooEvent} event + * @returns {Promise} resolves when the change has been done, and the UI + * updated + */ + _applyChanges: function (dataPointID, changes, event) { + var self = this; + var options = _.pick(event.data, 'context', 'doNotSetDirty', 'notifyChange', 'viewType', 'allowWarning'); + return this.model.notifyChanges(dataPointID, changes, options) + .then(function (result) { + if (event.data.force_save) { + return self.model.save(dataPointID).then(function () { + return self._confirmSave(dataPointID); + }).guardedCatch(function () { + return self._rejectSave(dataPointID); + }); + } else if (options.notifyChange !== false) { + return self._confirmChange(dataPointID, result, event); + } + }); + }, + /** + * This method will be called whenever a field value has changed (and has + * been confirmed by the model). + * + * @abstract + * @param {string} id basicModel Id for the changed record + * @param {string[]} fields the fields (names) that have been changed + * @param {OdooEvent} event the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, event) { + return Promise.resolve(); + }, + /** + * This method will be called whenever a save has been triggered by a change + * in some controlled field value. For example, when a priority widget is + * being changed in a readonly form. + * + * @see _onFieldChanged + * @abstract + * @param {string} id The basicModel ID for the saved record + * @returns {Promise} + */ + _confirmSave: function (id) { + return Promise.resolve(); + }, + /** + * This method will be called whenever a save has been triggered by a change + * and has failed. For example, when a statusbar button is clicked in a + * readonly form view. + * + * @abstract + * @private + * @param {string} id The basicModel ID for the saved record + * @returns {Deferred} + */ + _rejectSave: function (id) { + return Promise.resolve(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This is the main job of the FMM: deciding what to do when a controlled + * field changes. Most of the time, it notifies the model that a change + * just occurred, then confirm the change. + * + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + // in case of field changed in relational record (e.g. in the form view + // of a one2many subrecord), the field_changed event must be stopped as + // soon as is it handled by a field_manager (i.e. the one of the + // subrecord's form view), otherwise it bubbles up to the main form view + // but its model doesn't have any data related to the given dataPointID + event.stopPropagation(); + return this._applyChanges(event.data.dataPointID, event.data.changes, event) + .then(event.data.onSuccess || function () {}) + .guardedCatch(event.data.onFailure || function () {}); + }, + /** + * Some widgets need to trigger a reload of their data. For example, a + * one2many with a pager needs to be able to fetch the next page. To do + * that, it can trigger a load event. This will then ask the model to + * actually reload the data, then call the on_success callback. + * + * @param {OdooEvent} event + * @param {number} [event.data.limit] + * @param {number} [event.data.offset] + * @param {function} [event.data.on_success] callback + */ + _onLoad: function (event) { + var self = this; + event.stopPropagation(); // prevent other field managers from handling this request + var data = event.data; + if (!data.on_success) { return; } + var params = {}; + if ('limit' in data) { + params.limit = data.limit; + } + if ('offset' in data) { + params.offset = data.offset; + } + this.mutex.exec(function () { + return self.model.reload(data.id, params).then(function (db_id) { + data.on_success(self.model.get(db_id)); + }); + }); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {function} ev.data.action the function to execute in the mutex + */ + _onMutexify: function (ev) { + ev.stopPropagation(); // prevent other field managers from handling this request + this.mutex.exec(ev.data.action); + }, +}; + +return FieldManagerMixin; +}); diff --git a/addons/web/static/src/js/views/file_upload_mixin.js b/addons/web/static/src/js/views/file_upload_mixin.js new file mode 100644 index 00000000..84dddcd9 --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_mixin.js @@ -0,0 +1,234 @@ +odoo.define('web.fileUploadMixin', function (require) { +'use strict'; + +/** + * Mixin to be used in view Controllers to manage uploads and generate progress bars. + * supported views: kanban, list + */ + +const { csrf_token, _t } = require('web.core'); +const ProgressBar = require('web.ProgressBar'); +const ProgressCard = require('web.ProgressCard'); + +const ProgressBarMixin = { + + custom_events: { + progress_bar_abort: '_onProgressBarAbort', + }, + + init() { + /** + * Contains the uploads currently happening, used to attach progress bars. + * e.g: {'fileUploadId45': {progressBar, progressCard, ...params}} + */ + this._fileUploads = {}; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * used to use a mocked version of Xhr in the tests. + * + * @private + * @returns {XMLHttpRequest} + */ + _createXhr() { + return new window.XMLHttpRequest(); + }, + /** + * @private + */ + _getFileUploadRenderOptions() { + return { + predicate: () => true, + targetCallback: undefined, + }; + }, + /** + * @private + * @returns {string} upload route + */ + _getFileUploadRoute() { + return '/web/binary/upload_attachment'; + }, + /** + * @private + * @param {Object} params + * @param {Object[]} params.files + * @param {XMLHttpRequest} params.xhr + */ + _makeFileUpload(params) { + const { files, xhr } = params; + const fileUploadId = _.uniqueId('fileUploadId'); + const formData = new FormData(); + const formDataKeys = this._makeFileUploadFormDataKeys(Object.assign({ fileUploadId }, params)); + + formData.append('csrf_token', csrf_token); + for (const key in formDataKeys) { + if (formDataKeys[key] !== undefined) { + formData.append(key, formDataKeys[key]); + } + } + for (const file of files) { + formData.append('ufile', file); + } + + return { + fileUploadId, + xhr, + title: files.length === 1 + ? files[0].name + : _.str.sprintf(_t("%s Files"), files.length), + type: files.length === 1 ? files[0].type : undefined, + formData, + }; + }, + /** + * @private + * @param {Object} param0 + * @param {string} param0.fileUploadId + * @returns {Object} the list of the form entries of a file upload. + */ + _makeFileUploadFormDataKeys({ fileUploadId }) { + return { + callback: fileUploadId, + }; + }, + /** + * @private + * @param {integer} fileUploadId + */ + async _removeFileUpload(fileUploadId) { + const upload = this._fileUploads[fileUploadId]; + upload.progressCard && upload.progressCard.destroy(); + upload.progressBar && upload.progressBar.destroy(); + delete this._fileUploads[fileUploadId]; + await this.reload(); + }, + /** + * @private + */ + async _renderFileUploads() { + const { predicate, targetCallback } = this._getFileUploadRenderOptions(); + + for (const fileUploadId in this._fileUploads) { + const upload = this._fileUploads[fileUploadId]; + if (!predicate(upload)) { + continue; + } + + if (!upload.progressBar) { + if (!upload.recordId || this.viewType !== 'kanban') { + upload.progressCard = new ProgressCard(this, { + title: upload.title, + type: upload.type, + viewType: this.viewType, + }); + } + upload.progressBar = new ProgressBar(this, { + xhr: upload.xhr, + title: upload.title, + fileUploadId, + }); + } + + let $targetCard; + if (upload.progressCard) { + await upload.progressCard.prependTo(this.renderer.$el); + $targetCard = upload.progressCard.$el; + } else if (targetCallback) { + $targetCard = targetCallback(upload); + } + await upload.progressBar.appendTo($targetCard); + } + }, + /** + * @private + * @param {Object[]} files + * @param {Object} [params] optional additional data + */ + async _uploadFiles(files, params={}) { + if (!files || !files.length) { return; } + + await new Promise(resolve => { + const xhr = this._createXhr(); + xhr.open('POST', this._getFileUploadRoute()); + const fileUploadData = this._makeFileUpload(Object.assign({ files, xhr }, params)); + const { fileUploadId, formData } = fileUploadData; + this._fileUploads[fileUploadId] = fileUploadData; + xhr.upload.addEventListener("progress", ev => { + this._updateFileUploadProgress(fileUploadId, ev); + }); + const progressPromise = this._onBeforeUpload(); + xhr.onload = async () => { + await progressPromise; + resolve(); + this._onUploadLoad({ fileUploadId, xhr }); + }; + xhr.onerror = async () => { + await progressPromise; + resolve(); + this._onUploadError({ fileUploadId, xhr }); + }; + xhr.send(formData); + }); + }, + /** + * @private + * @param {string} fileUploadId + * @param {ProgressEvent} ev + */ + _updateFileUploadProgress(fileUploadId, ev) { + const { progressCard, progressBar } = this._fileUploads[fileUploadId]; + progressCard && progressCard.update(ev.loaded, ev.total); + progressBar && progressBar.update(ev.loaded, ev.total); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Hook to customize the behaviour of _uploadFiles() before an upload is made. + * + * @private + */ + async _onBeforeUpload() { + await this._renderFileUploads(); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {integer} ev.data.fileUploadId + */ + _onProgressBarAbort(ev) { + this._removeFileUpload(ev.data.fileUploadId); + }, + /** + * Hook to customize the behaviour of the xhr.onload of an upload. + * + * @private + * @param {string} param0.fileUploadId + */ + _onUploadLoad({ fileUploadId }) { + this._removeFileUpload(fileUploadId); + }, + /** + * Hook to customize the behaviour of the xhr.onerror of an upload. + * + * @private + * @param {string} param1.fileUploadId + * @param {XMLHttpRequest} param0.xhr + */ + _onUploadError({ fileUploadId, xhr }) { + this.do_notify(xhr.status, _.str.sprintf(_t('message: %s'), xhr.reponseText), true); + this._removeFileUpload(fileUploadId); + }, + +}; + +return ProgressBarMixin; + +}); diff --git a/addons/web/static/src/js/views/file_upload_progress_bar.js b/addons/web/static/src/js/views/file_upload_progress_bar.js new file mode 100644 index 00000000..6c938e2d --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_progress_bar.js @@ -0,0 +1,76 @@ +odoo.define('web.ProgressBar', function (require) { +'use strict'; + +const { _t } = require('web.core'); +const Dialog = require('web.Dialog'); +const Widget = require('web.Widget'); + +const ProgressBar = Widget.extend({ + template: 'web.FileUploadProgressBar', + + events: { + 'click .o_upload_cross': '_onClickCross', + }, + + /** + * @override + * @param {Object} param1 + * @param {String} param1.title + * @param {String} param1.fileUploadId + * @param {XMLHttpRequest} param2.xhr + */ + init(parent, { title, fileUploadId, xhr }) { + this._super(...arguments); + this.title = title; + this.fileUploadId = fileUploadId; + this.xhr = xhr; + }, + + /** + * @override + * @return {Promise} + */ + start() { + this.xhr.onabort = () => this.do_notify(false, _t("Upload cancelled")); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {integer} loaded + * @param {integer} total + */ + update(loaded, total) { + if (!this.$el) { + return; + } + const percent = Math.round((loaded / total) * 100); + this.$('.o_file_upload_progress_bar_value').css("width", percent + "%"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onClickCross(ev) { + ev.stopPropagation(); + const promptText = _.str.sprintf(_t("Do you really want to cancel the upload of %s?"), _.escape(this.title)); + Dialog.confirm(this, promptText, { + confirm_callback: () => { + this.xhr.abort(); + this.trigger_up('progress_bar_abort', { fileUploadId: this.fileUploadId }); + } + }); + }, +}); + +return ProgressBar; + +}); diff --git a/addons/web/static/src/js/views/file_upload_progress_card.js b/addons/web/static/src/js/views/file_upload_progress_card.js new file mode 100644 index 00000000..1f1db52e --- /dev/null +++ b/addons/web/static/src/js/views/file_upload_progress_card.js @@ -0,0 +1,52 @@ +odoo.define('web.ProgressCard', function (require) { +'use strict'; + +const { _t } = require('web.core'); +const Widget = require('web.Widget'); + +const ProgressCard = Widget.extend({ + template: 'web.ProgressCard', + + /** + * @override + * @param {Object} param1 + * @param {String} param1.title + * @param {String} param1.type file mimetype + * @param {String} param1.viewType + */ + init(parent, { title, type, viewType }) { + this._super(...arguments); + this.title = title; + this.type = type; + this.viewType = viewType; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {integer} loaded + * @param {integer} total + */ + update(loaded, total) { + if (!this.$el) { + return; + } + const percent = Math.round((loaded / total) * 100); + const $textDivLeft = this.$('.o_file_upload_progress_text_left'); + const $textDivRight = this.$('.o_file_upload_progress_text_right'); + if (percent === 100) { + $textDivLeft.text(_t('Processing...')); + } else { + const mbLoaded = Math.round(loaded/1000000); + const mbTotal = Math.round(total/1000000); + $textDivLeft.text(_.str.sprintf(_t("Uploading... (%s%%)"), percent)); + $textDivRight.text(_.str.sprintf(_t("(%s/%sMb)"), mbLoaded, mbTotal)); + } + }, +}); + +return ProgressCard; + +}); diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js new file mode 100644 index 00000000..323f7a75 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -0,0 +1,691 @@ +odoo.define('web.FormController', function (require) { +"use strict"; + +var BasicController = require('web.BasicController'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dialogs = require('web.view_dialogs'); + +var _t = core._t; +var qweb = core.qweb; + +var FormController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + button_clicked: '_onButtonClicked', + edited_list: '_onEditedList', + open_one2many_record: '_onOpenOne2ManyRecord', + open_record: '_onOpenRecord', + toggle_column_order: '_onToggleColumnOrder', + focus_control_button: '_onFocusControlButton', + form_dialog_discarded: '_onFormDialogDiscarded', + }), + /** + * @override + * + * @param {boolean} params.hasActionMenus + * @param {Object} params.toolbarActions + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.actionButtons = params.actionButtons; + this.disableAutofocus = params.disableAutofocus; + this.footerToButtons = params.footerToButtons; + this.defaultButtons = params.defaultButtons; + this.hasActionMenus = params.hasActionMenus; + this.toolbarActions = params.toolbarActions || {}; + }, + /** + * Called each time the form view is attached into the DOM + * + * @todo convert to new style + */ + on_attach_callback: function () { + this._super.apply(this, arguments); + this.autofocus(); + }, + /** + * This hook is called when a form view is restored (by clicking on the + * breadcrumbs). In general, we force mode back to readonly, because + * whenever we leave a form view by stacking another action on the top of + * it, it is saved, and should no longer be in edit mode. However, there is + * a special case for new records for which we still want to be in 'edit' + * as no record has been created (changes have been discarded before + * leaving). + * + * @override + */ + willRestore: function () { + this.mode = this.model.isNew(this.handle) ? 'edit' : 'readonly'; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Calls autofocus on the renderer + */ + autofocus: function () { + if (!this.disableAutofocus) { + var isControlActivted = this.renderer.autofocus(); + if (!isControlActivted) { + // this can happen in read mode if there are no buttons with + // btn-primary class + if (this.$buttons && this.mode === 'readonly') { + return this.$buttons.find('.o_form_button_edit').focus(); + } + } + } + }, + /** + * This method switches the form view in edit mode, with a new record. + * + * @todo make record creation a basic controller feature + * @param {string} [parentID] if given, the parentID will be used as parent + * for the new record. + * @param {Object} [additionalContext] + * @returns {Promise} + */ + createRecord: async function (parentID, additionalContext) { + const record = this.model.get(this.handle, { raw: true }); + const handle = await this.model.load({ + context: record.getContext({ additionalContext: additionalContext}), + fields: record.fields, + fieldsInfo: record.fieldsInfo, + modelName: this.modelName, + parentID: parentID, + res_ids: record.res_ids, + type: 'record', + viewType: 'form', + }); + this.handle = handle; + this._updateControlPanel(); + return this._setMode('edit'); + }, + /** + * Returns the current res_id, wrapped in a list. This is only used by the + * action menus (and the debugmanager) + * + * @override + * + * @returns {number[]} either [current res_id] or [] + */ + getSelectedIds: function () { + var env = this.model.get(this.handle, {env: true}); + return env.currentId ? [env.currentId] : []; + }, + /** + * @override method from AbstractController + * @returns {string} + */ + getTitle: function () { + return this.model.getName(this.handle); + }, + /** + * Add the current ID to the state pushed in the url. + * + * @override + */ + getState: function () { + const state = this._super.apply(this, arguments); + const env = this.model.get(this.handle, {env: true}); + state.id = env.currentId; + return state; + }, + /** + * Render buttons for the control panel. The form view can be rendered in + * a dialog, and in that case, if we have buttons defined in the footer, we + * have to use them instead of the standard buttons. + * + * @override method from AbstractController + * @param {jQuery} [$node] + */ + renderButtons: function ($node) { + var $footer = this.footerToButtons ? this.renderer.$el && this.renderer.$('footer') : null; + var mustRenderFooterButtons = $footer && $footer.length; + if ((this.defaultButtons && !this.$buttons) || mustRenderFooterButtons) { + this.$buttons = $('<div/>'); + 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', + }); + } + } + if (this.$buttons && $node) { + this.$buttons.appendTo($node); + } + }, + /** + * The form view has to prevent a click on the pager if the form is dirty + * + * @override method from BasicController + * @param {jQueryElement} $node + * @param {Object} options + * @returns {Promise} + */ + _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), + }); + }, + /** + * @override + * @private + **/ + _getActionMenuItems: function (state) { + if (!this.hasActionMenus || this.mode === 'edit') { + return null; + } + const props = this._super(...arguments); + const activeField = this.model.getActiveField(state); + const otherActionItems = []; + if (this.archiveEnabled && activeField in state.data) { + if (state.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), + }); + }, + }); + } else { + otherActionItems.push({ + description: _t("Unarchive"), + callback: () => this._toggleArchiveState(false), + }); + } + } + if (this.activeActions.create && this.activeActions.duplicate) { + otherActionItems.push({ + description: _t("Duplicate"), + callback: () => this._onDuplicateRecord(this), + }); + } + 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 + * field, the notification provides a link to edit the field's translations. + * + * @override + */ + 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 = 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 && fieldData !== '<p><br></p>') { + alertFields[changedFields[k]] = field; + } + } + if (!_.isEmpty(alertFields)) { + this.renderer.updateAlertFields(alertFields); + } + } + return changedFields; + }, + /** + * Overrides to force the viewType to 'form', so that we ensure that the + * correct fields are reloaded (this is only useful for one2many form views). + * + * @override + */ + 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); + 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); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _applyChanges: async function () { + const result = await this._super.apply(this, arguments); + core.bus.trigger('DOM_updated'); + return result; + }, + /** + * Assign on the buttons save and discard additionnal behavior to facilitate + * the work of the users doing input only using the keyboard + * + * @param {jQueryElement} $saveCancelButtonContainer The div containing the + * save and cancel buttons + * @private + */ + _assignSaveCancelKeyboardBehavior: function ($saveCancelButtonContainer) { + var self = this; + $saveCancelButtonContainer.children().on('keydown', function (e) { + switch(e.which) { + case $.ui.keyCode.ENTER: + e.preventDefault(); + self.saveRecord(); + break; + case $.ui.keyCode.ESCAPE: + e.preventDefault(); + self._discardChanges(); + break; + case $.ui.keyCode.TAB: + if (!e.shiftKey && e.target.classList.contains('btn-primary')) { + $saveCancelButtonContainer.tooltip('show'); + e.preventDefault(); + } + break; + } + }); + }, + /** + * When a save operation has been confirmed from the model, this method is + * called. + * + * @private + * @override method from field manager mixin + * @param {string} id - id of the previously changed record + * @returns {Promise} + */ + _confirmSave: function (id) { + if (id === this.handle) { + if (this.mode === 'readonly') { + return this.reload(); + } else { + return this._setMode('readonly'); + } + } else { + // A subrecord has changed, so update the corresponding relational field + // i.e. the one whose value is a record with the given id or a list + // having a record with the given id in its data + var record = this.model.get(this.handle); + + // Callback function which returns true + // if a value recursively contains a record with the given id. + // This will be used to determine the list of fields to reload. + var containsChangedRecord = function (value) { + return _.isObject(value) && + (value.id === id || _.find(value.data, containsChangedRecord)); + }; + + var changedFields = _.findKey(record.data, containsChangedRecord); + return this.renderer.confirmChange(record, record.id, [changedFields]); + } + }, + /** + * Override to disable buttons in the renderer. + * + * @override + * @private + */ + _disableButtons: function () { + this._super.apply(this, arguments); + this.renderer.disableButtons(); + }, + /** + * Override to enable buttons in the renderer. + * + * @override + * @private + */ + _enableButtons: function () { + this._super.apply(this, arguments); + this.renderer.enableButtons(); + }, + /** + * Hook method, called when record(s) has been deleted. + * + * @override + */ + _onDeletedRecords: function () { + var state = this.model.get(this.handle, {raw: true}); + if (!state.res_ids.length) { + this.trigger_up('history_back'); + } else { + this._super.apply(this, arguments); + } + }, + /** + * Overrides to reload the form when saving failed in readonly (e.g. after + * a change on a widget like priority or statusbar). + * + * @override + * @private + */ + _rejectSave: function () { + if (this.mode === 'readonly') { + return this.reload(); + } + return this._super.apply(this, arguments); + }, + /** + * Calls unfreezeOrder when changing the mode. + * Also, when there is a change of mode, the tracking of last activated + * field is reset, so that the following field activation process starts + * with the 1st field. + * + * @override + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) === this.handle) { + this.model.unfreezeOrder(this.handle); + } + if (this.mode !== mode) { + this.renderer.resetLastActivatedField(); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + _shouldBounceOnClick(element) { + return this.mode === 'readonly' && !!element.closest('.oe_title, .o_inner_group'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + // stop the event's propagation as a form controller might have other + // form controllers in its descendants (e.g. in a FormViewDialog) + ev.stopPropagation(); + var self = this; + var def; + + this._disableButtons(); + + function saveAndExecuteAction () { + return self.saveRecord(self.handle, { + stayInEdit: true, + }).then(function () { + // we need to reget the record to make sure we have changes made + // by the basic model, such as the new res_id, if the record is + // new. + var record = self.model.get(ev.data.record.id); + return self._callButtonAction(attrs, record); + }); + } + var attrs = ev.data.attrs; + if (attrs.confirm) { + def = new Promise(function (resolve, reject) { + Dialog.confirm(self, attrs.confirm, { + confirm_callback: saveAndExecuteAction, + }).on("closed", null, resolve); + }); + } else if (attrs.special === 'cancel') { + def = this._callButtonAction(attrs, ev.data.record); + } else if (!attrs.special || attrs.special === 'save') { + // save the record but don't switch to readonly mode + def = saveAndExecuteAction(); + } else { + console.warn('Unhandled button event', ev); + return; + } + + // Kind of hack for FormViewDialog: button on footer should trigger the dialog closing + // if the `close` attribute is set + def.then(function () { + self._enableButtons(); + if (attrs.close) { + self.trigger_up('close_dialog'); + } + }).guardedCatch(this._enableButtons.bind(this)); + }, + /** + * Called when the user wants to create a new record -> @see createRecord + * + * @private + */ + _onCreate: function () { + this.createRecord(); + }, + /** + * Deletes the current record + * + * @private + */ + _onDeleteRecord: function () { + this._deleteRecords([this.handle]); + }, + /** + * Called when the user wants to discard the changes made to the current + * record -> @see discardChanges + * + * @private + */ + _onDiscard: function () { + this._disableButtons(); + this._discardChanges() + .then(this._enableButtons.bind(this)) + .guardedCatch(this._enableButtons.bind(this)); + }, + /** + * Called when the user clicks on 'Duplicate Record' in the action menus + * + * @private + */ + _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 + * + * @private + */ + _onEdit: function () { + this._disableButtons(); + // wait for potential pending changes to be saved (done with widgets + // allowing to edit in readonly) + this.mutex.getUnlockedDef() + .then(this._setMode.bind(this, 'edit')) + .then(this._enableButtons.bind(this)) + .guardedCatch(this._enableButtons.bind(this)); + }, + /** + * This method is called when someone tries to freeze the order, most likely + * in a x2many list view + * + * @private + * @param {OdooEvent} ev + * @param {integer} ev.id of the list to freeze while editing a line + */ + _onEditedList: function (ev) { + ev.stopPropagation(); + if (ev.data.id) { + this.model.save(ev.data.id, {savePoint: true}); + } + this.model.freezeOrder(ev.data.id); + }, + /** + * Set the focus on the first primary button of the controller (likely Edit) + * + * @private + * @param {OdooEvent} event + */ + _onFocusControlButton:function(e) { + if (this.$buttons) { + e.stopPropagation(); + this.$buttons.find('.btn-primary:visible:first()').focus(); + } + }, + /** + * Reset the focus on the control that openned a Dialog after it was closed + * + * @private + * @param {OdooEvent} event + */ + _onFormDialogDiscarded: function(ev) { + ev.stopPropagation(); + var isFocused = this.renderer.focusLastActivatedWidget(); + if (ev.data.callback) { + ev.data.callback(_.str.toBool(isFocused)); + } + }, + /** + * Opens a one2many record (potentially new) in a dialog. This handler is + * o2m specific as in this case, the changes done on the related record + * shouldn't be saved in DB when the user clicks on 'Save' in the dialog, + * but later on when he clicks on 'Save' in the main form view. For this to + * work correctly, the main model and the local id of the opened record must + * be given to the dialog, which will complete the viewInfo of the record + * with the one of the form view. + * + * @private + * @param {OdooEvent} ev + */ + _onOpenOne2ManyRecord: async function (ev) { + ev.stopPropagation(); + var data = ev.data; + var record; + if (data.id) { + record = this.model.get(data.id, {raw: true}); + } + + // Sync with the mutex to wait for potential onchanges + await this.model.mutex.getUnlockedDef(); + + new dialogs.FormViewDialog(this, { + context: data.context, + domain: data.domain, + fields_view: data.fields_view, + model: this.model, + on_saved: data.on_saved, + on_remove: data.on_remove, + parentID: data.parentID, + readonly: data.readonly, + deletable: record ? data.deletable : false, + recordID: record && record.id, + res_id: record && record.res_id, + res_model: data.field.relation, + shouldSaveLocally: true, + title: (record ? _t("Open: ") : _t("Create ")) + (ev.target.string || data.field.string), + }).open(); + }, + /** + * Open an existing record in a form view dialog + * + * @private + * @param {OdooEvent} ev + */ + _onOpenRecord: function (ev) { + ev.stopPropagation(); + var self = this; + var record = this.model.get(ev.data.id, {raw: true}); + new dialogs.FormViewDialog(self, { + context: ev.data.context, + fields_view: ev.data.fields_view, + on_saved: ev.data.on_saved, + on_remove: ev.data.on_remove, + readonly: ev.data.readonly, + deletable: ev.data.deletable, + res_id: record.res_id, + res_model: record.model, + title: _t("Open: ") + ev.data.string, + }).open(); + }, + /** + * Called when the user wants to save the current record -> @see saveRecord + * + * @private + * @param {MouseEvent} ev + */ + _onSave: function (ev) { + ev.stopPropagation(); // Prevent x2m lines to be auto-saved + this._disableButtons(); + this.saveRecord().then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }, + /** + * This method is called when someone tries to sort a column, most likely + * in a x2many list view + * + * @private + * @param {OdooEvent} ev + */ + _onToggleColumnOrder: function (ev) { + ev.stopPropagation(); + var self = this; + this.model.setSort(ev.data.id, ev.data.name).then(function () { + var field = ev.data.field; + var state = self.model.get(self.handle); + self.renderer.confirmChange(state, state.id, [field]); + }); + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the action menus. + * + * @private + * @param {boolean} archive + */ + _toggleArchiveState: function (archive) { + const resIds = this.model.localIdsToResIds([this.handle]); + this._archive(resIds, archive); + }, +}); + +return FormController; + +}); diff --git a/addons/web/static/src/js/views/form/form_renderer.js b/addons/web/static/src/js/views/form/form_renderer.js new file mode 100644 index 00000000..e4c1b187 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -0,0 +1,1211 @@ +odoo.define('web.FormRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dom = require('web.dom'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +// symbol used as key to set the <field> node id on its widget +const symbol = Symbol('form'); + +var FormRenderer = BasicRenderer.extend({ + className: "o_form_view", + events: _.extend({}, BasicRenderer.prototype.events, { + 'click .o_notification_box .oe_field_translate': '_onTranslate', + 'click .o_notification_box .close': '_onTranslateNotificationClose', + 'shown.bs.tab a[data-toggle="tab"]': '_onNotebookTabChanged', + }), + custom_events: _.extend({}, BasicRenderer.prototype.custom_events, { + 'navigation_move':'_onNavigationMove', + 'activate_next_widget' : '_onActivateNextWidget', + }), + // default col attributes for the rendering of groups + INNER_GROUP_COL: 2, + OUTER_GROUP_COL: 2, + + /** + * @override + * @param {Object} params.fieldIdsToNames maps <field> node ids to field names + * (useful when there are several occurrences of the same field in the arch) + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.fieldIdsToNames = params.fieldIdsToNames; + this.idsForLabels = {}; + this.lastActivatedFieldIndex = -1; + this.alertFields = {}; + // The form renderer doesn't render invsible fields (invisible="1") by + // default, to speed up the rendering. However, we sometimes have to + // display them (e.g. in Studio, in "show invisible" mode). This flag + // allows to disable this optimization. + this.renderInvisible = false; + }, + /** + * @override + */ + start: function () { + this._applyFormSizeClass(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Focuses the field having attribute 'default_focus' set, if any, or the + * first focusable field otherwise. + * In read mode, delegate which button to give the focus to, to the form_renderer + * + * @returns {int | undefined} the index of the widget activated else + * undefined + */ + autofocus: function () { + if (this.mode === 'readonly') { + var firstPrimaryFormButton = this.$el.find('button.btn-primary:enabled:visible:first()'); + if (firstPrimaryFormButton.length > 0) { + return firstPrimaryFormButton.focus(); + } else { + return; + } + } + var focusWidget = this.defaultFocusField; + if (!focusWidget || !focusWidget.isFocusable()) { + var widgets = this.allFieldWidgets[this.state.id]; + for (var i = 0; i < (widgets ? widgets.length : 0); i++) { + var widget = widgets[i]; + if (widget.isFocusable()) { + focusWidget = widget; + break; + } + } + } + if (focusWidget) { + return focusWidget.activate({noselect: true, noAutomaticCreate: true}); + } + }, + /** + * Extend the method so that labels also receive the 'o_field_invalid' class + * if necessary. + * + * @override + * @see BasicRenderer.canBeSaved + * @param {string} recordID + * @returns {string[]} + */ + canBeSaved: function () { + var fieldNames = this._super.apply(this, arguments); + + var $labels = this.$('label'); + $labels.removeClass('o_field_invalid'); + + const allWidgets = this.allFieldWidgets[this.state.id] || []; + const widgets = allWidgets.filter(w => fieldNames.includes(w.name)); + for (const widget of widgets) { + const idForLabel = this.idsForLabels[widget[symbol]]; + if (idForLabel) { + $labels + .filter('[for=' + idForLabel + ']') + .addClass('o_field_invalid'); + } + } + return fieldNames; + }, + /* + * Updates translation alert fields for the current state and display updated fields + * + * @param {Object} alertFields + */ + updateAlertFields: function (alertFields) { + this.alertFields[this.state.res_id] = _.extend(this.alertFields[this.state.res_id] || {}, alertFields); + this.displayTranslationAlert(); + }, + /** + * Show a warning message if the user modified a translated field. For each + * field, the notification provides a link to edit the field's translations. + */ + displayTranslationAlert: function () { + this.$('.o_notification_box').remove(); + if (this.alertFields[this.state.res_id]) { + var $notification = $(qweb.render('notification-box', {type: 'info'})) + .append(qweb.render('translation-alert', { + fields: this.alertFields[this.state.res_id], + lang: _t.database.parameters.name + })); + if (this.$('.o_form_statusbar').length) { + this.$('.o_form_statusbar').after($notification); + } else if (this.$('.o_form_sheet_bg').length) { + this.$('.o_form_sheet_bg').prepend($notification); + } else { + this.$el.prepend($notification); + } + } + }, + /** + * @see BasicRenderer.confirmChange + * + * We need to reapply the idForLabel postprocessing since some widgets may + * have recomputed their dom entirely. + * + * @override + */ + confirmChange: function () { + var self = this; + return this._super.apply(this, arguments).then(function (resetWidgets) { + _.each(resetWidgets, function (widget) { + self._setIDForLabel(widget, self.idsForLabels[widget[symbol]]); + }); + if (self.$('.o_field_invalid').length) { + self.canBeSaved(self.state.id); + } + return resetWidgets; + }); + }, + /** + * Disable statusbar buttons and stat buttons so that they can't be clicked anymore + * + */ + disableButtons: function () { + this.$('.o_statusbar_buttons button, .oe_button_box button') + .attr('disabled', true); + }, + /** + * Enable statusbar buttons and stat buttons so they can be clicked again + * + */ + enableButtons: function () { + this.$('.o_statusbar_buttons button, .oe_button_box button') + .removeAttr('disabled'); + }, + /** + * Put the focus on the last activated widget. + * This function is used when closing a dialog to give the focus back to the + * form that has opened it and ensures that the focus is in the correct + * field. + */ + focusLastActivatedWidget: function () { + if (this.lastActivatedFieldIndex !== -1) { + return this._activateNextFieldWidget(this.state, this.lastActivatedFieldIndex - 1, + { noAutomaticCreate: true }); + } + return false; + }, + /** + * returns the active tab pages for each notebook + * + * @todo currently, this method is unused... + * + * @see setLocalState + * @returns {Object} a map from notebook name to the active tab index + */ + getLocalState: function () { + const state = {}; + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + const name = notebook.dataset.name; + const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item > .nav-link'); + state[name] = Math.max([...navs].findIndex( + nav => nav.classList.contains('active') + ), 0); + } + return state; + }, + /** + * Reset the tracking of the last activated field. The fast entry with + * keyboard navigation needs to track the last activated field in order to + * set the focus. + * + * In particular, when there are changes of mode (e.g. edit -> readonly -> + * edit), we do not want to auto-set the focus on the previously last + * activated field. To avoid this issue, this method should be called + * whenever there is a change of mode. + */ + resetLastActivatedField: function () { + this.lastActivatedFieldIndex = -1; + }, + /** + * Resets state which stores information like scroll position, curently + * active page, ... + * + * @override + */ + resetLocalState() { + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + [...notebook.querySelectorAll(':scope .o_notebook_headers .nav-item .nav-link')] + .map(nav => nav.classList.remove('active')); + [...notebook.querySelectorAll(':scope .tab-content > .tab-pane')] + .map(tab => tab.classList.remove('active')); + } + + }, + /** + * Restore active tab pages for each notebook. It relies on the implicit fact + * that each nav header corresponds to a tab page. + * + * @param {Object} state the result from a getLocalState call + */ + setLocalState: function (state) { + for (const notebook of this.el.querySelectorAll(':scope div.o_notebook')) { + if (notebook.closest(".o_field_widget")) { + continue; + } + const name = notebook.dataset.name; + if (name in state) { + const navs = notebook.querySelectorAll(':scope .o_notebook_headers .nav-item'); + const pages = notebook.querySelectorAll(':scope > .tab-content > .tab-pane'); + // We can't base the amount on the 'navs' length since some overrides + // are adding pageless nav items. + const validTabsAmount = pages.length; + if (!validTabsAmount) { + continue; // No page defined on the notebook. + } + let activeIndex = state[name]; + if (navs[activeIndex].classList.contains('o_invisible_modifier')) { + activeIndex = [...navs].findIndex( + nav => !nav.classList.contains('o_invisible_modifier') + ); + } + if (activeIndex <= 0) { + continue; // No visible tab OR first tab = active tab (no change to make). + } + for (let i = 0; i < validTabsAmount; i++) { + navs[i].querySelector('.nav-link').classList.toggle('active', activeIndex === i); + pages[i].classList.toggle('active', activeIndex === i); + } + core.bus.trigger('DOM_updated'); + } + } + }, + /** + * @override method from AbstractRenderer + * @param {Object} state a valid state given by the model + * @param {Object} params + * @param {string} [params.mode] new mode, either 'edit' or 'readonly' + * @param {string[]} [params.fieldNames] if given, the renderer will only + * update the fields in this list + * @returns {Promise} + */ + updateState: function (state, params) { + this._setState(state); + this.mode = (params && 'mode' in params) ? params.mode : this.mode; + + // if fieldNames are given, we update the corresponding field widget. + // I think this is wrong, and the caller could directly call the + // confirmChange method + if (params.fieldNames) { + // only update the given fields + return this.confirmChange(this.state, this.state.id, params.fieldNames); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Activates the first visible tab from a given list of tab objects. The + * first tab having an "autofocus" attribute set will be focused in + * priority. + * + * @private + * @param {Object[]} tabs + */ + _activateFirstVisibleTab(tabs) { + const visibleTabs = tabs.filter( + (tab) => !tab.$header.hasClass("o_invisible_modifier") + ); + const autofocusTab = visibleTabs.findIndex( + (tab) => tab.node.attrs.autofocus === "autofocus" + ); + const tabToFocus = visibleTabs[Math.max(0, autofocusTab)]; + if (tabToFocus) { + tabToFocus.$header.find('.nav-link').addClass('active'); + tabToFocus.$page.addClass('active'); + } + }, + /** + * @override + */ + _activateNextFieldWidget: function (record, currentIndex) { + //if we are the last widget, we should give the focus to the first Primary Button in the form + //else do the default behavior + if ( (currentIndex + 1) >= (this.allFieldWidgets[record.id] || []).length) { + this.trigger_up('focus_control_button'); + this.lastActivatedFieldIndex = -1; + } else { + var activatedIndex = this._super.apply(this, arguments); + if (activatedIndex === -1 ) { // no widget have been activated, we should go to the edit/save buttons + this.trigger_up('focus_control_button'); + this.lastActivatedFieldIndex = -1; + } + else { + this.lastActivatedFieldIndex = activatedIndex; + } + } + return this.lastActivatedFieldIndex; + }, + /** + * Add a tooltip on a button + * + * @private + * @param {Object} node + * @param {jQuery} $button + */ + _addButtonTooltip: function (node, $button) { + var self = this; + $button.tooltip({ + title: function () { + return qweb.render('WidgetButton.tooltip', { + debug: config.isDebug(), + state: self.state, + node: node, + }); + }, + }); + }, + /** + * @private + * @param {jQueryElement} $el + * @param {Object} node + */ + _addOnClickAction: function ($el, node) { + if (node.attrs.special || node.attrs.confirm || node.attrs.type || $el.hasClass('oe_stat_button')) { + var self = this; + $el.on("click", function () { + self.trigger_up('button_clicked', { + attrs: node.attrs, + record: self.state, + }); + }); + } + }, + _applyFormSizeClass: function () { + const formEl = this.$el[0]; + if (config.device.size_class <= config.device.SIZES.XS) { + formEl.classList.add('o_xxs_form_view'); + } else { + formEl.classList.remove('o_xxs_form_view'); + } + if (config.device.size_class === config.device.SIZES.XXL) { + formEl.classList.add('o_xxl_form_view'); + } else { + formEl.classList.remove('o_xxl_form_view'); + } + }, + /** + * @private + * @param {string} uid a <field> node id + * @returns {string} + */ + _getIDForLabel: function (uid) { + if (!this.idsForLabels[uid]) { + this.idsForLabels[uid] = _.uniqueId('o_field_input_'); + } + return this.idsForLabels[uid]; + }, + /** + * @override + * @private + */ + _getRecord: function (recordId) { + return this.state.id === recordId ? this.state : null; + }, + /** + * @override + * @private + */ + _postProcessField: function (widget, node) { + this._super.apply(this, arguments); + // set the node id on the widget, as it might be necessary later (tooltips, confirmChange...) + widget[symbol] = node.attrs.id; + this._setIDForLabel(widget, this._getIDForLabel(node.attrs.id)); + if (JSON.parse(node.attrs.default_focus || "0")) { + this.defaultFocusField = widget; + } + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderButtonBox: function (node) { + var self = this; + var $result = $('<' + node.tag + '>', {class: 'o_not_full'}); + + // The rendering of buttons may be async (see renderFieldWidget), so we + // must wait for the buttons to be ready (and their modifiers to be + // applied) before manipulating them, as we check if they are visible or + // not. To do so, we extract from this.defs the promises corresponding + // to the buttonbox buttons, and wait for them to be resolved. + var nextDefIndex = this.defs.length; + var buttons = _.map(node.children, function (child) { + if (child.tag === 'button') { + return self._renderStatButton(child); + } else { + return self._renderNode(child); + } + }); + + // At this point, each button is an empty div that will be replaced by + // the real $el of the button when it is ready (with replaceWith). + // However, this only works if the empty div is appended somewhere, so + // we here append them into a wrapper, and unwrap them once they have + // been replaced. + var $tempWrapper = $('<div>'); + _.each(buttons, function ($button) { + $button.appendTo($tempWrapper); + }); + var defs = this.defs.slice(nextDefIndex); + Promise.all(defs).then(function () { + buttons = $tempWrapper.children(); + var buttons_partition = _.partition(buttons, function (button) { + return $(button).is('.o_invisible_modifier'); + }); + var invisible_buttons = buttons_partition[0]; + var visible_buttons = buttons_partition[1]; + + // Get the unfolded buttons according to window size + var nb_buttons = self._renderButtonBoxNbButtons(); + var unfolded_buttons = visible_buttons.slice(0, nb_buttons).concat(invisible_buttons); + + // Get the folded buttons + var folded_buttons = visible_buttons.slice(nb_buttons); + if (folded_buttons.length === 1) { + unfolded_buttons = buttons; + folded_buttons = []; + } + + // Toggle class to tell if the button box is full (CSS requirement) + var full = (visible_buttons.length > nb_buttons); + $result.toggleClass('o_full', full).toggleClass('o_not_full', !full); + + // Add the unfolded buttons + _.each(unfolded_buttons, function (button) { + $(button).appendTo($result); + }); + + // Add the dropdown with folded buttons if any + if (folded_buttons.length) { + $result.append(dom.renderButton({ + attrs: { + 'class': 'oe_stat_button o_button_more dropdown-toggle', + 'data-toggle': 'dropdown', + }, + text: _t("More"), + })); + + var $dropdown = $("<div>", {class: "dropdown-menu o_dropdown_more", role: "menu"}); + _.each(folded_buttons, function (button) { + $(button).addClass('dropdown-item').appendTo($dropdown); + }); + $dropdown.appendTo($result); + } + }); + + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + return $result; + }, + /** + * @private + * @returns {integer} + */ + _renderButtonBoxNbButtons: function () { + return [2, 2, 2, 4][config.device.size_class] || 7; + }, + /** + * Do not render a field widget if it is always invisible. + * + * @override + */ + _renderFieldWidget(node) { + if (!this.renderInvisible && node.attrs.modifiers.invisible === true) { + return $(); + } + return this._super(...arguments); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderGenericTag: function (node) { + var $result = $('<' + node.tag + '>', _.omit(node.attrs, 'modifiers')); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderHeaderButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node); + + // Current API of odoo for rendering buttons is "if classes are given + // use those on top of the 'btn' and 'btn-{size}' classes, otherwise act + // as if 'btn-secondary' class was given". The problem is that, for + // header buttons only, we allowed users to only indicate their custom + // classes without having to explicitely ask for the 'btn-secondary' + // class to be added. We force it so here when no bootstrap btn type + // class is found. + if ($button.not('.btn-primary, .btn-secondary, .btn-link, .btn-success, .btn-info, .btn-warning, .btn-danger').length) { + $button.addClass('btn-secondary'); + } + + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + + // Display tooltip + if (config.isDebug() || node.attrs.help) { + this._addButtonTooltip(node, $button); + } + return $button; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderHeaderButtons: function (node) { + var self = this; + var buttons = []; + _.each(node.children, function (child) { + if (child.tag === 'button') { + buttons.push(self._renderHeaderButton(child)); + } + if (child.tag === 'widget') { + buttons.push(self._renderTagWidget(child)); + } + }); + return this._renderStatusbarButtons(buttons); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroup: function (node) { + var self = this; + var $result = $('<table/>', {class: 'o_group o_inner_group'}); + var $tbody = $('<tbody />').appendTo($result); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + + var col = parseInt(node.attrs.col, 10) || this.INNER_GROUP_COL; + + if (node.attrs.string) { + var $sep = $('<tr><td colspan="' + col + '" style="width: 100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>'); + $result.append($sep); + } + + var rows = []; + var $currentRow = $('<tr/>'); + var currentColspan = 0; + node.children.forEach(function (child) { + if (child.tag === 'newline') { + rows.push($currentRow); + $currentRow = $('<tr/>'); + currentColspan = 0; + return; + } + + var colspan = parseInt(child.attrs.colspan, 10); + var isLabeledField = (child.tag === 'field' && child.attrs.nolabel !== '1'); + if (!colspan) { + if (isLabeledField) { + colspan = 2; + } else { + colspan = 1; + } + } + var finalColspan = colspan - (isLabeledField ? 1 : 0); + currentColspan += colspan; + + if (currentColspan > col) { + rows.push($currentRow); + $currentRow = $('<tr/>'); + currentColspan = colspan; + } + + var $tds; + if (child.tag === 'field') { + $tds = self._renderInnerGroupField(child); + } else if (child.tag === 'label') { + $tds = self._renderInnerGroupLabel(child); + } else { + var $td = $('<td/>'); + var $child = self._renderNode(child); + if ($child.hasClass('o_td_label')) { // transfer classname to outer td for css reasons + $td.addClass('o_td_label'); + $child.removeClass('o_td_label'); + } + $tds = $td.append($child); + } + if (finalColspan > 1) { + $tds.last().attr('colspan', finalColspan); + } + $currentRow.append($tds); + }); + rows.push($currentRow); + + _.each(rows, function ($tr) { + var nonLabelColSize = 100 / (col - $tr.children('.o_td_label').length); + _.each($tr.children(':not(.o_td_label)'), function (el) { + var $el = $(el); + $el.css('width', ((parseInt($el.attr('colspan'), 10) || 1) * nonLabelColSize) + '%'); + }); + $tbody.append($tr); + }); + + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroupField: function (node) { + var $el = this._renderFieldWidget(node, this.state); + var $tds = $('<td/>').append($el); + + if (node.attrs.nolabel !== '1') { + var $labelTd = this._renderInnerGroupLabel(node); + $tds = $labelTd.add($tds); + + // apply the oe_(edit|read)_only className on the label as well + if (/\boe_edit_only\b/.test(node.attrs.class)) { + $tds.addClass('oe_edit_only'); + } + if (/\boe_read_only\b/.test(node.attrs.class)) { + $tds.addClass('oe_read_only'); + } + } + + return $tds; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderInnerGroupLabel: function (node) { + return $('<td/>', {class: 'o_td_label'}) + .append(this._renderTagLabel(node)); + }, + /** + * Render a node, from the arch of the view. It is a generic method, that + * will dispatch on specific other methods. The rendering of a node is a + * jQuery element (or a string), with the correct classes, attrs, and + * content. + * + * For fields, it will return the $el of the field widget. Note that this + * method is synchronous, field widgets are instantiated and appended, but + * if they are asynchronous, they register their promises in this.defs, and + * the _renderView method will properly wait. + * + * @private + * @param {Object} node + * @returns {jQueryElement | string} + */ + _renderNode: function (node) { + var renderer = this['_renderTag' + _.str.capitalize(node.tag)]; + if (renderer) { + return renderer.call(this, node); + } + if (node.tag === 'div' && node.attrs.name === 'button_box') { + return this._renderButtonBox(node); + } + if (_.isString(node)) { + return node; + } + return this._renderGenericTag(node); + }, + /** + * Renders a 'group' node, which contains 'group' nodes in its children. + * + * @param {Object} node] + * @returns {JQueryElement} + */ + _renderOuterGroup: function (node) { + var self = this; + var $result = $('<div/>', {class: 'o_group'}); + var nbCols = parseInt(node.attrs.col, 10) || this.OUTER_GROUP_COL; + var colSize = Math.max(1, Math.round(12 / nbCols)); + if (node.attrs.string) { + var $sep = $('<div/>', {class: 'o_horizontal_separator'}).text(node.attrs.string); + $result.append($sep); + } + $result.append(_.map(node.children, function (child) { + if (child.tag === 'newline') { + return $('<br/>'); + } + var $child = self._renderNode(child); + $child.addClass('o_group_col_' + (colSize * (parseInt(child.attrs.colspan, 10) || 1))); + return $child; + })); + this._handleAttributes($result, node); + this._registerModifiers(node, this.state, $result); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderStatButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node, { + extraClass: 'oe_stat_button', + }); + $button.append(_.map(node.children, this._renderNode.bind(this))); + if (node.attrs.help) { + this._addButtonTooltip(node, $button); + } + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + return $button; + }, + /** + * @private + * @param {Array} buttons + * @return {jQueryElement} + */ + _renderStatusbarButtons: function (buttons) { + var $statusbarButtons = $('<div>', {class: 'o_statusbar_buttons'}); + buttons.forEach(button => $statusbarButtons.append(button)); + return $statusbarButtons; + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabHeader: function (page, page_id) { + var $a = $('<a>', { + 'data-toggle': 'tab', + disable_anchor: 'true', + href: '#' + page_id, + class: 'nav-link', + role: 'tab', + text: page.attrs.string, + }); + return $('<li>', {class: 'nav-item'}).append($a); + }, + /** + * @private + * @param {Object} page + * @param {string} page_id + * @returns {jQueryElement} + */ + _renderTabPage: function (page, page_id) { + var $result = $('<div class="tab-pane" id="' + page_id + '">'); + $result.append(_.map(page.children, this._renderNode.bind(this))); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagButton: function (node) { + var $button = viewUtils.renderButtonFromNode(node); + $button.append(_.map(node.children, this._renderNode.bind(this))); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + this._registerModifiers(node, this.state, $button); + + // Display tooltip + if (config.isDebug() || node.attrs.help) { + this._addButtonTooltip(node, $button); + } + + return $button; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagField: function (node) { + return this._renderFieldWidget(node, this.state); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagForm: function (node) { + var $result = $('<div/>'); + if (node.attrs.class) { + $result.addClass(node.attrs.class); + } + var allNodes = node.children.map(this._renderNode.bind(this)); + $result.append(allNodes); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagGroup: function (node) { + var isOuterGroup = _.some(node.children, function (child) { + return child.tag === 'group'; + }); + if (!isOuterGroup) { + return this._renderInnerGroup(node); + } + return this._renderOuterGroup(node); + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagHeader: function (node) { + var self = this; + var $statusbar = $('<div>', {class: 'o_form_statusbar'}); + $statusbar.append(this._renderHeaderButtons(node)); + _.each(node.children, function (child) { + if (child.tag === 'field') { + var $el = self._renderFieldWidget(child, self.state); + $statusbar.append($el); + } + }); + this._handleAttributes($statusbar, node); + this._registerModifiers(node, this.state, $statusbar); + return $statusbar; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagLabel: function (node) { + if (!this.renderInvisible && node.tag === 'field' && + node.attrs.modifiers.invisible === true) { + // skip rendering of invisible fields/labels + return $(); + } + var self = this; + var text; + let fieldName; + if (node.tag === 'label') { + fieldName = this.fieldIdsToNames[node.attrs.for]; // 'for' references a <field> node id + } else { + fieldName = node.attrs.name; + } + if ('string' in node.attrs) { // allow empty string + text = node.attrs.string; + } else if (fieldName) { + text = this.state.fields[fieldName].string; + } else { + return this._renderGenericTag(node); + } + var $result = $('<label>', { + class: 'o_form_label', + for: this._getIDForLabel(node.tag === 'label' ? node.attrs.for : node.attrs.id), + text: text, + }); + if (node.tag === 'label') { + this._handleAttributes($result, node); + } + var modifiersOptions; + if (fieldName) { + modifiersOptions = { + callback: function (element, modifiers, record) { + var widgets = self.allFieldWidgets[record.id]; + var widget = _.findWhere(widgets, {name: fieldName}); + if (!widget) { + return; // FIXME this occurs if the widget is created + // after the label (explicit <label/> tag in the + // arch), so this won't work on first rendering + // only on reevaluation + } + element.$el.toggleClass('o_form_label_empty', !!( // FIXME condition is evaluated twice (label AND widget...) + record.data.id + && (modifiers.readonly || self.mode === 'readonly') + && !widget.isSet() + )); + }, + }; + } + // FIXME if the function is called with a <label/> node, the registered + // modifiers will be those on this node. Maybe the desired behavior + // would be to merge them with associated field node if any... note: + // this worked in 10.0 for "o_form_label_empty" reevaluation but not for + // "o_invisible_modifier" reevaluation on labels... + this._registerModifiers(node, this.state, $result, modifiersOptions); + return $result; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagNotebook: function (node) { + var self = this; + var $headers = $('<ul class="nav nav-tabs">'); + var $pages = $('<div class="tab-content">'); + // renderedTabs is used to aggregate the generated $headers and $pages + // alongside their node, so that their modifiers can be registered once + // all tabs have been rendered, to ensure that the first visible tab + // is correctly activated + var renderedTabs = _.map(node.children, function (child, index) { + var pageID = _.uniqueId('notebook_page_'); + var $header = self._renderTabHeader(child, pageID); + var $page = self._renderTabPage(child, pageID); + self._handleAttributes($header, child); + $headers.append($header); + $pages.append($page); + return { + $header: $header, + $page: $page, + node: child, + }; + }); + // register the modifiers for each tab + _.each(renderedTabs, function (tab) { + self._registerModifiers(tab.node, self.state, tab.$header, { + callback: function (element, modifiers) { + // if the active tab is invisible, activate the first visible tab instead + var $link = element.$el.find('.nav-link'); + if (modifiers.invisible && $link.hasClass('active')) { + $link.removeClass('active'); + tab.$page.removeClass('active'); + self.inactiveNotebooks.push(renderedTabs); + } + if (!modifiers.invisible) { + // make first page active if there is only one page to display + var $visibleTabs = $headers.find('li:not(.o_invisible_modifier)'); + if ($visibleTabs.length === 1) { + self.inactiveNotebooks.push(renderedTabs); + } + } + }, + }); + }); + this._activateFirstVisibleTab(renderedTabs); + var $notebookHeaders = $('<div class="o_notebook_headers">').append($headers); + var $notebook = $('<div class="o_notebook">').append($notebookHeaders, $pages); + $notebook[0].dataset.name = node.attrs.name || '_default_'; + this._registerModifiers(node, this.state, $notebook); + this._handleAttributes($notebook, node); + return $notebook; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagSeparator: function (node) { + var $separator = $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + this._handleAttributes($separator, node); + this._registerModifiers(node, this.state, $separator); + return $separator; + }, + /** + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagSheet: function (node) { + this.has_sheet = true; + var $sheet = $('<div>', {class: 'clearfix position-relative o_form_sheet'}); + $sheet.append(node.children.map(this._renderNode.bind(this))); + return $sheet; + }, + /** + * Instantiate custom widgets + * + * @private + * @param {Object} node + * @returns {jQueryElement} + */ + _renderTagWidget: function (node) { + return this._renderWidget(this.state, node); + }, + /** + * Main entry point for the rendering. From here, we call _renderNode on + * the root of the arch, then, when every promise (from the field widgets) + * are done, it will resolves itself. + * + * @private + * @override method from BasicRenderer + * @returns {Promise} + */ + _renderView: function () { + var self = this; + + // render the form and evaluate the modifiers + var defs = []; + this.defs = defs; + this.inactiveNotebooks = []; + var $form = this._renderNode(this.arch).addClass(this.className); + delete this.defs; + + return Promise.all(defs).then(() => this.__renderView()).then(function () { + self._updateView($form.contents()); + if (self.state.res_id in self.alertFields) { + self.displayTranslationAlert(); + } + }).then(function(){ + if (self.lastActivatedFieldIndex >= 0) { + self._activateNextFieldWidget(self.state, self.lastActivatedFieldIndex); + } + }).guardedCatch(function () { + $form.remove(); + }); + }, + /** + * Meant to be overridden if asynchronous work needs to be done when + * rendering the view. This is called right before attaching the new view + * content. + * @private + * @returns {Promise<any>} + */ + async __renderView() {}, + /** + * This method is overridden to activate the first notebook page if the + * current active page is invisible due to modifiers. This is done after + * all modifiers are applied on all page elements. + * + * @override + */ + async _updateAllModifiers() { + await this._super(...arguments); + for (const tabs of this.inactiveNotebooks) { + this._activateFirstVisibleTab(tabs); + } + this.inactiveNotebooks = []; + }, + /** + * Updates the form's $el with new content. + * + * @private + * @see _renderView + * @param {JQuery} $newContent + */ + _updateView: function ($newContent) { + var self = this; + + // Set the new content of the form view, and toggle classnames + this.$el.html($newContent); + this.$el.toggleClass('o_form_nosheet', !this.has_sheet); + if (this.has_sheet) { + this.$el.children().not('.o_FormRenderer_chatterContainer') + .wrapAll($('<div/>', {class: 'o_form_sheet_bg'})); + } + this.$el.toggleClass('o_form_editable', this.mode === 'edit'); + this.$el.toggleClass('o_form_readonly', this.mode === 'readonly'); + + // Attach the tooltips on the fields' label + _.each(this.allFieldWidgets[this.state.id], function (widget) { + const idForLabel = self.idsForLabels[widget[symbol]]; + var $label = idForLabel ? self.$('.o_form_label[for=' + idForLabel + ']') : $(); + self._addFieldTooltip(widget, $label); + if (widget.attrs.widget === 'upgrade_boolean') { + // this widget needs a reference to its $label to be correctly + // rendered + widget.renderWithLabel($label); + } + }); + }, + /** + * Sets id attribute of given widget to idForLabel + * + * @private + * @param {AbstractField} widget + * @param {idForLabel} string + */ + _setIDForLabel: function (widget, idForLabel) { + widget.setIDForLabel(idForLabel); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onActivateNextWidget: function (ev) { + ev.stopPropagation(); + var index = this.allFieldWidgets[this.state.id].indexOf(ev.data.target); + this._activateNextFieldWidget(this.state, index); + }, + /** + * @override + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + ev.stopPropagation(); + // We prevent the default behaviour and stop the propagation of the + // originalEvent when the originalEvent is a tab keydown to not let + // the browser do it. The action is done by this renderer. + if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) { + ev.data.originalEvent.preventDefault(); + ev.data.originalEvent.stopPropagation(); + } + var index; + let target = ev.data.target || ev.target; + if (target.__owl__) { + target = target.__owl__.parent; // Owl fields are wrapped by the FieldWrapper + } + if (ev.data.direction === "next") { + index = this.allFieldWidgets[this.state.id].indexOf(target); + this._activateNextFieldWidget(this.state, index); + } else if (ev.data.direction === "previous") { + index = this.allFieldWidgets[this.state.id].indexOf(target); + this._activatePreviousFieldWidget(this.state, index); + } + }, + /** + * Listen to notebook tab changes and trigger a DOM_updated event such that + * widgets in the visible tab can correctly compute their dimensions (e.g. + * autoresize on field text) + * + * @private + */ + _onNotebookTabChanged: function () { + core.bus.trigger('DOM_updated'); + }, + /** + * open the translation view for the current field + * + * @private + * @param {MouseEvent} ev + */ + _onTranslate: function (ev) { + ev.preventDefault(); + this.trigger_up('translate', { + fieldName: ev.target.name, + id: this.state.id, + isComingFromTranslationAlert: true, + }); + }, + /** + * remove alert fields of record from alertFields object + * + * @private + * @param {MouseEvent} ev + */ + _onTranslateNotificationClose: function(ev) { + delete this.alertFields[this.state.res_id]; + }, +}); + +return FormRenderer; +}); diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js new file mode 100644 index 00000000..a7885e0c --- /dev/null +++ b/addons/web/static/src/js/views/form/form_view.js @@ -0,0 +1,201 @@ +odoo.define('web.FormView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var Context = require('web.Context'); +var core = require('web.core'); +var FormController = require('web.FormController'); +var FormRenderer = require('web.FormRenderer'); +const { generateID } = require('web.utils'); + +var _lt = core._lt; + +var FormView = BasicView.extend({ + config: _.extend({}, BasicView.prototype.config, { + Renderer: FormRenderer, + Controller: FormController, + }), + display_name: _lt('Form'), + icon: 'fa-edit', + multi_record: false, + withSearchBar: false, + searchMenuTypes: [], + viewType: 'form', + /** + * @override + */ + init: function (viewInfo, params) { + 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 hasActionMenus value if it was + // not already present in the beginning of this method + if (hasActionMenus === undefined) { + hasActionMenus = params.hasActionMenus; + } + this.controllerParams.hasActionMenus = hasActionMenus; + this.controllerParams.disableAutofocus = params.disable_autofocus || this.arch.attrs.disable_autofocus; + this.controllerParams.toolbarActions = viewInfo.toolbar; + this.controllerParams.footerToButtons = params.footerToButtons; + + var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; + this.controllerParams.defaultButtons = defaultButtons; + this.controllerParams.mode = mode; + + this.rendererParams.mode = mode; + this.rendererParams.isFromFormViewDialog = params.isFromFormViewDialog; + this.rendererParams.fieldIdsToNames = this.fieldsView.fieldIdsToNames; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getController: function (parent) { + return this._loadSubviews(parent).then(this._super.bind(this, parent)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _extractParamsFromAction: function (action) { + var params = this._super.apply(this, arguments); + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + var fullscreen = action.target === 'fullscreen'; + params.withControlPanel = !(inDialog || inline); + params.footerToButtons = inDialog; + params.hasSearchView = inDialog ? false : params.hasSearchView; + params.hasActionMenus = !inDialog && !inline; + params.searchMenuTypes = inDialog ? [] : params.searchMenuTypes; + if (inDialog || inline || fullscreen) { + params.mode = 'edit'; + } else if (action.context && action.context.form_view_initial_mode) { + params.mode = action.context.form_view_initial_mode; + } + return params; + }, + /** + * Loads the subviews for x2many fields when they are not inline + * + * @private + * @param {Widget} parent the parent of the model, if it has to be created + * @returns {Promise} + */ + _loadSubviews: function (parent) { + var self = this; + var defs = []; + if (this.loadParams && this.loadParams.fieldsInfo) { + var fields = this.loadParams.fields; + + _.each(this.loadParams.fieldsInfo.form, function (attrs, fieldName) { + var field = fields[fieldName]; + if (!field) { + // when a one2many record is opened in a form view, the fields + // of the main one2many view (list or kanban) are added to the + // fieldsInfo of its form view, but those fields aren't in the + // loadParams.fields, as they are not displayed in the view, so + // we can ignore them. + return; + } + if (field.type !== 'one2many' && field.type !== 'many2many') { + return; + } + + if (attrs.Widget.prototype.useSubview && !attrs.__no_fetch && !attrs.views[attrs.mode]) { + var context = {}; + var regex = /'([a-z]*_view_ref)' *: *'(.*?)'/g; + var matches; + while (matches = regex.exec(attrs.context)) { + context[matches[1]] = matches[2]; + } + + // Remove *_view_ref coming from parent view + var refinedContext = _.pick(self.loadParams.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); + // Specify the main model to prevent access rights defined in the context + // (e.g. create: 0) to apply to subviews. We use here the same logic as + // the one applied by the server for inline views. + refinedContext.base_model_name = self.controllerParams.modelName; + defs.push(parent.loadViews( + field.relation, + new Context(context, self.userContext, refinedContext).eval(), + [[null, attrs.mode === 'tree' ? 'list' : attrs.mode]]) + .then(function (views) { + for (var viewName in views) { + // clone to make runbot green? + attrs.views[viewName] = self._processFieldsView(views[viewName], viewName); + attrs.views[viewName].fields = attrs.views[viewName].viewFields; + self._processSubViewAttrs(attrs.views[viewName], attrs); + } + self._setSubViewLimit(attrs); + })); + } else { + self._setSubViewLimit(attrs); + } + }); + } + return Promise.all(defs); + }, + /** + * @override + */ + _processArch(arch, fv) { + fv.fieldIdsToNames = {}; // maps field ids (identifying <field> nodes) to field names + return this._super(...arguments); + }, + /** + * Override to populate the 'fieldIdsToNames' dict mapping <field> node ids + * to field names. Those ids are computed as follows: + * - if set on the node, we use the 'id' attribute + * - otherwise + * - if this is the first occurrence of the field in the arch, we use + * its name as id ('name' attribute) + * - otherwise we generate an id by concatenating the field name with + * a unique id + * - in both cases, we set the id we generated in the attrs, as it + * will be used by the renderer. + * + * @override + */ + _processNode(node, fv) { + if (node.tag === 'field') { + const name = node.attrs.name; + let uid = node.attrs.id; + if (!uid) { + uid = name in fv.fieldIdsToNames ? `${name}__${generateID()}__` : name; + node.attrs.id = uid; + } + fv.fieldIdsToNames[uid] = name; + } + return this._super(...arguments); + }, + /** + * We set here the limit for the number of records fetched (in one page). + * This method is only called for subviews, not for main views. + * + * @private + * @param {Object} attrs + */ + _setSubViewLimit: function (attrs) { + var view = attrs.views && attrs.views[attrs.mode]; + var limit = view && view.arch.attrs.limit && parseInt(view.arch.attrs.limit, 10); + attrs.limit = limit || attrs.Widget.prototype.limit || 40; + }, +}); + +return FormView; + +}); diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js new file mode 100644 index 00000000..6cb2b899 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -0,0 +1,356 @@ +odoo.define('web.GraphController', function (require) { +"use strict"; + +/*--------------------------------------------------------- + * Odoo Graph view + *---------------------------------------------------------*/ + +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'); +const { _t } = require('web.core'); + +class CarretDropdownMenu extends DropdownMenu { + /** + * @override + */ + get displayCaret() { + return true; + } +} + +var GraphController = AbstractController.extend({ + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + item_selected: '_onItemSelected', + open_view: '_onOpenView', + }), + + /** + * @override + * @param {Widget} parent + * @param {GraphModel} model + * @param {GraphRenderer} renderer + * @param {Object} params + * @param {string[]} params.measures + * @param {boolean} params.isEmbedded + * @param {string[]} params.groupableFields, + */ + init: function (parent, model, renderer, params) { + 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; + // views to use in the action triggered when the graph is clicked + this.views = params.views; + this.title = params.title; + + // this parameter determines what is the list of fields + // that may be used within the groupby menu available when + // the view is embedded + this.groupableFields = params.groupableFields; + this.buttonDropdownPromises = []; + }, + /** + * @override + */ + start: function () { + this.$el.addClass('o_graph_controller'); + return this._super.apply(this, arguments); + }, + /** + * @todo check if this can be removed (mostly duplicate with + * AbstractController method) + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off().tooltip('dispose'); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the current mode, measure and groupbys, so we can restore the + * view when we save the current state in the search view, or when we add it + * to the dashboard. + * + * @override + * @returns {Object} + */ + getOwnedQueryParams: function () { + var state = this.model.get(); + return { + context: { + graph_measure: state.measure, + graph_mode: state.mode, + graph_groupbys: state.groupBy, + } + }; + }, + /** + * @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. + * Set this.$buttons with the produced jQuery element + * + * @param {jQuery} [$node] a jQuery node where the rendered buttons should + * be inserted $node may be undefined, in which case the GraphView does + * nothing + */ + renderButtons: function ($node) { + this.$buttons = $(qweb.render('GraphView.buttons')); + this.$buttons.find('button').tooltip(); + this.$buttons.click(ev => this._onButtonClick(ev)); + + if (this.withButtons) { + 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: _t("Measures"), + items: this.measures, + }); + this.buttonDropdownPromises = [this.measureMenu.mount(fragment)]; + if ($node) { + if (this.isEmbedded) { + // Instantiate and append GroupBy menu + this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, { + title: _t("Group By"), + icon: 'fa fa-bars', + items: this._getGroupBys(state.groupBy), + }); + this.buttonDropdownPromises.push(this.groupByMenu.mount(fragment)); + } + this.$buttons.appendTo($node); + } + } + }, + /** + * Makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that). + * + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + var state = this.model.get(); + this.$buttons.find('.o_graph_button').removeClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="' + state.mode + '"]') + .addClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="stack"]') + .data('stacked', state.stacked) + .toggleClass('active', state.stacked) + .toggleClass('o_hidden', state.mode !== 'bar'); + this.$buttons + .find('.o_graph_button[data-order]') + .toggleClass('o_hidden', state.mode === 'pie' || !!Object.keys(state.timeRanges).length) + .filter('.o_graph_button[data-order="' + state.orderBy + '"]') + .toggleClass('active', !!state.orderBy); + + if (this.withButtons) { + this._attachDropdownComponents(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Attaches the different dropdown components to the buttons container. + * + * @returns {Promise} + */ + async _attachDropdownComponents() { + await Promise.all(this.buttonDropdownPromises); + const actionsContainer = this.$buttons[0]; + // Attach "measures" button + actionsContainer.appendChild(this.measureMenu.el); + this.measureMenu.el.classList.add('o_graph_measures_list'); + if (this.isEmbedded) { + // Attach "groupby" button + actionsContainer.appendChild(this.groupByMenu.el); + this.groupByMenu.el.classList.add('o_group_by_menu'); + } + // Update button classes accordingly to the current mode + 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; + } + } + }, + + /** + * 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; + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Do what need to be done when a button from the control panel is clicked. + * + * @private + * @param {MouseEvent} ev + */ + _onButtonClick: function (ev) { + var $target = $(ev.target); + if ($target.hasClass('o_graph_button')) { + if (_.contains(['bar','line', 'pie'], $target.data('mode'))) { + this.update({ mode: $target.data('mode') }); + } else if ($target.data('mode') === 'stack') { + this.update({ stacked: !$target.data('stacked') }); + } else if (['asc', 'desc'].includes($target.data('order'))) { + const order = $target.data('order'); + const state = this.model.get(); + this.update({ orderBy: state.orderBy === order ? false : order }); + } + } + }, + + /** + * @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 }); + } + }, + + /** + * @private + * @param {OdooEvent} ev + * @param {Array[]} ev.data.domain + */ + _onOpenView(ev) { + ev.stopPropagation(); + const state = this.model.get(); + const context = Object.assign({}, state.context); + Object.keys(context).forEach(x => { + if (x === 'group_by' || x.startsWith('search_default_')) { + delete context[x]; + } + }); + this.do_action({ + context: context, + domain: ev.data.domain, + name: this.title, + res_model: this.modelName, + target: 'current', + type: 'ir.actions.act_window', + view_mode: 'list', + views: this.views, + }); + }, +}); + +return GraphController; + +}); diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js new file mode 100644 index 00000000..b1bcddb4 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -0,0 +1,322 @@ +odoo.define('web.GraphModel', function (require) { +"use strict"; + +var core = require('web.core'); +const { DEFAULT_INTERVAL, rankInterval } = require('web.searchUtils'); + +var _t = core._t; + +/** + * The graph model is responsible for fetching and processing data from the + * server. It basically just do a(some) read_group(s) and format/normalize data. + */ +var AbstractModel = require('web.AbstractModel'); + +return AbstractModel.extend({ + /** + * @override + * @param {Widget} parent + */ + init: function () { + this._super.apply(this, arguments); + this.chart = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * + * We defend against outside modifications by extending the chart data. It + * may be overkill. + * + * @override + * @returns {Object} + */ + __get: function () { + return Object.assign({ isSample: this.isSampleModel }, this.chart); + }, + /** + * Initial loading. + * + * @todo All the work to fall back on the graph_groupbys keys in the context + * should be done by the graphView I think. + * + * @param {Object} params + * @param {Object} params.context + * @param {Object} params.fields + * @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.measure a valid field name + * @param {'pie'|'bar'|'line'} params.mode + * @param {string} params.modelName + * @param {Object} params.timeRanges + * @returns {Promise} The promise does not return a handle, we don't need + * to keep track of various entities. + */ + __load: function (params) { + var groupBys = params.context.graph_groupbys || params.groupBys; + this.initialGroupBys = groupBys; + this.fields = params.fields; + this.modelName = params.modelName; + this.chart = Object.assign({ + context: params.context, + dataPoints: [], + domain: params.domain, + groupBy: params.groupedBy.length ? params.groupedBy : groupBys, + measure: params.context.graph_measure || params.measure, + mode: params.context.graph_mode || params.mode, + origins: [], + stacked: params.stacked, + timeRanges: params.timeRanges, + orderBy: params.orderBy + }); + + this._computeDerivedParams(); + + return this._loadGraph(); + }, + /** + * Reload data. It is similar to the load function. Note that we ignore the + * handle parameter, we always expect our data to be in this.chart object. + * + * @todo This method takes 'groupBy' and load method takes 'groupedBy'. This + * is insane. + * + * @param {any} handle ignored! + * @param {Object} params + * @param {boolean} [params.stacked] + * @param {Object} [params.context] + * @param {string[]} [params.domain] + * @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) { + if ('context' in params) { + this.chart.context = params.context; + 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; + } + if ('domain' in params) { + this.chart.domain = params.domain; + } + if ('groupBy' in params) { + this.chart.groupBy = params.groupBy.length ? params.groupBy : this.initialGroupBys; + } + if ('measure' in params) { + this.chart.measure = params.measure; + } + if ('timeRanges' in params) { + this.chart.timeRanges = params.timeRanges; + } + + this._computeDerivedParams(); + + if ('mode' in params) { + this.chart.mode = params.mode; + return Promise.resolve(); + } + if ('stacked' in params) { + this.chart.stacked = params.stacked; + return Promise.resolve(); + } + if ('orderBy' in params) { + this.chart.orderBy = params.orderBy; + return Promise.resolve(); + } + return this._loadGraph(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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 + */ + _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.domain.concat(comparisonRange), + ]; + this.chart.origins = [rangeDescription, 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; + } + }, + /** + * @override + */ + _isEmpty() { + return this.chart.dataPoints.length === 0; + }, + /** + * 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 + * to separate date groups in the field list, because they can be defined + * with an aggregation function, such as my_date:week. + * + * @private + * @returns {Promise} + */ + _loadGraph: function () { + var self = this; + this.chart.dataPoints = []; + var groupBy = this.chart.processedGroupBy; + var fields = _.map(groupBy, function (groupBy) { + return groupBy.split(':')[0]; + }); + + if (this.chart.measure !== '__count__') { + if (this.fields[this.chart.measure].type === 'many2one') { + fields = fields.concat(this.chart.measure + ":count_distinct"); + } + else { + fields = fields.concat(this.chart.measure); + } + } + + var context = _.extend({fill_temporal: true}, this.chart.context); + + var proms = []; + this.chart.domains.forEach(function (domain, originIndex) { + proms.push(self._rpc({ + model: self.modelName, + method: 'read_group', + context: context, + domain: domain, + fields: fields, + groupBy: groupBy, + lazy: false, + }).then(self._processData.bind(self, originIndex))); + }); + return Promise.all(proms); + }, + /** + * Since read_group is insane and returns its result on different keys + * depending of some input, we have to normalize the result. + * Each group coming from the read_group produces a dataPoint + * + * @todo This is not good for race conditions. The processing should get + * the object this.chart in argument, or an array or something. We want to + * avoid writing on a this.chart object modified by a subsequent read_group + * + * @private + * @param {number} originIndex + * @param {any} rawData result from the read_group + */ + _processData: function (originIndex, rawData) { + var self = this; + var isCount = this.chart.measure === '__count__'; + var labels; + + function getLabels (dataPt) { + return self.chart.processedGroupBy.map(function (field) { + return self._sanitizeValue(dataPt[field], field.split(":")[0]); + }); + } + rawData.forEach(function (dataPt){ + labels = getLabels(dataPt); + var count = dataPt.__count || dataPt[self.chart.processedGroupBy[0]+'_count'] || 0; + var value = isCount ? count : dataPt[self.chart.measure]; + if (value instanceof Array) { + // when a many2one field is used as a measure AND as a grouped + // field, bad things happen. The server will only return the + // grouped value and will not aggregate it. Since there is a + // name clash, we are then in the situation where this value is + // an array. Fortunately, if we group by a field, then we can + // say for certain that the group contains exactly one distinct + // value for that field. + value = 1; + } + self.chart.dataPoints.push({ + resId: dataPt[self.chart.groupBy[0]] instanceof Array ? dataPt[self.chart.groupBy[0]][0] : -1, + count: count, + domain: dataPt.__domain, + value: value, + labels: labels, + originIndex: originIndex, + }); + }); + }, + /** + * Process the groupBy parameter in order to keep only the finer interval option for + * elements based on date/datetime field (e.g. 'date:year'). This means that + * 'week' is prefered to 'month'. The field stays at the place of its first occurence. + * For instance, + * ['foo', 'date:month', 'bar', 'date:week'] becomes ['foo', 'date:week', 'bar']. + * + * @private + * @param {string[]} groupBy + * @returns {string[]} + */ + _processGroupBy: function(groupBy) { + const groupBysMap = new Map(); + for (const gb of groupBy) { + let [fieldName, interval] = gb.split(':'); + const field = this.fields[fieldName]; + if (['date', 'datetime'].includes(field.type)) { + interval = interval || DEFAULT_INTERVAL; + } + if (groupBysMap.has(fieldName)) { + const registeredInterval = groupBysMap.get(fieldName); + if (rankInterval(registeredInterval) < rankInterval(interval)) { + groupBysMap.set(fieldName, interval); + } + } else { + groupBysMap.set(fieldName, interval); + } + } + return [...groupBysMap].map(([fieldName, interval]) => { + if (interval) { + return `${fieldName}:${interval}`; + } + return fieldName; + }); + }, + /** + * Helper function (for _processData), turns various values in a usable + * string form, that we can display in the interface. + * + * @private + * @param {any} value value for the field fieldName received by the read_group rpc + * @param {string} fieldName + * @returns {string} + */ + _sanitizeValue: function (value, fieldName) { + if (value === false && this.fields[fieldName].type !== 'boolean') { + return _t("Undefined"); + } + if (value instanceof Array) { + return value[1]; + } + if (fieldName && (this.fields[fieldName].type === 'selection')) { + var selected = _.where(this.fields[fieldName].selection, {0: value})[0]; + return selected ? selected[1] : value; + } + return value; + }, +}); + +}); diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js new file mode 100644 index 00000000..118245f9 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -0,0 +1,1099 @@ +odoo.define('web.GraphRenderer', function (require) { +"use strict"; + +/** + * The graph renderer turns the data from the graph model into a nice looking + * canvas chart. This code uses the Chart.js library. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); +var fieldUtils = require('web.field_utils'); + +var _t = core._t; +var DateClasses = dataComparisonUtils.DateClasses; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'line']; + +var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728", + "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", + "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; +var COLOR_NB = COLORS.length; + +function hexToRGBA(hex, opacity) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + var rgb = result.slice(1, 4).map(function (n) { + return parseInt(n, 16); + }).join(','); + return 'rgba(' + rgb + ',' + opacity + ')'; +} + +// used to format values in tooltips and yAxes. +var FORMAT_OPTIONS = { + // allow to decide if utils.human_number should be used + humanReadable: function (value) { + return Math.abs(value) >= 1000; + }, + // with the choices below, 1236 is represented by 1.24k + minDigits: 1, + decimals: 2, + // avoid comma separators for thousands in numbers when human_number is used + formatterCallback: function (str) { + return str; + }, +}; + +var NO_DATA = [_t('No data')]; +NO_DATA.isNoData = true; + +// hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 4 * (Math.max(1, config.device.size_class)); + +return AbstractRenderer.extend({ + className: "o_graph_renderer", + sampleDataTargets: ['.o_graph_canvas_container'], + /** + * @override + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + * @param {boolean} [params.isEmbedded] + * @param {Object} [params.fields] + * @param {string} [params.title] + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.isEmbedded = params.isEmbedded || false; + this.title = params.title || ''; + this.fields = params.fields || {}; + this.disableLinking = params.disableLinking; + + this.chart = null; + this.chartId = _.uniqueId('chart'); + this.$legendTooltip = null; + this.$tooltip = null; + }, + /** + * Chart.js does not need the canvas to be in dom in order + * to be able to work well. We could avoid the calls to on_attach_callback + * and on_detach_callback. + * + * @override + */ + on_attach_callback: function () { + this._super.apply(this, arguments); + this.isInDOM = true; + this._render(); + }, + /** + * @override + */ + on_detach_callback: function () { + this._super.apply(this, arguments); + this.isInDOM = false; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This function aims to remove a suitable number of lines from the tooltip in order to make it reasonably visible. + * A message indicating the number of lines is added if necessary. + * + * @private + * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip + */ + _adjustTooltipHeight: function (maxTooltipHeight) { + var sizeOneLine = this.$tooltip.find('tbody tr')[0].clientHeight; + var tbodySize = this.$tooltip.find('tbody')[0].clientHeight; + var toKeep = Math.floor((maxTooltipHeight - (this.$tooltip[0].clientHeight - tbodySize)) / sizeOneLine) - 1; + var $lines = this.$tooltip.find('tbody tr'); + var toRemove = $lines.length - toKeep; + if (toRemove > 0) { + $lines.slice(toKeep).remove(); + var tr = document.createElement('tr'); + var td = document.createElement('td'); + tr.classList.add('o_show_more'); + td.innerHTML = _t("..."); + tr.appendChild(td); + this.$tooltip.find('tbody').append(tr); + } + }, + /** + * This function creates a custom HTML tooltip. + * + * @private + * @param {Object} tooltipModel see chartjs documentation + */ + _customTooltip: function (tooltipModel) { + this.$el.css({ cursor: 'default' }); + if (this.$tooltip) { + this.$tooltip.remove(); + } + if (tooltipModel.opacity === 0) { + return; + } + if (tooltipModel.dataPoints.length === 0) { + return; + } + + if (this._isRedirectionEnabled()) { + this.$el.css({ cursor: 'pointer' }); + } + + const chartArea = this.chart.chartArea; + const chartAreaLeft = chartArea.left; + const chartAreaRight = chartArea.right; + const chartAreaTop = chartArea.top; + const rendererTop = this.$el[0].getBoundingClientRect().top; + + const maxTooltipLabelWidth = Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px'; + + const tooltipItems = this._getTooltipItems(tooltipModel); + + this.$tooltip = $(qweb.render('GraphView.CustomTooltip', { + measure: this.fields[this.state.measure].string, + tooltipItems: tooltipItems, + maxWidth: maxTooltipLabelWidth, + })).css({top: '2px', left: '2px'}); + const $container = this.$el.find('.o_graph_canvas_container'); + $container.append(this.$tooltip); + + let top; + const tooltipHeight = this.$tooltip[0].clientHeight; + const minTopAllowed = Math.floor(chartAreaTop); + const maxTopAllowed = Math.floor(window.innerHeight - rendererTop - tooltipHeight) - 2; + const y = Math.floor(tooltipModel.y); + if (minTopAllowed <= maxTopAllowed) { + // Here we know that the full tooltip can fit in the screen. + // We put it in the position where Chart.js would put it + // if two conditions are respected: + // 1: the tooltip is not cut (because we know it is possible to not cut it) + // 2: the tooltip does not hide the legend. + // If it is not possible to use the Chart.js proposition (y) + // we use the best approximated value. + if (y <= maxTopAllowed) { + if (y >= minTopAllowed) { + top = y; + } else { + top = minTopAllowed; + } + } else { + top = maxTopAllowed; + } + } else { + // Here we know that we cannot satisfy condition 1 above, + // so we position the tooltip at the minimal position and + // cut it the minimum possible. + top = minTopAllowed; + const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) -2; + this._adjustTooltipHeight(maxTooltipHeight); + } + this.$tooltip[0].style.top = Math.floor(top) + 'px'; + + this._fixTooltipLeftPosition(this.$tooltip[0], tooltipModel.x); + }, + /** + * Filter out some dataPoints because they would lead to bad graphics. + * The filtering is done with respect to the graph view mode. + * Note that the method does not alter this.state.dataPoints, since we + * want to be able to change of mode without fetching data again: + * we simply present the same data in a different way. + * + * @private + * @returns {Object[]} + */ + _filterDataPoints: function () { + var dataPoints = []; + if (_.contains(['bar', 'pie'], this.state.mode)) { + dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + } else if (this.state.mode === 'line') { + var counts = 0; + this.state.dataPoints.forEach(function (dataPt) { + if (dataPt.labels[0] !== _t("Undefined")) { + dataPoints.push(dataPt); + } + counts += dataPt.count; + }); + // data points with zero count might have been created on purpose + // we only remove them if there are no data point with positive count + if (counts === 0) { + dataPoints = []; + } + } + return dataPoints; + }, + /** + * Sets best left position of a tooltip approaching the proposal x + * + * @private + * @param {DOMElement} tooltip + * @param {number} x, left offset proposed + */ + _fixTooltipLeftPosition: function (tooltip, x) { + let left; + const tooltipWidth = tooltip.clientWidth; + const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2); + const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth -2); + x = Math.floor(x); + if (x <= maxLeftAllowed) { + if (x >= minLeftAllowed) { + left = x; + } else { + left = minLeftAllowed; + } + } else { + left = maxLeftAllowed; + } + tooltip.style.left = left + 'px'; + }, + /** + * Used to format correctly the values in tooltips and yAxes + * + * @private + * @param {number} value + * @returns {string} The value formatted using fieldUtils.format.float + */ + _formatValue: function (value) { + var measureField = this.fields[this.state.measure]; + var formatter = fieldUtils.format.float; + var formatedValue = formatter(value, measureField, FORMAT_OPTIONS); + return formatedValue; + }, + /** + * Used any time we need a new color in our charts. + * + * @private + * @param {number} index + * @returns {string} a color in HEX format + */ + _getColor: function (index) { + return COLORS[index % COLOR_NB]; + }, + /** + * Determines the initial section of the labels array + * over a dataset has to be completed. The section only depends + * on the datasets origins. + * + * @private + * @param {number} originIndex + * @param {number} defaultLength + * @returns {number} + */ + _getDatasetDataLength: function (originIndex, defaultLength) { + if (_.contains(['bar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) { + return this.dateClasses.dateSets[originIndex].length; + } + return defaultLength; + }, + /** + * Determines to which dataset belong the data point + * + * @private + * @param {Object} dataPt + * @returns {string} + */ + _getDatasetLabel: function (dataPt) { + if (_.contains(['bar', 'line'], this.state.mode)) { + // ([origin] + second to last groupBys) or measure + var datasetLabel = dataPt.labels.slice(1).join("/"); + if (this.state.origins.length > 1) { + datasetLabel = this.state.origins[dataPt.originIndex] + + (datasetLabel ? ('/' + datasetLabel) : ''); + } + datasetLabel = datasetLabel || this.fields[this.state.measure].string; + return datasetLabel; + } + return this.state.origins[dataPt.originIndex]; + }, + /** + * Returns an object used to style chart elements independently from the datasets. + * + * @private + * @returns {Object} + */ + _getElementOptions: function () { + var elementOptions = {}; + if (this.state.mode === 'bar') { + elementOptions.rectangle = {borderWidth: 1}; + } else if (this.state.mode === 'line') { + elementOptions.line = { + tension: 0, + fill: false, + }; + } + return elementOptions; + }, + /** + * Returns a DateClasses instance used to manage equivalence of dates. + * + * @private + * @param {Object[]} dataPoints + * @returns {DateClasses} + */ + _getDateClasses: function (dataPoints) { + var self = this; + var dateSets = this.state.origins.map(function () { + return []; + }); + dataPoints.forEach(function (dataPt) { + dateSets[dataPt.originIndex].push(dataPt.labels[self.state.comparisonFieldIndex]); + }); + dateSets = dateSets.map(function (dateSet) { + return _.uniq(dateSet); + }); + return new DateClasses(dateSets); + }, + /** + * Determines over which label is the data point + * + * @private + * @param {Object} dataPt + * @returns {Array} + */ + _getLabel: function (dataPt) { + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode)) { + if (i === 0) { + return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])]; + } else { + return dataPt.labels.slice(0, 1); + } + } else if (i === 0) { + return Array.prototype.concat.apply([], [ + this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]), + dataPt.labels.slice(i+1) + ]); + } else { + return dataPt.labels; + } + }, + /** + * Returns the options used to generate the chart legend. + * + * @private + * @param {Number} datasetsCount + * @returns {Object} + */ + _getLegendOptions: function (datasetsCount) { + var legendOptions = { + display: datasetsCount <= MAX_LEGEND_LENGTH, + // position: this.state.mode === 'pie' ? 'right' : 'top', + position: 'top', + onHover: this._onlegendTooltipHover.bind(this), + onLeave: this._onLegendTootipLeave.bind(this), + }; + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + } + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + return data.datasets.map(function (dataset, i) { + return { + text: self._shortenLabel(dataset.label), + fullText: dataset.label, + fillStyle: dataset[referenceColor], + hidden: !chart.isDatasetVisible(i), + lineCap: dataset.borderCapStyle, + lineDash: dataset.borderDash, + lineDashOffset: dataset.borderDashOffset, + lineJoin: dataset.borderJoinStyle, + lineWidth: dataset.borderWidth, + strokeStyle: dataset[referenceColor], + pointStyle: dataset.pointStyle, + datasetIndex: i, + }; + }); + }, + }; + } else { + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + var metaData = data.datasets.map(function (dataset, index) { + return chart.getDatasetMeta(index).data; + }); + return data.labels.map(function (label, i) { + var hidden = metaData.reduce( + function (hidden, data) { + if (data[i]) { + hidden = hidden || data[i].hidden; + } + return hidden; + }, + false + ); + var fullText = self._relabelling(label); + var text = self._shortenLabel(fullText); + return { + text: text, + fullText: fullText, + fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i), + hidden: hidden, + index: i, + }; + }); + }, + }; + } + return legendOptions; + }, + /** + * Returns the options used to generate the chart axes. + * + * @private + * @returns {Object} + */ + _getScaleOptions: function () { + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + return { + xAxes: [{ + type: 'category', + scaleLabel: { + display: this.state.processedGroupBy.length && !this.isEmbedded, + labelString: this.state.processedGroupBy.length ? + this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '', + }, + ticks: { + // don't use bind: callback is called with 'index' as second parameter + // with value labels.indexOf(label)! + callback: function (label) { + return self._relabelling(label); + }, + }, + }], + yAxes: [{ + type: 'linear', + scaleLabel: { + display: !this.isEmbedded, + labelString: this.fields[this.state.measure].string, + }, + ticks: { + callback: this._formatValue.bind(this), + suggestedMax: 0, + suggestedMin: 0, + } + }], + }; + } + return {}; + }, + /** + * Extracts the important information from a tooltipItem generated by Charts.js + * (a tooltip item corresponds to a line (different from measure name) of a tooltip) + * + * @private + * @param {Object} item + * @param {Object} data + * @returns {Object} + */ + _getTooltipItemContent: function (item, data) { + var dataset = data.datasets[item.datasetIndex]; + var label = data.labels[item.index]; + var value; + var boxColor; + if (this.state.mode === 'bar') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.yLabel); + boxColor = dataset.backgroundColor; + } else if (this.state.mode === 'line') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.yLabel); + boxColor = dataset.borderColor; + } else { + if (label.isNoData) { + value = this._formatValue(0); + } else { + value = this._formatValue(dataset.data[item.index]); + } + label = this._relabelling(label, dataset.originIndex); + if (this.state.origins.length > 1) { + label = dataset.label + "/" + label; + } + boxColor = dataset.backgroundColor[item.index]; + } + return { + label: label, + value: value, + boxColor: boxColor, + }; + }, + /** + * This function extracts the information from the data points in tooltipModel.dataPoints + * (corresponding to datapoints over a given label determined by the mouse position) + * that will be displayed in a custom tooltip. + * + * @private + * @param {Object} tooltipModel see chartjs documentation + * @return {Object[]} + */ + _getTooltipItems: function (tooltipModel) { + var self = this; + var data = this.chart.config.data; + + var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) { + return dPt2.yLabel - dPt1.yLabel; + }); + return orderedItems.reduce( + function (acc, item) { + acc.push(self._getTooltipItemContent(item, data)); + return acc; + }, + [] + ); + }, + /** + * Returns the options used to generate chart tooltips. + * + * @private + * @returns {Object} + */ + _getTooltipOptions: function () { + var tooltipOptions = { + // disable Chart.js tooltips + enabled: false, + custom: this._customTooltip.bind(this), + }; + if (this.state.mode === 'line') { + tooltipOptions.mode = 'index'; + tooltipOptions.intersect = false; + } + return tooltipOptions; + }, + /** + * Returns true iff the current graph can be clicked on to redirect to the + * list of records. + * + * @private + * @returns {boolean} + */ + _isRedirectionEnabled: function () { + return !this.disableLinking && + (this.state.mode === 'bar' || this.state.mode === 'pie'); + }, + /** + * Return the first index of the array list where label can be found + * or -1. + * + * @private + * @param {Array[]} list + * @param {Array} label + * @returns {number} + */ + _indexOf: function (list, label) { + var index = -1; + for (var j = 0; j < list.length; j++) { + var otherLabel = list[j]; + if (label.length === otherLabel.length) { + var equal = true; + for (var i = 0; i < label.length; i++) { + if (label[i] !== otherLabel[i]) { + equal = false; + } + } + if (equal) { + index = j; + break; + } + } + } + return index; + }, + /** + * Separate dataPoints coming from the read_group(s) into different datasets. + * This function returns the parameters data and labels used to produce the charts. + * + * @private + * @param {Object[]} dataPoints + * @param {function} getLabel, + * @param {function} getDatasetLabel, determines to which dataset belong a given data point + * @param {function} [getDatasetDataLength], determines the initial section of the labels array + * over which the datasets have to be completed. These sections only depend + * on the datasets origins. Default is the constant function _ => labels.length. + * @returns {Object} the parameter data used to instantiate the chart. + */ + _prepareData: function (dataPoints) { + var self = this; + + var labelMap = {}; + var labels = dataPoints.reduce( + function (acc, dataPt) { + var label = self._getLabel(dataPt); + var labelKey = dataPt.resId + ':' + JSON.stringify(label); + var index = labelMap[labelKey]; + if (index === undefined) { + labelMap[labelKey] = dataPt.labelIndex = acc.length; + acc.push(label); + } + else{ + dataPt.labelIndex = index; + } + return acc; + }, + [] + ); + + var newDataset = function (datasetLabel, originIndex) { + var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0); + const domain = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill([]); + return { + label: datasetLabel, + data: data, + domain: domain, + originIndex: originIndex, + }; + }; + + // dataPoints --> datasets + var datasets = _.values(dataPoints.reduce( + function (acc, dataPt) { + var datasetLabel = self._getDatasetLabel(dataPt); + if (!(datasetLabel in acc)) { + acc[datasetLabel] = newDataset(datasetLabel, dataPt.originIndex); + } + var labelIndex = dataPt.labelIndex; + acc[datasetLabel].data[labelIndex] = dataPt.value; + acc[datasetLabel].domain[labelIndex] = dataPt.domain; + return acc; + }, + {} + )); + + // sort by origin + datasets = datasets.sort(function (dataset1, dataset2) { + return dataset1.originIndex - dataset2.originIndex; + }); + + return { + datasets: datasets, + labels: labels, + }; + }, + /** + * Prepare options for the chart according to the current mode (= chart type). + * This function returns the parameter options used to instantiate the chart + * + * @private + * @param {number} datasetsCount + * @returns {Object} the chart options used for the current mode + */ + _prepareOptions: function (datasetsCount) { + const options = { + maintainAspectRatio: false, + scales: this._getScaleOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(), + elements: this._getElementOptions(), + }; + if (this._isRedirectionEnabled()) { + options.onClick = this._onGraphClicked.bind(this); + } + return options; + }, + /** + * Determine how to relabel a label according to a given origin. + * The idea is that the getLabel function is in general not invertible but + * it is when restricted to the set of dataPoints coming from a same origin. + + * @private + * @param {Array} label + * @param {Array} originIndex + * @returns {string} + */ + _relabelling: function (label, originIndex) { + if (label.isNoData) { + return label[0]; + } + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode) && i === 0) { + // here label is an array of length 1 and contains a number + return this.dateClasses.representative(label, originIndex) || ''; + } else if (this.state.mode === 'pie' && i === 0) { + // here label is an array of length at least one containing string or numbers + var labelCopy = label.slice(0); + if (originIndex !== undefined) { + labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex)); + } else { + labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i])); + } + return labelCopy.join('/'); + } + // here label is an array containing strings or numbers. + return label.join('/') || _t('Total'); + }, + /** + * Render the chart or display a message error in case data is not good enough. + * + * Note that This method is synchronous, but the actual rendering is done + * asynchronously. The reason for that is that Chart.js needs to be in the + * DOM to correctly render itself. So, we trick Odoo by returning + * immediately, then we render the chart when the widget is in the DOM. + * + * @override + */ + async _renderView() { + if (this.chart) { + this.chart.destroy(); + } + this.$el.empty(); + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } + var dataPoints = this._filterDataPoints(); + dataPoints = this._sortDataPoints(dataPoints); + if (this.isInDOM) { + this._renderTitle(); + + // detect if some pathologies are still present after the filtering + if (this.state.mode === 'pie') { + const someNegative = dataPoints.some(dataPt => dataPt.value < 0); + const somePositive = dataPoints.some(dataPt => dataPt.value > 0); + if (someNegative && somePositive) { + const context = { + title: _t("Invalid data"), + description: [ + _t("Pie chart cannot mix positive and negative numbers. "), + _t("Try to change your domain to only display positive results") + ].join("") + }; + this._renderNoContentHelper(context); + return; + } + } + + if (this.state.isSample && !this.isEmbedded) { + this._renderNoContentHelper(); + } + + // only render the graph if the widget is already in the DOM (this + // happens typically after an update), otherwise, it will be + // rendered when the widget will be attached to the DOM (see + // 'on_attach_callback') + var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'}); + var $canvas = $('<canvas/>').attr('id', this.chartId); + $canvasContainer.append($canvas); + this.$el.append($canvasContainer); + + var i = this.state.comparisonFieldIndex; + if (i === 0) { + this.dateClasses = this._getDateClasses(dataPoints); + } + if (this.state.mode === 'bar') { + this._renderBarChart(dataPoints); + } else if (this.state.mode === 'line') { + this._renderLineChart(dataPoints); + } else if (this.state.mode === 'pie') { + this._renderPieChart(dataPoints); + } + } + }, + /** + * create bar chart. + * + * @private + * @param {Object[]} dataPoints + */ + _renderBarChart: function (dataPoints) { + var self = this; + + // prepare data + var data = this._prepareData(dataPoints); + + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + var color = self._getColor(index); + dataset.backgroundColor = color; + }); + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'bar', + data: data, + options: options, + }); + }, + /** + * create line chart. + * + * @private + * @param {Object[]} dataPoints + */ + _renderLineChart: function (dataPoints) { + var self = this; + + // prepare data + var data = this._prepareData(dataPoints); + data.datasets.forEach(function (dataset, index) { + if (self.state.processedGroupBy.length <= 1 && self.state.origins.length > 1) { + if (dataset.originIndex === 0) { + dataset.fill = 'origin'; + dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4); + dataset.borderColor = hexToRGBA(COLORS[0], 1); + } else if (dataset.originIndex === 1) { + dataset.borderColor = hexToRGBA(COLORS[1], 1); + } else { + dataset.borderColor = self._getColor(index); + } + } else { + dataset.borderColor = self._getColor(index); + } + if (data.labels.length === 1) { + // shift of the real value to right. This is done to center the points in the chart + // See data.labels below in Chart parameters + dataset.data.unshift(undefined); + } + dataset.pointBackgroundColor = dataset.borderColor; + dataset.pointBorderColor = 'rgba(0,0,0,0.2)'; + }); + if (data.datasets.length === 1) { + const dataset = data.datasets[0]; + dataset.fill = 'origin'; + dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4); + } + + // center the points in the chart (without that code they are put on the left and the graph seems empty) + data.labels = data.labels.length > 1 ? + data.labels : + Array.prototype.concat.apply([], [[['']], data.labels, [['']]]); + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'line', + data: data, + options: options, + }); + }, + /** + * create pie chart + * + * @private + * @param {Object[]} dataPoints + */ + _renderPieChart: function (dataPoints) { + var self = this; + // prepare data + var data = {}; + var colors = []; + const allZero = dataPoints.every(dataPt => dataPt.value === 0); + if (allZero) { + // add fake data to display a pie chart with a grey zone associated + // with every origin + data.labels = [NO_DATA]; + data.datasets = this.state.origins.map(function (origin) { + return { + label: origin, + data: [1], + backgroundColor: ['#d3d3d3'], + }; + }); + } else { + data = this._prepareData(dataPoints); + // give same color to same groups from different origins + colors = data.labels.map(function (label, index) { + return self._getColor(index); + }); + data.datasets.forEach(function (dataset) { + dataset.backgroundColor = colors; + dataset.borderColor = 'rgba(255,255,255,0.6)'; + }); + // make sure there is a zone associated with every origin + var representedOriginIndexes = data.datasets.map(function (dataset) { + return dataset.originIndex; + }); + var addNoDataToLegend = false; + var fakeData = (new Array(data.labels.length)).concat([1]); + this.state.origins.forEach(function (origin, originIndex) { + if (!_.contains(representedOriginIndexes, originIndex)) { + data.datasets.splice(originIndex, 0, { + label: origin, + data: fakeData, + backgroundColor: colors.concat(['#d3d3d3']), + }); + addNoDataToLegend = true; + } + }); + if (addNoDataToLegend) { + data.labels.push(NO_DATA); + } + } + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'pie', + data: data, + options: options, + }); + }, + /** + * Add the graph title (if any) above the canvas + * + * @private + */ + _renderTitle: function () { + if (this.title) { + this.$el.prepend($('<label/>', { + text: this.title, + })); + } + }, + /** + * Used to avoid too long legend items + * + * @private + * @param {string} label + * @returns {string} shortened version of the input label + */ + _shortenLabel: function (label) { + // string returned could be 'wrong' if a groupby value contain a '/'! + var groups = label.split("/"); + var shortLabel = groups.slice(0, 3).join("/"); + if (shortLabel.length > 30) { + shortLabel = shortLabel.slice(0, 30) + '...'; + } else if (groups.length > 3) { + shortLabel = shortLabel + '/...'; + } + return shortLabel; + }, + /** + * Sort datapoints according to the current order (ASC or DESC). + * + * Note: this should be moved to the model at some point. + * + * @private + * @param {Object[]} dataPoints + * @returns {Object[]} sorted dataPoints if orderby set on state + */ + _sortDataPoints(dataPoints) { + if (!Object.keys(this.state.timeRanges).length && this.state.orderBy && + ['bar', 'line'].includes(this.state.mode) && this.state.groupBy.length) { + // group data by their x-axis value, and then sort datapoints + // based on the sum of values by group in ascending/descending order + const groupByFieldName = this.state.groupBy[0].split(':')[0]; + const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one'; + const groupedDataPoints = {}; + dataPoints.forEach(function (dataPoint) { + const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0]; + groupedDataPoints[key] = groupedDataPoints[key] || []; + groupedDataPoints[key].push(dataPoint); + }); + dataPoints = _.sortBy(groupedDataPoints, function (group) { + return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0); + }); + dataPoints = dataPoints.flat(); + if (this.state.orderBy === 'desc') { + dataPoints = dataPoints.reverse('value'); + } + } + return dataPoints; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onGraphClicked: function (ev) { + const activeElement = this.chart.getElementAtEvent(ev); + if (activeElement.length === 0) { + return; + } + const domain = this.chart.data.datasets[activeElement[0]._datasetIndex].domain; + if (!domain) { + return; // empty dataset + } + this.trigger_up('open_view', { + domain: domain[activeElement[0]._index], + }); + }, + /** + * If the text of a legend item has been shortened and the user mouse over + * that item (actually the event type is mousemove), a tooltip with the item + * full text is displayed. + * + * @private + * @param {MouseEvent} e + * @param {Object} legendItem + */ + _onlegendTooltipHover: function (e, legendItem) { + // set cursor pointer on hover of legend + e.target.style.cursor = 'pointer'; + // The string legendItem.text is an initial segment of legendItem.fullText. + // If the two coincide, no need to generate a tooltip. + // If a tooltip for the legend already exists, it is already good and don't need + // to be recreated. + if (legendItem.text === legendItem.fullText || this.$legendTooltip) { + return; + } + + const chartAreaLeft = this.chart.chartArea.left; + const chartAreaRight = this.chart.chartArea.right; + const rendererTop = this.$el[0].getBoundingClientRect().top; + + this.$legendTooltip = $('<div>', { + class: "o_tooltip_legend", + text: legendItem.fullText, + css: { + maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px', + top: (e.clientY - rendererTop) + 'px', + } + }); + const $container = this.$el.find('.o_graph_canvas_container'); + $container.append(this.$legendTooltip); + + this._fixTooltipLeftPosition(this.$legendTooltip[0], e.clientX); + }, + /** + * If there's a legend tooltip and the user mouse out of the corresponding + * legend item, the tooltip is removed. + * + * @private + */ + _onLegendTootipLeave: function (e) { + // remove cursor style pointer on mouseleave from legend + e.target.style.cursor = ""; + if (this.$legendTooltip) { + this.$legendTooltip.remove(); + this.$legendTooltip = null; + } + }, +}); +}); diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js new file mode 100644 index 00000000..759817c8 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -0,0 +1,162 @@ +odoo.define('web.GraphView', function (require) { +"use strict"; + +/** + * The Graph View is responsible to display a graphical (meaning: chart) + * representation of the current dataset. As of now, it is currently able to + * display data in three types of chart: bar chart, line chart and pie chart. + */ + +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var GraphModel = require('web.GraphModel'); +var Controller = require('web.GraphController'); +var GraphRenderer = require('web.GraphRenderer'); + +var _t = core._t; +var _lt = core._lt; + +var searchUtils = require('web.searchUtils'); +var GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES; + +var GraphView = AbstractView.extend({ + display_name: _lt('Graph'), + icon: 'fa-bar-chart', + jsLibs: [ + '/web/static/lib/Chart/Chart.js', + ], + config: _.extend({}, AbstractView.prototype.config, { + Model: GraphModel, + Controller: Controller, + Renderer: GraphRenderer, + }), + viewType: 'graph', + searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'], + + /** + * @override + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + const additionalMeasures = params.additionalMeasures || []; + let measure; + const measures = {}; + const measureStrings = {}; + let groupBys = []; + const groupableFields = {}; + this.fields.__count__ = { string: _t("Count"), type: 'integer' }; + + this.arch.children.forEach(field => { + let fieldName = field.attrs.name; + if (fieldName === "id") { + return; + } + const interval = field.attrs.interval; + if (interval) { + fieldName = fieldName + ':' + interval; + } + if (field.attrs.type === 'measure') { + const { string } = this.fields[fieldName]; + measure = fieldName; + measures[fieldName] = { + description: string, + fieldName, + groupNumber: 0, + isActive: false, + itemType: 'measure', + }; + } else { + groupBys.push(fieldName); + } + if (field.attrs.string) { + measureStrings[fieldName] = field.attrs.string; + } + }); + + for (const name in this.fields) { + const field = this.fields[name]; + if (name !== 'id' && field.store === true) { + if ( + ['integer', 'float', 'monetary'].includes(field.type) || + additionalMeasures.includes(name) + ) { + measures[name] = { + description: field.string, + fieldName: name, + groupNumber: 0, + isActive: false, + itemType: 'measure', + }; + } + if (GROUPABLE_TYPES.includes(field.type)) { + groupableFields[name] = field; + } + } + } + for (const name in measureStrings) { + if (measures[name]) { + measures[name].description = measureStrings[name]; + } + } + + // Remove invisible fields from the measures + this.arch.children.forEach(field => { + let fieldName = field.attrs.name; + if (field.attrs.invisible && py.eval(field.attrs.invisible)) { + groupBys = groupBys.filter(groupBy => groupBy !== fieldName); + if (fieldName in groupableFields) { + delete groupableFields[fieldName]; + } + if (!additionalMeasures.includes(fieldName)) { + delete measures[fieldName]; + } + } + }); + + 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.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled"); + // retrieve form and list view ids from the action to open those views + // when the graph is clicked + function _findView(views, viewType) { + const view = views.find(view => { + return view.type === viewType; + }); + return [view ? view.viewID : false, viewType]; + } + this.controllerParams.views = [ + _findView(params.actionViews, 'list'), + _findView(params.actionViews, 'form'), + ]; + + this.rendererParams.fields = this.fields; + this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead + this.rendererParams.disableLinking = !!JSON.parse(this.arch.attrs.disable_linking || '0'); + + this.loadParams.mode = this.arch.attrs.type || 'bar'; + this.loadParams.orderBy = this.arch.attrs.order; + this.loadParams.measure = measure || '__count__'; + this.loadParams.groupBys = groupBys; + this.loadParams.fields = this.fields; + this.loadParams.comparisonDomain = params.comparisonDomain; + this.loadParams.stacked = this.arch.attrs.stacked !== "False"; + }, +}); + +return GraphView; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js new file mode 100644 index 00000000..4aeb5404 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -0,0 +1,411 @@ +odoo.define('web.KanbanColumn', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); +var Dialog = require('web.Dialog'); +var KanbanRecord = require('web.KanbanRecord'); +var RecordQuickCreate = require('web.kanban_record_quick_create'); +var view_dialogs = require('web.view_dialogs'); +var viewUtils = require('web.viewUtils'); +var Widget = require('web.Widget'); +var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); + +var _t = core._t; +var QWeb = core.qweb; + +var KanbanColumn = Widget.extend({ + template: 'KanbanView.Group', + custom_events: { + cancel_quick_create: '_onCancelQuickCreate', + quick_create_add_record: '_onQuickCreateAddRecord', + tweak_column: '_onTweakColumn', + tweak_column_records: '_onTweakColumnRecords', + }, + events: { + 'click .o_column_edit': '_onEditColumn', + 'click .o_column_delete': '_onDeleteColumn', + 'click .o_kanban_quick_add': '_onAddQuickCreate', + 'click .o_kanban_load_more': '_onLoadMore', + 'click .o_kanban_toggle_fold': '_onToggleFold', + 'click .o_column_archive_records': '_onArchiveRecords', + 'click .o_column_unarchive_records': '_onUnarchiveRecords', + 'click .o_kanban_config .dropdown-menu': '_onConfigDropdownClicked', + }, + /** + * @override + */ + init: function (parent, data, options, recordOptions) { + this._super(parent); + this.db_id = data.id; + this.data_records = data.data; + this.data = data; + + var value = data.value; + this.id = data.res_id; + this.folded = !data.isOpen; + this.has_active_field = 'active' in data.fields; + this.fields = data.fields; + this.records = []; + this.modelName = data.model; + + this.quick_create = options.quick_create; + this.quickCreateView = options.quickCreateView; + this.groupedBy = options.groupedBy; + this.grouped_by_m2o = options.grouped_by_m2o; + this.editable = options.editable; + this.deletable = options.deletable; + this.archivable = options.archivable; + this.draggable = options.draggable; + this.KanbanRecord = options.KanbanRecord || KanbanRecord; // the KanbanRecord class to use + this.records_editable = options.records_editable; + this.records_deletable = options.records_deletable; + this.recordsDraggable = options.recordsDraggable; + this.relation = options.relation; + this.offset = 0; + this.remaining = data.count - this.data_records.length; + this.canBeFolded = this.folded; + + if (options.hasProgressBar) { + this.barOptions = { + columnID: this.db_id, + progressBarStates: options.progressBarStates, + }; + } + + this.record_options = _.clone(recordOptions); + + if (options.grouped_by_m2o || options.grouped_by_date ) { + // For many2one and datetime, a false value means that the field is not set. + this.title = value ? value : _t('Undefined'); + } else { + // False and 0 might be valid values for these fields. + this.title = value === undefined ? _t('Undefined') : value; + } + + if (options.group_by_tooltip) { + this.tooltipInfo = _.compact(_.map(options.group_by_tooltip, function (help, field) { + help = help ? help + "</br>" : ''; + return (data.tooltipData && data.tooltipData[field] && "<div>" + help + data.tooltipData[field] + "</div>") || ''; + })); + this.tooltipInfo = this.tooltipInfo.join("<div class='dropdown-divider' role='separator' />"); + } + }, + /** + * @override + */ + start: function () { + var self = this; + var defs = [this._super.apply(this, arguments)]; + this.$header = this.$('.o_kanban_header'); + + for (var i = 0; i < this.data_records.length; i++) { + defs.push(this._addRecord(this.data_records[i])); + } + + if (this.recordsDraggable) { + this.$el.sortable({ + connectWith: '.o_kanban_group', + containment: this.draggable ? false : 'parent', + revert: 0, + delay: 0, + items: '> .o_kanban_record:not(.o_updating)', + cursor: 'move', + over: function () { + self.$el.addClass('o_kanban_hover'); + }, + out: function () { + self.$el.removeClass('o_kanban_hover'); + }, + start: function (event, ui) { + ui.item.addClass('o_currently_dragged'); + }, + stop: function (event, ui) { + var item = ui.item; + setTimeout(function () { + item.removeClass('o_currently_dragged'); + }); + }, + update: function (event, ui) { + var record = ui.item.data('record'); + var index = self.records.indexOf(record); + record.$el.removeAttr('style'); // jqueryui sortable add display:block inline + if (index >= 0) { + if ($.contains(self.$el[0], record.$el[0])) { + // resequencing records + self.trigger_up('kanban_column_resequence', {ids: self._getIDs()}); + } + } else { + // adding record to this column + ui.item.addClass('o_updating'); + self.trigger_up('kanban_column_add_record', {record: record, ids: self._getIDs()}); + } + } + }); + } + this.$el.click(function (event) { + if (self.folded) { + self._onToggleFold(event); + } + }); + if (this.barOptions) { + this.$el.addClass('o_kanban_has_progressbar'); + this.progressBar = new KanbanColumnProgressBar(this, this.barOptions, this.data); + defs.push(this.progressBar.appendTo(this.$header)); + } + + var title = this.folded ? this.title + ' (' + this.data.count + ')' : this.title; + this.$header.find('.o_column_title').text(title); + + this.$el.toggleClass('o_column_folded', this.canBeFolded); + if (this.tooltipInfo) { + this.$header.find('.o_kanban_header_title').tooltip({}).attr('data-original-title', this.tooltipInfo); + } + if (!this.remaining) { + this.$('.o_kanban_load_more').remove(); + } else { + this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this})); + } + + return Promise.all(defs); + }, + /** + * Called when a record has been quick created, as a new column is rendered + * and appended into a fragment, before replacing the old column in the DOM. + * When this happens, the quick create widget is inserted into the new + * column directly, and it should be focused. However, as it is rendered + * into a fragment, the focus has to be set manually once in the DOM. + */ + on_attach_callback: function () { + _.invoke(this.records, 'on_attach_callback'); + if (this.quickCreateWidget) { + this.quickCreateWidget.on_attach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds the quick create record to the top of the column. + * + * @returns {Promise} + */ + addQuickCreate: async function () { + if (this.folded) { + // first open the column, and then add the quick create + this.trigger_up('column_toggle_fold', { + openQuickCreate: true, + }); + return; + } + + if (this.quickCreateWidget) { + return Promise.reject(); + } + this.trigger_up('close_quick_create'); // close other quick create widgets + var context = this.data.getContext(); + context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy); + this.quickCreateWidget = new RecordQuickCreate(this, { + context: context, + formViewRef: this.quickCreateView, + model: this.modelName, + }); + await this.quickCreateWidget.appendTo(document.createDocumentFragment()); + this.trigger_up('start_quick_create'); + this.quickCreateWidget.$el.insertAfter(this.$header); + this.quickCreateWidget.on_attach_callback(); + }, + /** + * Closes the quick create widget if it isn't dirty. + */ + cancelQuickCreate: function () { + if (this.quickCreateWidget) { + this.quickCreateWidget.cancel(); + } + }, + /** + * @returns {Boolean} true iff the column is empty + */ + isEmpty: function () { + return !this.records.length; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a record in the column. + * + * @private + * @param {Object} recordState + * @param {Object} [options] + * @param {string} [options.position] + * 'before' to add at the top, add at the bottom by default + * @return {Promise} + */ + _addRecord: function (recordState, options) { + var record = new this.KanbanRecord(this, recordState, this.record_options); + this.records.push(record); + if (options && options.position === 'before') { + return record.insertAfter(this.quickCreateWidget ? this.quickCreateWidget.$el : this.$header); + } else { + var $load_more = this.$('.o_kanban_load_more'); + if ($load_more.length) { + return record.insertBefore($load_more); + } else { + return record.appendTo(this.$el); + } + } + }, + /** + * Destroys the QuickCreate widget. + * + * @private + */ + _cancelQuickCreate: function () { + this.quickCreateWidget.destroy(); + this.quickCreateWidget = undefined; + }, + /** + * @returns {integer[]} the res_ids of the records in the column + */ + _getIDs: function () { + var ids = []; + this.$('.o_kanban_record').each(function (index, r) { + ids.push($(r).data('record').id); + }); + return ids; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddQuickCreate: function () { + this.trigger_up('add_quick_create', { groupId: this.db_id }); + }, + /** + * @private + */ + _onCancelQuickCreate: function () { + this._cancelQuickCreate(); + }, + /** + * Prevent from closing the config dropdown when the user clicks on a + * disabled item (e.g. 'Fold' in sample mode). + * + * @private + */ + _onConfigDropdownClicked(ev) { + ev.stopPropagation(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onDeleteColumn: function (event) { + event.preventDefault(); + var buttons = [ + { + text: _t("Ok"), + classes: 'btn-primary', + close: true, + click: this.trigger_up.bind(this, 'kanban_column_delete'), + }, + {text: _t("Cancel"), close: true} + ]; + new Dialog(this, { + size: 'medium', + buttons: buttons, + $content: $('<div>', { + text: _t("Are you sure that you want to remove this column ?") + }), + }).open(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onEditColumn: function (event) { + event.preventDefault(); + new view_dialogs.FormViewDialog(this, { + res_model: this.relation, + res_id: this.id, + context: session.user_context, + title: _t("Edit Column"), + on_saved: this.trigger_up.bind(this, 'reload'), + }).open(); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onLoadMore: function (event) { + event.preventDefault(); + this.trigger_up('kanban_load_more'); + }, + /** + * @private + * @param {OdooEvent} event + */ + _onQuickCreateAddRecord: function (event) { + this.trigger_up('quick_create_record', event.data); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onToggleFold: function (event) { + event.preventDefault(); + this.trigger_up('column_toggle_fold'); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onTweakColumn: function (ev) { + ev.data.callback(this.$el); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onTweakColumnRecords: function (ev) { + _.each(this.records, function (record) { + ev.data.callback(record.$el, record.state.data); + }); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onArchiveRecords: function (event) { + event.preventDefault(); + Dialog.confirm(this, _t("Are you sure that you want to archive all the records from this column?"), { + confirm_callback: this.trigger_up.bind(this, 'kanban_column_records_toggle_active', { + archive: true, + }), + }); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onUnarchiveRecords: function (event) { + event.preventDefault(); + this.trigger_up('kanban_column_records_toggle_active', { + archive: false, + }); + } +}); + +return KanbanColumn; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js new file mode 100644 index 00000000..752d2b2d --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js @@ -0,0 +1,288 @@ +odoo.define('web.KanbanColumnProgressBar', function (require) { +'use strict'; + +const core = require('web.core'); +var session = require('web.session'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +const _t = core._t; + +var KanbanColumnProgressBar = Widget.extend({ + template: 'KanbanView.ColumnProgressBar', + events: { + 'click .o_kanban_counter_progress': '_onProgressBarParentClick', + 'click .progress-bar': '_onProgressBarClick', + }, + /** + * Allows to disable animations for tests. + * @type {boolean} + */ + ANIMATE: true, + + /** + * @constructor + */ + init: function (parent, options, columnState) { + this._super.apply(this, arguments); + + this.columnID = options.columnID; + this.columnState = columnState; + + // <progressbar/> attributes + this.fieldName = columnState.progressBarValues.field; + this.colors = _.extend({}, columnState.progressBarValues.colors, { + __false: 'muted', // color to use for false value + }); + this.sumField = columnState.progressBarValues.sum_field; + + // Previous progressBar state + var state = options.progressBarStates[this.columnID]; + if (state) { + this.groupCount = state.groupCount; + this.subgroupCounts = state.subgroupCounts; + this.totalCounterValue = state.totalCounterValue; + this.activeFilter = state.activeFilter; + } + + // Prepare currency (TODO this should be automatic... use a field ?) + var sumFieldInfo = this.sumField && columnState.fieldsInfo.kanban[this.sumField]; + var currencyField = sumFieldInfo && sumFieldInfo.options && sumFieldInfo.options.currency_field; + if (currencyField && columnState.data.length) { + this.currency = session.currencies[columnState.data[0].data[currencyField].res_id]; + } + }, + /** + * @override + */ + start: function () { + var self = this; + + this.$bars = {}; + _.each(this.colors, function (val, key) { + self.$bars[key] = self.$(`.progress-bar[data-filter=${key}]`); + }); + this.$counter = this.$('.o_kanban_counter_side'); + this.$number = this.$counter.find('b'); + + if (this.currency) { + var $currency = $('<span/>', { + text: this.currency.symbol, + }); + if (this.currency.position === 'before') { + $currency.prependTo(this.$counter); + } else { + $currency.appendTo(this.$counter); + } + } + + return this._super.apply(this, arguments).then(function () { + // This should be executed when the progressbar is fully rendered + // and is in the DOM, this happens to be always the case with + // current use of progressbars + self.computeCounters(); + self._notifyState(); + self._render(); + }); + }, + /** + * Computes the count of each sub group and the total count + */ + computeCounters() { + const subgroupCounts = {}; + let allSubgroupCount = 0; + for (const key of Object.keys(this.colors)) { + const subgroupCount = this.columnState.progressBarValues.counts[key] || 0; + if (this.activeFilter === key && subgroupCount === 0) { + this.activeFilter = false; + } + subgroupCounts[key] = subgroupCount; + allSubgroupCount += subgroupCount; + }; + subgroupCounts.__false = this.columnState.count - allSubgroupCount; + + this.groupCount = this.columnState.count; + this.subgroupCounts = subgroupCounts; + this.prevTotalCounterValue = this.totalCounterValue; + this.totalCounterValue = this.sumField ? (this.columnState.aggregateValues[this.sumField] || 0) : this.columnState.count; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates the rendering according to internal data. This is done without + * qweb rendering because there are animations. + * + * @private + */ + _render: function () { + var self = this; + + // Update column display according to active filter + this.trigger_up('tweak_column', { + callback: function ($el) { + $el.removeClass('o_kanban_group_show'); + _.each(self.colors, function (val, key) { + $el.removeClass('o_kanban_group_show_' + val); + }); + if (self.activeFilter) { + $el.addClass('o_kanban_group_show o_kanban_group_show_' + self.colors[self.activeFilter]); + } + }, + }); + this.trigger_up('tweak_column_records', { + callback: function ($el, recordData) { + var categoryValue = recordData[self.fieldName] ? recordData[self.fieldName] : '__false'; + _.each(self.colors, function (val, key) { + $el.removeClass('oe_kanban_card_' + val); + }); + if (self.colors[categoryValue]) { + $el.addClass('oe_kanban_card_' + self.colors[categoryValue]); + } + }, + }); + + // Display and animate the progress bars + var barNumber = 0; + var barMinWidth = 6; // In % + const selection = self.columnState.fields[self.fieldName].selection; + _.each(self.colors, function (val, key) { + var $bar = self.$bars[key]; + var count = self.subgroupCounts && self.subgroupCounts[key] || 0; + + if (!$bar) { + return; + } + + // Adapt tooltip + let value; + if (selection) { // progressbar on a field of type selection + const option = selection.find(option => option[0] === key); + value = option && option[1] || _t('Other'); + } else { + value = key; + } + $bar.attr('data-original-title', count + ' ' + value); + $bar.tooltip({ + delay: 0, + trigger: 'hover', + }); + + // Adapt active state + $bar.toggleClass('progress-bar-animated progress-bar-striped', key === self.activeFilter); + + // Adapt width + $bar.removeClass('o_bar_has_records transition-off'); + window.getComputedStyle($bar[0]).getPropertyValue('width'); // Force reflow so that animations work + if (count > 0) { + $bar.addClass('o_bar_has_records'); + // Make sure every bar that has records has some space + // and that everything adds up to 100% + var maxWidth = 100 - barMinWidth * barNumber; + self.$('.progress-bar.o_bar_has_records').css('max-width', maxWidth + '%'); + $bar.css('width', (count * 100 / self.groupCount) + '%'); + barNumber++; + $bar.attr('aria-valuemin', 0); + $bar.attr('aria-valuemax', self.groupCount); + $bar.attr('aria-valuenow', count); + } else { + $bar.css('width', ''); + } + }); + this.$('.progress-bar').css('min-width', ''); + this.$('.progress-bar.o_bar_has_records').css('min-width', barMinWidth + '%'); + + // Display and animate the counter number + var start = this.prevTotalCounterValue; + var end = this.totalCounterValue; + + if (this.activeFilter) { + if (this.sumField) { + end = 0; + _.each(self.columnState.data, function (record) { + var recordData = record.data; + if (self.activeFilter === recordData[self.fieldName] || + (self.activeFilter === '__false' && !recordData[self.fieldName])) { + end += parseFloat(recordData[self.sumField]); + } + }); + } else { + end = this.subgroupCounts[this.activeFilter]; + } + } + this.prevTotalCounterValue = end; + var animationClass = start > 999 ? 'o_kanban_grow' : 'o_kanban_grow_huge'; + + if (start !== undefined && (end > start || this.activeFilter) && this.ANIMATE) { + $({currentValue: start}).animate({currentValue: end}, { + duration: 1000, + start: function () { + self.$counter.addClass(animationClass); + }, + step: function () { + self.$number.html(_getCounterHTML(this.currentValue)); + }, + complete: function () { + self.$number.html(_getCounterHTML(this.currentValue)); + self.$counter.removeClass(animationClass); + }, + }); + } else { + this.$number.html(_getCounterHTML(end)); + } + + function _getCounterHTML(value) { + return utils.human_number(value, 0, 3); + } + }, + /** + * Notifies the new progressBar state so that if a full rerender occurs, the + * new progressBar that would replace this one will be initialized with + * current state, so that animations are correct. + * + * @private + */ + _notifyState: function () { + this.trigger_up('set_progress_bar_state', { + columnID: this.columnID, + values: { + groupCount: this.groupCount, + subgroupCounts: this.subgroupCounts, + totalCounterValue: this.totalCounterValue, + activeFilter: this.activeFilter, + }, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onProgressBarClick: function (ev) { + this.$clickedBar = $(ev.currentTarget); + var filter = this.$clickedBar.data('filter'); + this.activeFilter = (this.activeFilter === filter ? false : filter); + this._notifyState(); + this._render(); + }, + /** + * @private + * @param {Event} ev + */ + _onProgressBarParentClick: function (ev) { + if (ev.target !== ev.currentTarget) { + return; + } + this.activeFilter = false; + this._notifyState(); + this._render(); + }, +}); +return KanbanColumnProgressBar; +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js new file mode 100644 index 00000000..c4bed5fa --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js @@ -0,0 +1,246 @@ +odoo.define('web.kanban_column_quick_create', function (require) { +"use strict"; + +/** + * This file defines the ColumnQuickCreate widget for Kanban. It allows to + * create kanban columns directly from the Kanban view. + */ + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); + +var _t = core._t; +var QWeb = core.qweb; + +var ColumnQuickCreate = Widget.extend({ + template: 'KanbanView.ColumnQuickCreate', + events: { + 'click .o_quick_create_folded': '_onUnfold', + 'click .o_kanban_add': '_onAddClicked', + 'click .o_kanban_examples': '_onShowExamples', + 'keydown': '_onKeydown', + 'keypress input': '_onKeypress', + 'blur input': '_onInputBlur', + 'focus input': '_onInputFocus', + }, + + /** + * @override + * @param {Object} [options] + * @param {Object} [options.examples] + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.applyExamplesText = options.applyExampleText || _t("Use This For My Kanban"); + this.examples = options.examples; + this.folded = true; + this.isMobile = false; + }, + /** + * @override + */ + start: function () { + this.$quickCreateFolded = this.$('.o_quick_create_folded'); + this.$quickCreateUnfolded = this.$('.o_quick_create_unfolded'); + this.$input = this.$('input'); + + // destroy the quick create when the user clicks outside + core.bus.on('click', this, this._onWindowClicked); + + this._update(); + + return this._super.apply(this, arguments); + }, + /** + * Called each time the quick create is attached into the DOM + */ + on_attach_callback: function () { + if (!this.folded) { + this.$input.focus(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Folds/unfolds the Column quick create widget + */ + toggleFold: function () { + this.folded = !this.folded; + this._update(); + if (!this.folded) { + this.$input.focus(); + this.trigger_up('scrollTo', {selector: '.o_column_quick_create'}); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Clears the input value and notify the environment to create a column + * + * @private + */ + _add: function () { + var value = this.$input.val().trim(); + if (!value.length) { + this._cancel(); + return; + } + this.$input.val(''); + this.trigger_up('quick_create_add_column', {value: value}); + this.$input.focus(); + }, + /** + * Cancels the quick creation + * + * @private + */ + _cancel: function () { + if (!this.folded) { + this.$input.val(''); + this.folded = true; + this._update(); + } + }, + /** + * Updates the rendering according to the current state (folded/unfolded) + * + * @private + */ + _update: function () { + this.$quickCreateFolded.toggle(this.folded); + this.$quickCreateUnfolded.toggle(!this.folded); + this.trigger_up('quick_create_column_updated'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onAddClicked: function (event) { + event.stopPropagation(); + this._add(); + }, + /** + * When the input is not focused, no key event may occur in the column, so + * the discard feature will not work by pressing ESC. We simply hide the + * help message in that case, so we do not mislead our users. + * + * @private + * @param {KeyboardEvent} event + */ + _onInputBlur: function () { + this.$('.o_discard_msg').hide(); + }, + /** + * When the input is focused, we need to show the discard help message (it + * might have been hidden, @see _onInputBlur) + * + * @private + * @param {KeyboardEvent} event + */ + _onInputFocus: function () { + this.$('.o_discard_msg').show(); + }, + /** + * Cancels quick creation on escape keydown event + * + * @private + * @param {KeyEvent} event + */ + _onKeydown: function (event) { + if (event.keyCode === $.ui.keyCode.ESCAPE) { + this._cancel(); + } + }, + /** + * Validates quick creation on enter keypress event + * + * @private + * @param {KeyEvent} event + */ + _onKeypress: function (event) { + if (event.keyCode === $.ui.keyCode.ENTER) { + this._add(); + } + }, + /** + * Opens a dialog containing examples of Kanban processes + * + * @private + */ + _onShowExamples: function () { + var self = this; + var dialog = new Dialog(this, { + $content: $(QWeb.render('KanbanView.ExamplesDialog', { + examples: this.examples, + })), + buttons: [{ + classes: 'btn-primary float-right', + text: this.applyExamplesText, + close: true, + click: function () { + const activeExample = self.examples[this.$('.nav-link.active').data("exampleIndex")]; + activeExample.columns.forEach(column => { + self.trigger_up('quick_create_add_column', { value: column.toString(), foldQuickCreate: true }); + }); + } + }, { + classes: 'btn-secondary float-right', + close: true, + text: _t('Close'), + }], + size: "large", + title: "Kanban Examples", + }).open(); + dialog.on('closed', this, function () { + self.$input.focus(); + }); + }, + /** + * @private + */ + _onUnfold: function () { + if (this.folded) { + this.toggleFold(); + } + }, + /** + * When a click happens outside the quick create, we want to close it. + * + * @private + * @param {MouseEvent} event + */ + _onWindowClicked: function (event) { + // ignore clicks if the quick create is not in the dom + if (!document.contains(this.el)) { + return; + } + + // ignore clicks in modals + if ($(event.target).closest('.modal').length) { + return; + } + + // ignore clicks if target is inside the quick create + if (this.el.contains(event.target)) { + return; + } + + this._cancel(); + }, +}); + +return ColumnQuickCreate; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js new file mode 100644 index 00000000..1b6e6301 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -0,0 +1,537 @@ +odoo.define('web.KanbanController', function (require) { +"use strict"; + +/** + * The KanbanController is the class that coordinates the kanban model and the + * kanban renderer. It also makes sure that update from the search view are + * properly interpreted. + */ + +var BasicController = require('web.BasicController'); +var Context = require('web.Context'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Domain = require('web.Domain'); +var view_dialogs = require('web.view_dialogs'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +var KanbanController = BasicController.extend({ + buttons_template: 'KanbanView.buttons', + custom_events: _.extend({}, BasicController.prototype.custom_events, { + add_quick_create: '_onAddQuickCreate', + quick_create_add_column: '_onAddColumn', + quick_create_record: '_onQuickCreateRecord', + resequence_columns: '_onResequenceColumn', + button_clicked: '_onButtonClicked', + kanban_record_delete: '_onRecordDelete', + kanban_record_update: '_onUpdateRecord', + kanban_column_delete: '_onDeleteColumn', + kanban_column_add_record: '_onAddRecordToColumn', + kanban_column_resequence: '_onColumnResequence', + kanban_load_more: '_onLoadMore', + column_toggle_fold: '_onToggleColumn', + kanban_column_records_toggle_active: '_onToggleActiveRecords', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.quickCreateEnabled set to false to disable the + * quick create feature + * @param {SearchPanel} [params.searchPanel] + * @param {Array[]} [params.controlPanelDomain=[]] initial domain coming + * from the controlPanel + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.on_create = params.on_create; + this.hasButtons = params.hasButtons; + this.quickCreateEnabled = params.quickCreateEnabled; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {jQuery} [$node] + */ + renderButtons: function ($node) { + if (!this.hasButtons || !this.is_action_enabled('create')) { + return; + } + 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); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Displays the record quick create widget in the requested column, given its + * id (in the first column by default). Ensures that we removed sample data + * if any, before displaying the quick create. + * + * @private + * @param {string} [groupId] + */ + _addQuickCreate(groupId) { + this._removeSampleData(async () => { + await this.update({ shouldUpdateSearchComponents: false }, { reload: false }); + return this.renderer.addQuickCreate(groupId); + }); + }, + /** + * @override method comes from field manager mixin + * @private + * @param {string} id local id from the basic record data + * @returns {Promise} + */ + _confirmSave: function (id) { + var data = this.model.get(this.handle, {raw: true}); + var grouped = data.groupedBy.length; + if (grouped) { + var columnState = this.model.getColumn(id); + return this.renderer.updateColumn(columnState.id, columnState); + } + return this.renderer.updateRecord(this.model.get(id)); + }, + /** + * Only display the pager in the ungrouped case, with data. + * + * @override + * @private + */ + _getPagingInfo: function (state) { + if (!(state.count && !state.groupedBy.length)) { + return null; + } + return this._super(...arguments); + }, + /** + * @private + * @param {Widget} kanbanRecord + * @param {Object} params + */ + _reloadAfterButtonClick: function (kanbanRecord, params) { + var self = this; + var recordModel = this.model.localData[params.record.id]; + var group = this.model.localData[recordModel.parentID]; + var parent = this.model.localData[group.parentID]; + + this.model.reload(params.record.id).then(function (db_id) { + var data = self.model.get(db_id); + kanbanRecord.update(data); + + // Check if we still need to display the record. Some fields of the domain are + // not guaranteed to be in data. This is for example the case if the action + // contains a domain on a field which is not in the Kanban view. Therefore, + // we need to handle multiple cases based on 3 variables: + // domInData: all domain fields are in the data + // activeInDomain: 'active' is already in the domain + // activeInData: 'active' is available in the data + + var domain = (parent ? parent.domain : group.domain) || []; + var domInData = _.every(domain, function (d) { + return d[0] in data.data; + }); + var activeInDomain = _.pluck(domain, 0).indexOf('active') !== -1; + var activeInData = 'active' in data.data; + + // Case # | domInData | activeInDomain | activeInData + // 1 | true | true | true => no domain change + // 2 | true | true | false => not possible + // 3 | true | false | true => add active in domain + // 4 | true | false | false => no domain change + // 5 | false | true | true => no evaluation + // 6 | false | true | false => no evaluation + // 7 | false | false | true => replace domain + // 8 | false | false | false => no evaluation + + // There are 3 cases which cannot be evaluated since we don't have all the + // necessary information. The complete solution would be to perform a RPC in + // these cases, but this is out of scope. A simpler one is to do a try / catch. + + if (domInData && !activeInDomain && activeInData) { + domain = domain.concat([['active', '=', true]]); + } else if (!domInData && !activeInDomain && activeInData) { + domain = [['active', '=', true]]; + } + try { + var visible = new Domain(domain).compute(data.evalContext); + } catch (e) { + return; + } + if (!visible) { + kanbanRecord.destroy(); + } + }); + }, + /** + * @param {number[]} ids + * @private + * @returns {Promise} + */ + _resequenceColumns: function (ids) { + var state = this.model.get(this.handle, {raw: true}); + var model = state.fields[state.groupedBy[0]].relation; + return this.model.resequence(model, ids, this.handle); + }, + /** + * This method calls the server to ask for a resequence. Note that this + * does not rerender the user interface, because in most case, the + * resequencing operation has already been displayed by the renderer. + * + * @private + * @param {string} column_id + * @param {string[]} ids + * @returns {Promise} + */ + _resequenceRecords: function (column_id, ids) { + var self = this; + return this.model.resequence(this.modelName, ids, column_id); + }, + /** + * @override + */ + _shouldBounceOnClick(element) { + const state = this.model.get(this.handle, {raw: true}); + if (!state.count || state.isSample) { + const classesList = [ + 'o_kanban_view', + 'o_kanban_group', + 'o_kanban_header', + 'o_column_quick_create', + 'o_view_nocontent_smiling_face', + ]; + return classesList.some(c => element.classList.contains(c)); + } + return false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This handler is called when an event (from the quick create add column) + * event bubbles up. When that happens, we need to ask the model to create + * a group and to update the renderer + * + * @private + * @param {OdooEvent} ev + */ + _onAddColumn: function (ev) { + var self = this; + this.mutex.exec(function () { + return self.model.createGroup(ev.data.value, self.handle).then(function () { + var state = self.model.get(self.handle, {raw: true}); + var ids = _.pluck(state.data, 'res_id').filter(_.isNumber); + return self._resequenceColumns(ids); + }).then(function () { + return self.update({}, {reload: false}); + }).then(function () { + let quickCreateFolded = self.renderer.quickCreate.folded; + if (ev.data.foldQuickCreate ? !quickCreateFolded : quickCreateFolded) { + self.renderer.quickCreateToggleFold(); + } + self.renderer.trigger_up("quick_create_column_created"); + }); + }); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onAddRecordToColumn: function (ev) { + var self = this; + var record = ev.data.record; + var column = ev.target; + this.alive(this.model.moveRecord(record.db_id, column.db_id, this.handle)) + .then(function (column_db_ids) { + return self._resequenceRecords(column.db_id, ev.data.ids) + .then(function () { + _.each(column_db_ids, function (db_id) { + var data = self.model.get(db_id); + self.renderer.updateColumn(db_id, data); + }); + }); + }).guardedCatch(this.reload.bind(this)); + }, + /** + * @private + * @param {OdooEvent} ev + * @returns {string} ev.data.groupId + */ + _onAddQuickCreate(ev) { + ev.stopPropagation(); + this._addQuickCreate(ev.data.groupId); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + var self = this; + ev.stopPropagation(); + var attrs = ev.data.attrs; + var record = ev.data.record; + var def = Promise.resolve(); + if (attrs.context) { + attrs.context = new Context(attrs.context) + .set_eval_context({ + active_id: record.res_id, + active_ids: [record.res_id], + active_model: record.model, + }); + } + if (attrs.confirm) { + def = new Promise(function (resolve, reject) { + Dialog.confirm(this, attrs.confirm, { + confirm_callback: resolve, + cancel_callback: reject, + }).on("closed", null, reject); + }); + } + def.then(function () { + self.trigger_up('execute_action', { + action_data: attrs, + env: { + context: record.getContext(), + currentID: record.res_id, + model: record.model, + resIDs: record.res_ids, + }, + on_closed: self._reloadAfterButtonClick.bind(self, ev.target, ev.data), + }); + }); + }, + /** + * @private + */ + _onButtonNew: function () { + var state = this.model.get(this.handle, {raw: true}); + var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state); + if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) { + // activate the quick create in the first column when the mutex is + // unlocked, to ensure that there is no pending re-rendering that + // would remove it (e.g. if we are currently adding a new column) + this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null)); + } else if (this.on_create && this.on_create !== 'quick_create') { + // Execute the given action + this.do_action(this.on_create, { + on_close: this.reload.bind(this, {}), + additional_context: state.context, + }); + } else { + // Open the form view + this.trigger_up('switch_view', { + view_type: 'form', + res_id: undefined + }); + } + }, + /** + * Moves the focus from the controller buttons to the first kanban record + * + * @private + * @param {jQueryEvent} ev + */ + _onButtonsKeyDown: function (ev) { + switch(ev.keyCode) { + case $.ui.keyCode.DOWN: + this._giveFocus(); + } + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onColumnResequence: function (ev) { + this._resequenceRecords(ev.target.db_id, ev.data.ids); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onDeleteColumn: function (ev) { + var column = ev.target; + var state = this.model.get(this.handle, {raw: true}); + var relatedModelName = state.fields[state.groupedBy[0]].relation; + this.model + .deleteRecords([column.db_id], relatedModelName) + .then(this.update.bind(this, {}, {})); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onLoadMore: function (ev) { + var self = this; + var column = ev.target; + this.model.loadMore(column.db_id).then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.updateColumn(db_id, data); + }); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {KanbanColumn} ev.target the column in which the record should + * be added + * @param {Object} ev.data.values the field values of the record to + * create; if values only contains the value of the 'display_name', a + * 'name_create' is performed instead of 'create' + * @param {function} [ev.data.onFailure] called when the quick creation + * failed + */ + _onQuickCreateRecord: function (ev) { + var self = this; + var values = ev.data.values; + var column = ev.target; + var onFailure = ev.data.onFailure || function () {}; + + // 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) { + + var columnState = self.model.getColumn(db_id); + var state = self.model.get(self.handle); + return self.renderer + .updateColumn(columnState.id, columnState, {openQuickCreate: true, state: state}) + .then(function () { + if (ev.data.openRecord) { + self.trigger_up('open_record', {id: db_id, mode: 'edit'}); + } + }); + }; + + this.model.createRecordInGroup(column.db_id, values) + .then(update) + .guardedCatch(function (reason) { + reason.event.preventDefault(); + var columnState = self.model.get(column.db_id, {raw: true}); + var context = columnState.getContext(); + var state = self.model.get(self.handle, {raw: true}); + var groupedBy = state.groupedBy[0]; + context['default_' + groupedBy] = viewUtils.getGroupValue(columnState, groupedBy); + new view_dialogs.FormViewDialog(self, { + res_model: state.model, + context: _.extend({default_name: values.name || values.display_name}, context), + title: _t("Create"), + disable_multiple_selection: true, + on_saved: function (record) { + self.model.addRecordToGroup(column.db_id, record.res_id) + .then(update); + }, + }).open().opened(onFailure); + }); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onRecordDelete: function (ev) { + this._deleteRecords([ev.data.id]); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onResequenceColumn: function (ev) { + var self = this; + this._resequenceColumns(ev.data.ids); + }, + /** + * @private + * @param {OdooEvent} ev + * @param {boolean} [ev.data.openQuickCreate=false] if true, opens the + * QuickCreate in the toggled column (it assumes that we are opening it) + */ + _onToggleColumn: function (ev) { + var self = this; + const columnID = ev.target.db_id || ev.data.db_id; + this.model.toggleGroup(columnID) + .then(function (db_id) { + var data = self.model.get(db_id); + var options = { + openQuickCreate: !!ev.data.openQuickCreate, + }; + return self.renderer.updateColumn(db_id, data, options); + }) + .then(function () { + if (ev.data.onSuccess) { + ev.data.onSuccess(); + } + }); + }, + /** + * @todo should simply use field_changed event... + * + * @private + * @param {OdooEvent} ev + * @param {function} [ev.data.onSuccess] callback to execute after applying + * changes + */ + _onUpdateRecord: function (ev) { + var onSuccess = ev.data.onSuccess; + delete ev.data.onSuccess; + var changes = _.clone(ev.data); + ev.data.force_save = true; + this._applyChanges(ev.target.db_id, changes, ev).then(onSuccess); + }, + /** + * Allow the user to archive/restore all the records of a column. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleActiveRecords: function (ev) { + var self = this; + var archive = ev.data.archive; + var column = ev.target; + var recordIds = _.pluck(column.records, 'id'); + if (recordIds.length) { + var prom = archive ? + this.model.actionArchive(recordIds, column.db_id) : + this.model.actionUnarchive(recordIds, column.db_id); + prom.then(function (dbID) { + var data = self.model.get(dbID); + if (data) { // Could be null if a wizard is returned for example + self.model.reload(self.handle).then(function () { + const state = self.model.get(self.handle); + self.renderer.updateColumn(dbID, data, { state }); + }); + } + }); + } + }, +}); + +return KanbanController; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_examples_registry.js b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js new file mode 100644 index 00000000..effdd7ff --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js @@ -0,0 +1,27 @@ +odoo.define('web.kanban_examples_registry', function (require) { +"use strict"; + +/** + * This file instantiates and exports a registry. The purpose of this registry + * is to store static data displayed in a dialog to help the end user to + * configure its columns in the grouped Kanban view. + * + * To activate a link on the ColumnQuickCreate widget on open such a dialog, the + * attribute 'examples' on the root arch node must be set to a valid key in this + * registry. + * + * Each value in this registry must be an array of Objects containing the + * following keys: + * - name (string) + * - columns (Array[string]) + * - description (string, optional) BE CAREFUL [*] + * + * [*] The description is added with a t-raw so the translated texts must be + * properly escaped. + */ + +var Registry = require('web.Registry'); + +return new Registry(); + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js new file mode 100644 index 00000000..7dcfe408 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -0,0 +1,445 @@ +odoo.define('web.KanbanModel', function (require) { +"use strict"; + +/** + * The KanbanModel extends the BasicModel to add Kanban specific features like + * moving a record from a group to another, resequencing records... + */ + +var BasicModel = require('web.BasicModel'); +var viewUtils = require('web.viewUtils'); + +var KanbanModel = BasicModel.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds a record to a group in the localData, and fetch the record. + * + * @param {string} groupID localID of the group + * @param {integer} resId id of the record + * @returns {Promise<string>} resolves to the local id of the new record + */ + addRecordToGroup: function (groupID, resId) { + var self = this; + var group = this.localData[groupID]; + var new_record = this._makeDataPoint({ + res_id: resId, + modelName: group.model, + fields: group.fields, + fieldsInfo: group.fieldsInfo, + viewType: group.viewType, + parentID: groupID, + }); + + var def = this._fetchRecord(new_record).then(function (result) { + group.data.unshift(new_record.id); + group.res_ids.unshift(resId); + group.count++; + + // update the res_ids and count of the parent + self.localData[group.parentID].count++; + self._updateParentResIDs(group); + + return result.id; + }); + return this._reloadProgressBarGroupFromRecord(new_record.id, def); + }, + /** + * Creates a new group from a name (performs a name_create). + * + * @param {string} name + * @param {string} parentID localID of the parent of the group + * @returns {Promise<string>} resolves to the local id of the new group + */ + createGroup: function (name, parentID) { + var self = this; + var parent = this.localData[parentID]; + var groupBy = parent.groupedBy[0]; + var groupByField = parent.fields[groupBy]; + if (!groupByField || groupByField.type !== 'many2one') { + return Promise.reject(); // only supported when grouped on m2o + } + return this._rpc({ + model: groupByField.relation, + method: 'name_create', + args: [name], + context: parent.context, // todo: combine with view context + }) + .then(function (result) { + const createGroupDataPoint = (model, parent) => { + const newGroup = model._makeDataPoint({ + modelName: parent.model, + context: parent.context, + domain: parent.domain.concat([[groupBy, "=", result[0]]]), + fields: parent.fields, + fieldsInfo: parent.fieldsInfo, + isOpen: true, + limit: parent.limit, + parentID: parent.id, + openGroupByDefault: true, + orderedBy: parent.orderedBy, + value: result, + viewType: parent.viewType, + }); + if (parent.progressBar) { + newGroup.progressBarValues = _.extend({ + counts: {}, + }, parent.progressBar); + } + return newGroup; + }; + const newGroup = createGroupDataPoint(self, parent); + parent.data.push(newGroup.id); + if (self.isInSampleMode()) { + // in sample mode, create the new group in both models (main + sample) + const sampleParent = self.sampleModel.localData[parentID]; + const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent); + sampleParent.data.push(newSampleGroup.id); + } + return newGroup.id; + }); + }, + /** + * Creates a new record from the given value, and add it to the given group. + * + * @param {string} groupID + * @param {Object} values + * @returns {Promise} resolved with the local id of the created record + */ + createRecordInGroup: function (groupID, values) { + var self = this; + var group = this.localData[groupID]; + var context = this._getContext(group); + var parent = this.localData[group.parentID]; + var groupedBy = parent.groupedBy; + context['default_' + groupedBy] = viewUtils.getGroupValue(group, groupedBy); + var def; + if (Object.keys(values).length === 1 && 'display_name' in values) { + // only 'display_name is given, perform a 'name_create' + def = this._rpc({ + model: parent.model, + method: 'name_create', + args: [values.display_name], + context: context, + }).then(function (records) { + return records[0]; + }); + } else { + // other fields are specified, perform a classical 'create' + def = this._rpc({ + model: parent.model, + method: 'create', + args: [values], + context: context, + }); + } + return def.then(function (resID) { + return self.addRecordToGroup(group.id, resID); + }); + }, + /** + * Add the following (kanban specific) keys when performing a `get`: + * + * - tooltipData + * - progressBarValues + * - isGroupedByM2ONoColumn + * + * @override + * @see _readTooltipFields + * @returns {Object} + */ + __get: function () { + var result = this._super.apply(this, arguments); + var dp = result && this.localData[result.id]; + if (dp) { + if (dp.tooltipData) { + result.tooltipData = $.extend(true, {}, dp.tooltipData); + } + if (dp.progressBarValues) { + result.progressBarValues = $.extend(true, {}, dp.progressBarValues); + } + if (dp.fields[dp.groupedBy[0]]) { + var groupedByM2O = dp.fields[dp.groupedBy[0]].type === 'many2one'; + result.isGroupedByM2ONoColumn = !dp.data.length && groupedByM2O; + } else { + result.isGroupedByM2ONoColumn = false; + } + } + return result; + }, + /** + * Same as @see get but getting the parent element whose ID is given. + * + * @param {string} id + * @returns {Object} + */ + getColumn: function (id) { + var element = this.localData[id]; + if (element) { + return this.get(element.parentID); + } + return null; + }, + /** + * @override + */ + __load: function (params) { + this.defaultGroupedBy = params.groupBy || []; + params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy; + return this._super(params); + }, + /** + * Load more records in a group. + * + * @param {string} groupID localID of the group + * @returns {Promise<string>} resolves to the localID of the group + */ + loadMore: function (groupID) { + var group = this.localData[groupID]; + var offset = group.loadMoreOffset + group.limit; + return this.reload(group.id, { + loadMoreOffset: offset, + }); + }, + /** + * Moves a record from a group to another. + * + * @param {string} recordID localID of the record + * @param {string} groupID localID of the new group of the record + * @param {string} parentID localID of the parent + * @returns {Promise<string[]>} resolves to a pair [oldGroupID, newGroupID] + */ + moveRecord: function (recordID, groupID, parentID) { + var self = this; + var parent = this.localData[parentID]; + var new_group = this.localData[groupID]; + var changes = {}; + var groupedFieldName = parent.groupedBy[0]; + var groupedField = parent.fields[groupedFieldName]; + if (groupedField.type === 'many2one') { + changes[groupedFieldName] = { + id: new_group.res_id, + display_name: new_group.value, + }; + } else if (groupedField.type === 'selection') { + var value = _.findWhere(groupedField.selection, {1: new_group.value}); + changes[groupedFieldName] = value && value[0] || false; + } else { + changes[groupedFieldName] = new_group.value; + } + + // Manually updates groups data. Note: this is done before the actual + // save as it might need to perform a read group in some cases so those + // updated data might be overridden again. + var record = self.localData[recordID]; + var resID = record.res_id; + // Remove record from its current group + var old_group; + for (var i = 0; i < parent.data.length; i++) { + old_group = self.localData[parent.data[i]]; + var index = _.indexOf(old_group.data, recordID); + if (index >= 0) { + old_group.data.splice(index, 1); + old_group.count--; + old_group.res_ids = _.without(old_group.res_ids, resID); + self._updateParentResIDs(old_group); + break; + } + } + // Add record to its new group + new_group.data.push(recordID); + new_group.res_ids.push(resID); + new_group.count++; + + return this.notifyChanges(recordID, changes).then(function () { + return self.save(recordID); + }).then(function () { + record.parentID = new_group.id; + return [old_group.id, new_group.id]; + }); + }, + /** + * @override + */ + reload: function (id, options) { + // if the groupBy is given in the options and if it is an empty array, + // fallback on the default groupBy + if (options && options.groupBy && !options.groupBy.length) { + options.groupBy = this.defaultGroupedBy; + } + return this._super(id, options); + }, + /** + * @override + */ + __reload: function (id, options) { + var def = this._super(id, options); + if (options && options.loadMoreOffset) { + return def; + } + return this._reloadProgressBarGroupFromRecord(id, def); + }, + /** + * @override + */ + save: function (recordID) { + var def = this._super.apply(this, arguments); + return this._reloadProgressBarGroupFromRecord(recordID, def); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _makeDataPoint: function (params) { + var dataPoint = this._super.apply(this, arguments); + if (params.progressBar) { + dataPoint.progressBar = params.progressBar; + } + return dataPoint; + }, + /** + * @override + */ + _load: function (dataPoint, options) { + if (dataPoint.groupedBy.length && dataPoint.progressBar) { + return this._readProgressBarGroup(dataPoint, options); + } + return this._super.apply(this, arguments); + }, + /** + * Ensures that there is no nested groups in Kanban (only the first grouping + * level is taken into account). + * + * @override + * @private + * @param {Object} list valid resource object + */ + _readGroup: function (list) { + var self = this; + if (list.groupedBy.length > 1) { + list.groupedBy = [list.groupedBy[0]]; + } + return this._super.apply(this, arguments).then(function (result) { + return self._readTooltipFields(list).then(_.constant(result)); + }); + }, + /** + * @private + * @param {Object} dataPoint + * @returns {Promise<Object>} + */ + _readProgressBarGroup: function (list, options) { + var self = this; + var groupsDef = this._readGroup(list, options); + var progressBarDef = this._rpc({ + model: list.model, + method: 'read_progress_bar', + kwargs: { + domain: list.domain, + group_by: list.groupedBy[0], + progress_bar: list.progressBar, + context: list.context, + }, + }); + return Promise.all([groupsDef, progressBarDef]).then(function (results) { + var data = results[1]; + _.each(list.data, function (groupID) { + var group = self.localData[groupID]; + group.progressBarValues = _.extend({ + counts: data[group.value] || {}, + }, list.progressBar); + }); + return list; + }); + }, + /** + * Fetches tooltip specific fields on the group by relation and stores it in + * the column datapoint in a special key `tooltipData`. + * Data for the tooltips (group_by_tooltip) are fetched in batch for all + * groups, to avoid doing multiple calls. + * Data are stored in a special key `tooltipData` on the datapoint. + * Note that the option `group_by_tooltip` is only for m2o fields. + * + * @private + * @param {Object} list a list of groups + * @returns {Promise} + */ + _readTooltipFields: function (list) { + var self = this; + var groupedByField = list.fields[list.groupedBy[0].split(':')[0]]; + if (groupedByField.type !== 'many2one') { + return Promise.resolve(); + } + var groupIds = _.reduce(list.data, function (groupIds, id) { + var res_id = self.get(id, {raw: true}).res_id; + // The field on which we are grouping might not be set on all records + if (res_id) { + groupIds.push(res_id); + } + return groupIds; + }, []); + var tooltipFields = []; + var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]]; + if (groupedByFieldInfo && groupedByFieldInfo.options) { + tooltipFields = Object.keys(groupedByFieldInfo.options.group_by_tooltip || {}); + } + if (groupIds.length && tooltipFields.length) { + var fieldNames = _.union(['display_name'], tooltipFields); + return this._rpc({ + model: groupedByField.relation, + method: 'read', + args: [groupIds, fieldNames], + context: list.context, + }).then(function (result) { + _.each(list.data, function (id) { + var dp = self.localData[id]; + dp.tooltipData = _.findWhere(result, {id: dp.res_id}); + }); + }); + } + return Promise.resolve(); + }, + /** + * Reloads all progressbar data. This is done after given promise and + * insures that the given promise's result is not lost. + * + * @private + * @param {string} recordID + * @param {Promise} def + * @returns {Promise} + */ + _reloadProgressBarGroupFromRecord: function (recordID, def) { + var element = this.localData[recordID]; + if (element.type === 'list' && !element.parentID) { + // we are reloading the whole view, so there is no need to manually + // reload the progressbars + return def; + } + + // If we updated a record, then we must potentially update columns' + // progressbars, so we need to load groups info again + var self = this; + while (element) { + if (element.progressBar) { + return def.then(function (data) { + return self._load(element, { + keepEmptyGroups: true, + onlyGroups: true, + }).then(function () { + return data; + }); + }); + } + element = this.localData[element.parentID]; + } + return def; + }, +}); +return KanbanModel; +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js new file mode 100644 index 00000000..02dc22e9 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -0,0 +1,761 @@ +odoo.define('web.KanbanRecord', function (require) { +"use strict"; + +/** + * This file defines the KanbanRecord widget, which corresponds to a card in + * a Kanban view. + */ +var config = require('web.config'); +var core = require('web.core'); +var Domain = require('web.Domain'); +var Dialog = require('web.Dialog'); +var field_utils = require('web.field_utils'); +const FieldWrapper = require('web.FieldWrapper'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); +var widgetRegistry = require('web.widget_registry'); + +var _t = core._t; +var QWeb = core.qweb; + +var KANBAN_RECORD_COLORS = require('web.basic_fields').FieldColorPicker.prototype.RECORD_COLORS; +var NB_KANBAN_RECORD_COLORS = KANBAN_RECORD_COLORS.length; + +var KanbanRecord = Widget.extend({ + events: { + 'click .oe_kanban_action': '_onKanbanActionClicked', + 'click .o_kanban_manage_toggle_button': '_onManageTogglerClicked', + }, + /** + * @override + */ + init: function (parent, state, options) { + this._super(parent); + + this.fields = state.fields; + this.fieldsInfo = state.fieldsInfo.kanban; + this.modelName = state.model; + + this.options = options; + this.editable = options.editable; + this.deletable = options.deletable; + this.read_only_mode = options.read_only_mode; + this.selectionMode = options.selectionMode; + this.qweb = options.qweb; + this.subWidgets = {}; + + this._setState(state); + // avoid quick multiple clicks + this._onKanbanActionClicked = _.debounce(this._onKanbanActionClicked, 300, true); + }, + /** + * @override + */ + start: function () { + return Promise.all([this._super.apply(this, arguments), this._render()]); + }, + /** + * Called each time the record is attached to the DOM. + */ + on_attach_callback: function () { + this.isInDOM = true; + _.invoke(this.subWidgets, 'on_attach_callback'); + }, + /** + * Called each time the record is detached from the DOM. + */ + on_detach_callback: function () { + this.isInDOM = false; + _.invoke(this.subWidgets, 'on_detach_callback'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Re-renders the record with a new state + * + * @param {Object} state + * @returns {Promise} + */ + update: function (state) { + // detach the widgets because the record will empty its $el, which will + // remove all event handlers on its descendants, and we want to keep + // those handlers alive as we will re-use these widgets + _.invoke(_.pluck(this.subWidgets, '$el'), 'detach'); + this._setState(state); + return this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _attachTooltip: function () { + var self = this; + this.$('[tooltip]').each(function () { + var $el = $(this); + var tooltip = $el.attr('tooltip'); + if (tooltip) { + $el.tooltip({ + title: self.qweb.render(tooltip, self.qweb_context) + }); + } + }); + }, + /** + * @private + * @param {string} d a stringified domain + * @returns {boolean} the domain evaluted with the current values + */ + _computeDomain: function (d) { + return new Domain(d).compute(this.state.evalContext); + }, + /** + * Generates the color classname from a given variable + * + * @private + * @param {number | string} variable + * @return {string} the classname + */ + _getColorClassname: function (variable) { + var color = this._getColorID(variable); + return 'oe_kanban_color_' + color; + }, + /** + * Computes a color id between 0 and 10 from a given value + * + * @private + * @param {number | string} variable + * @returns {integer} the color id + */ + _getColorID: function (variable) { + if (typeof variable === 'number') { + return Math.round(variable) % NB_KANBAN_RECORD_COLORS; + } + if (typeof variable === 'string') { + var index = 0; + for (var i = 0; i < variable.length; i++) { + index += variable.charCodeAt(i); + } + return index % NB_KANBAN_RECORD_COLORS; + } + return 0; + }, + /** + * Computes a color name from value + * + * @private + * @param {number | string} variable + * @returns {integer} the color name + */ + _getColorname: function (variable) { + var colorID = this._getColorID(variable); + return KANBAN_RECORD_COLORS[colorID]; + }, + file_type_magic_word: { + '/': 'jpg', + 'R': 'gif', + 'i': 'png', + 'P': 'svg+xml', + }, + /** + * @private + * @param {string} model the name of the model + * @param {string} field the name of the field + * @param {integer} id the id of the resource + * @param {string} placeholder + * @returns {string} the url of the image + */ + _getImageURL: function (model, field, id, placeholder) { + id = (_.isArray(id) ? id[0] : id) || null; + var isCurrentRecord = this.modelName === model && (this.recordData.id === id || (!this.recordData.id && !id)); + var url; + if (isCurrentRecord && this.record[field] && this.record[field].raw_value && !utils.is_bin_size(this.record[field].raw_value)) { + // Use magic-word technique for detecting image type + url = 'data:image/' + this.file_type_magic_word[this.record[field].raw_value[0]] + ';base64,' + this.record[field].raw_value; + } else if (placeholder && (!model || !field || !id || (isCurrentRecord && this.record[field] && !this.record[field].raw_value))) { + url = placeholder; + } else { + var session = this.getSession(); + var params = { + model: model, + field: field, + id: id + }; + if (isCurrentRecord) { + params.unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, ''); + } + url = session.url('/web/image', params); + } + return url; + }, + /** + * Triggers up an event to open the record + * + * @private + */ + _openRecord: function () { + if (this.$el.hasClass('o_currently_dragged')) { + // this record is currently being dragged and dropped, so we do not + // want to open it. + return; + } + var editMode = this.$el.hasClass('oe_kanban_global_click_edit'); + this.trigger_up('open_record', { + id: this.db_id, + mode: editMode ? 'edit' : 'readonly', + }); + }, + /** + * Processes each 'field' tag and replaces it by the specified widget, if + * any, or directly by the formatted value + * + * @private + */ + _processFields: function () { + var self = this; + this.$("field").each(function () { + var $field = $(this); + var field_name = $field.attr("name"); + var field_widget = $field.attr("widget"); + + // a widget is specified for that field or a field is a many2many ; + // in this latest case, we want to display the widget many2manytags + // even if it is not specified in the view. + if (field_widget || self.fields[field_name].type === 'many2many') { + var widget = self.subWidgets[field_name]; + if (!widget) { + // the widget doesn't exist yet, so instanciate it + var Widget = self.fieldsInfo[field_name].Widget; + if (Widget) { + widget = self._processWidget($field, field_name, Widget); + self.subWidgets[field_name] = widget; + } else if (config.isDebug()) { + // the widget is not implemented + $field.replaceWith($('<span>', { + text: _.str.sprintf(_t('[No widget %s]'), field_widget), + })); + } + } else { + // a widget already exists for that field, so reset it with the new state + widget.reset(self.state); + $field.replaceWith(widget.$el); + if (self.isInDOM && widget.on_attach_callback) { + widget.on_attach_callback(); + } + } + } else { + self._processField($field, field_name); + } + }); + }, + /** + * Replace a field by its formatted value. + * + * @private + * @param {JQuery} $field + * @param {String} field_name + * @returns {Jquery} the modified node + */ + _processField: function ($field, field_name) { + // no widget specified for that field, so simply use a formatter + // note: we could have used the widget corresponding to the field's type, but + // it is much more efficient to use a formatter + var field = this.fields[field_name]; + var value = this.recordData[field_name]; + var options = { data: this.recordData, forceString: true }; + var formatted_value = field_utils.format[field.type](value, field, options); + var $result = $('<span>', { + text: formatted_value, + }); + $field.replaceWith($result); + this._setFieldDisplay($result, field_name); + return $result; + }, + /** + * Replace a field by its corresponding widget. + * + * @private + * @param {JQuery} $field + * @param {String} field_name + * @param {Class} Widget + * @returns {Widget} the widget instance + */ + _processWidget: function ($field, field_name, Widget) { + // some field's attrs might be record dependent (they start with + // 't-att-') and should thus be evaluated, which is done by qweb + // we here replace those attrs in the dict of attrs of the state + // by their evaluted value, to make it transparent from the + // field's widgets point of view + // that dict being shared between records, we don't modify it + // in place + var self = this; + var attrs = Object.create(null); + _.each(this.fieldsInfo[field_name], function (value, key) { + if (_.str.startsWith(key, 't-att-')) { + key = key.slice(6); + value = $field.attr(key); + } + attrs[key] = value; + }); + var options = _.extend({}, this.options, { attrs: attrs }); + let widget; + let def; + if (utils.isComponent(Widget)) { + widget = new FieldWrapper(this, Widget, { + fieldName: field_name, + record: this.state, + options: options, + }); + def = widget.mount(document.createDocumentFragment()) + .then(() => { + $field.replaceWith(widget.$el); + }); + } else { + widget = new Widget(this, field_name, this.state, options); + def = widget.replace($field); + } + this.defs.push(def); + def.then(function () { + self._setFieldDisplay(widget.$el, field_name); + }); + return widget; + }, + _processWidgets: function () { + var self = this; + this.$("widget").each(function () { + var $field = $(this); + var Widget = widgetRegistry.get($field.attr('name')); + var widget = new Widget(self, self.state); + + var def = widget._widgetRenderAndInsert(function () { }); + self.defs.push(def); + def.then(function () { + $field.replaceWith(widget.$el); + widget.$el.addClass('o_widget'); + }); + }); + }, + /** + * Renders the record + * + * @returns {Promise} + */ + _render: function () { + this.defs = []; + // call 'on_detach_callback' on each subwidget as they will be removed + // from the DOM at the next line + _.invoke(this.subWidgets, 'on_detach_callback'); + this._replaceElement(this.qweb.render('kanban-box', this.qweb_context)); + this.$el.addClass('o_kanban_record').attr("tabindex", 0); + this.$el.attr('role', 'article'); + this.$el.data('record', this); + // forcefully add class oe_kanban_global_click to have clickable record always to select it + if (this.selectionMode) { + this.$el.addClass('oe_kanban_global_click'); + } + if (this.$el.hasClass('oe_kanban_global_click') || + this.$el.hasClass('oe_kanban_global_click_edit')) { + this.$el.on('click', this._onGlobalClick.bind(this)); + this.$el.on('keydown', this._onKeyDownCard.bind(this)); + } else { + this.$el.on('keydown', this._onKeyDownOpenFirstLink.bind(this)); + } + this._processFields(); + this._processWidgets(); + this._setupColor(); + this._setupColorPicker(); + this._attachTooltip(); + + // We use boostrap tooltips for better and faster display + this.$('span.o_tag').tooltip({ delay: { 'show': 50 } }); + + return Promise.all(this.defs); + }, + /** + * Sets cover image on a kanban card through an attachment dialog. + * + * @private + * @param {string} fieldName field used to set cover image + * @param {boolean} autoOpen automatically open the file choser if there are no attachments + */ + _setCoverImage: function (fieldName, autoOpen) { + var self = this; + this._rpc({ + model: 'ir.attachment', + method: 'search_read', + domain: [ + ['res_model', '=', this.modelName], + ['res_id', '=', this.id], + ['mimetype', 'ilike', 'image'] + ], + fields: ['id', 'name'], + }).then(function (attachmentIds) { + self.imageUploadID = _.uniqueId('o_cover_image_upload'); + self.accepted_file_extensions = 'image/*'; // prevent uploading of other file types + self.attachment_count = attachmentIds.length; + var coverId = self.record[fieldName] && self.record[fieldName].raw_value; + var $content = $(QWeb.render('KanbanView.SetCoverModal', { + coverId: coverId, + attachmentIds: attachmentIds, + widget: self, + })); + var $imgs = $content.find('.o_kanban_cover_image'); + var dialog = new Dialog(self, { + title: _t("Set a Cover Image"), + $content: $content, + buttons: [{ + text: _t("Select"), + classes: attachmentIds.length ? 'btn-primary' : 'd-none', + close: true, + disabled: !coverId, + click: function () { + var $img = $imgs.filter('.o_selected').find('img'); + var data = {}; + data[fieldName] = { + id: $img.data('id'), + display_name: $img.data('name') + }; + self.trigger_up('kanban_record_update', data); + }, + }, { + text: _t('Upload and Set'), + classes: attachmentIds.length ? '' : 'btn-primary', + close: false, + click: function () { + $content.find('input.o_input_file').click(); + }, + }, { + text: _t("Remove Cover Image"), + classes: coverId ? '' : 'd-none', + close: true, + click: function () { + var data = {}; + data[fieldName] = false; + self.trigger_up('kanban_record_update', data); + }, + }, { + text: _t("Discard"), + close: true, + }], + }); + dialog.opened().then(function () { + var $selectBtn = dialog.$footer.find('.btn-primary'); + if (autoOpen && !self.attachment_count) { + $selectBtn.click(); + } + + $content.on('click', '.o_kanban_cover_image', function (ev) { + $imgs.not(ev.currentTarget).removeClass('o_selected'); + $selectBtn.prop('disabled', !$(ev.currentTarget).toggleClass('o_selected').hasClass('o_selected')); + }); + + $content.on('dblclick', '.o_kanban_cover_image', function (ev) { + var $img = $(ev.currentTarget).find('img'); + var data = {}; + data[fieldName] = { + id: $img.data('id'), + display_name: $img.data('name') + }; + self.trigger_up('kanban_record_update', data); + dialog.close(); + }); + $content.on('change', 'input.o_input_file', function () { + $content.find('form.o_form_binary_form').submit(); + }); + $(window).on(self.imageUploadID, function () { + var images = Array.prototype.slice.call(arguments, 1); + var data = {}; + data[fieldName] = { + id: images[0].id, + display_name: images[0].filename + }; + self.trigger_up('kanban_record_update', data); + dialog.close(); + }); + }); + dialog.open(); + }); + }, + /** + * Sets particular classnames on a field $el according to the + * field's attrs (display or bold attributes) + * + * @private + * @param {JQuery} $el + * @param {string} fieldName + */ + _setFieldDisplay: function ($el, fieldName) { + // attribute display + if (this.fieldsInfo[fieldName].display === 'right') { + $el.addClass('float-right'); + } else if (this.fieldsInfo[fieldName].display === 'full') { + $el.addClass('o_text_block'); + } + + // attribute bold + if (this.fieldsInfo[fieldName].bold) { + $el.addClass('o_text_bold'); + } + }, + /** + * Sets internal values of the kanban record according to the given state + * + * @private + * @param {Object} recordState + */ + _setState: function (recordState) { + this.state = recordState; + this.id = recordState.res_id; + this.db_id = recordState.id; + this.recordData = recordState.data; + this.record = this._transformRecord(recordState.data); + this.qweb_context = { + context: this.state.getContext(), + kanban_image: this._getImageURL.bind(this), + kanban_color: this._getColorClassname.bind(this), + kanban_getcolor: this._getColorID.bind(this), + kanban_getcolorname: this._getColorname.bind(this), + kanban_compute_domain: this._computeDomain.bind(this), + selection_mode: this.selectionMode, + read_only_mode: this.read_only_mode, + record: this.record, + user_context: this.getSession().user_context, + widget: this, + }; + }, + /** + * If an attribute `color` is set on the kanban record, adds the + * corresponding color classname. + * + * @private + */ + _setupColor: function () { + var color_field = this.$el.attr('color'); + if (color_field && color_field in this.fields) { + var colorHelp = _.str.sprintf(_t("Card color: %s"), this._getColorname(this.recordData[color_field])); + var colorClass = this._getColorClassname(this.recordData[color_field]); + this.$el.addClass(colorClass); + this.$el.prepend('<span title="' + colorHelp + '" aria-label="' + colorHelp + '" role="img" class="oe_kanban_color_help"/>'); + } + }, + /** + * Renders the color picker in the kanban record, and binds the event handler + * + * @private + */ + _setupColorPicker: function () { + var $colorpicker = this.$('ul.oe_kanban_colorpicker'); + if (!$colorpicker.length) { + return; + } + $colorpicker.html(QWeb.render('KanbanColorPicker', { colors: KANBAN_RECORD_COLORS})); + $colorpicker.on('click', 'a', this._onColorChanged.bind(this)); + }, + /** + * Builds an object containing the formatted record data used in the + * template + * + * @private + * @param {Object} recordData + * @returns {Object} transformed record data + */ + _transformRecord: function (recordData) { + var self = this; + var new_record = {}; + _.each(this.state.getFieldNames(), function (name) { + var value = recordData[name]; + var r = _.clone(self.fields[name] || {}); + + if ((r.type === 'date' || r.type === 'datetime') && value) { + r.raw_value = value.toDate(); + } else if (r.type === 'one2many' || r.type === 'many2many') { + r.raw_value = value.count ? value.res_ids : []; + } else if (r.type === 'many2one') { + r.raw_value = value && value.res_id || false; + } else { + r.raw_value = value; + } + + if (r.type) { + var formatter = field_utils.format[r.type]; + r.value = formatter(value, self.fields[name], recordData, self.state); + } else { + r.value = value; + } + + new_record[name] = r; + }); + return new_record; + }, + /** + * Notifies the controller that the record has changed + * + * @private + * @param {Object} data the new values + */ + _updateRecord: function (data) { + this.trigger_up('kanban_record_update', data); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} event + */ + _onColorChanged: function (event) { + event.preventDefault(); + var data = {}; + var color_field = $(event.delegateTarget).data('field') || 'color'; + data[color_field] = $(event.currentTarget).data('color'); + this.trigger_up('kanban_record_update', data); + }, + /** + * @private + * @param {MouseEvent} event + */ + _onGlobalClick: function (event) { + if ($(event.target).parents('.o_dropdown_kanban').length) { + return; + } + var trigger = true; + var elem = event.target; + var ischild = true; + var children = []; + while (elem) { + var events = $._data(elem, 'events'); + if (elem === event.currentTarget) { + ischild = false; + } + var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== 'bs.tooltip'); + var testLinkWithHref = elem.nodeName.toLowerCase() === 'a' && elem.href; + if (ischild) { + children.push(elem); + if (test_event || testLinkWithHref) { + // Do not trigger global click if one child has a click + // event registered (or it is a link with href) + trigger = false; + } + } + if (trigger && test_event) { + _.each(events.click, function (click_event) { + if (click_event.selector) { + // For each parent of original target, check if a + // delegated click is bound to any previously found children + _.each(children, function (child) { + if ($(child).is(click_event.selector)) { + trigger = false; + } + }); + } + }); + } + elem = elem.parentElement; + } + if (trigger) { + this._openRecord(); + } + }, + /** + * @private + * @param {MouseEvent} event + */ + _onKanbanActionClicked: function (event) { + event.preventDefault(); + + var $action = $(event.currentTarget); + var type = $action.data('type') || 'button'; + + switch (type) { + case 'edit': + this.trigger_up('open_record', { id: this.db_id, mode: 'edit' }); + break; + case 'open': + this.trigger_up('open_record', { id: this.db_id }); + break; + case 'delete': + this.trigger_up('kanban_record_delete', { id: this.db_id, record: this }); + break; + case 'action': + case 'object': + var attrs = $action.data(); + attrs.confirm = $action.attr('confirm'); + this.trigger_up('button_clicked', { + attrs: attrs, + record: this.state, + }); + break; + case 'set_cover': + var fieldName = $action.data('field'); + var autoOpen = $action.data('auto-open'); + if (this.fields[fieldName].type === 'many2one' && + this.fields[fieldName].relation === 'ir.attachment' && + this.fieldsInfo[fieldName].widget === 'attachment_image') { + this._setCoverImage(fieldName, autoOpen); + } else { + var warning = _.str.sprintf(_t('Could not set the cover image: incorrect field ("%s") is provided in the view.'), fieldName); + this.do_warn(warning); + } + break; + default: + this.do_warn(false, _t("Kanban: no action for type: ") + type); + } + }, + /** + * This event is linked to the kanban card when there is a global_click + * class on this card + * + * @private + * @param {KeyDownEvent} event + */ + _onKeyDownCard: function (event) { + switch (event.keyCode) { + case $.ui.keyCode.ENTER: + if ($(event.target).hasClass('oe_kanban_global_click')) { + event.preventDefault(); + this._onGlobalClick(event); + break; + } + } + }, + /** + * This event is linked ot the kanban card when there is no global_click + * class on the card + * + * @private + * @param {KeyDownEvent} event + */ + _onKeyDownOpenFirstLink: function (event) { + switch (event.keyCode) { + case $.ui.keyCode.ENTER: + event.preventDefault(); + $(event.target).find('a, button').first().click(); + break; + } + }, + /** + * Toggles the configuration panel of the record + * + * @private + * @param {MouseEvent} event + */ + _onManageTogglerClicked: function (event) { + event.preventDefault(); + this.$el.parent().find('.o_kanban_record').not(this.$el).removeClass('o_dropdown_open'); + this.$el.toggleClass('o_dropdown_open'); + var colorClass = this._getColorClassname(this.recordData.color || 0); + this.$('.o_kanban_manage_button_section').toggleClass(colorClass); + }, +}); + +return KanbanRecord; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js new file mode 100644 index 00000000..e7206917 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js @@ -0,0 +1,315 @@ +odoo.define('web.kanban_record_quick_create', function (require) { +"use strict"; + +/** + * This file defines the RecordQuickCreate widget for Kanban. It allows to + * create kanban records directly from the Kanban view. + */ + +var core = require('web.core'); +var QuickCreateFormView = require('web.QuickCreateFormView'); +const session = require('web.session'); +var Widget = require('web.Widget'); + +var RecordQuickCreate = Widget.extend({ + className: 'o_kanban_quick_create', + custom_events: { + add: '_onAdd', + cancel: '_onCancel', + }, + events: { + 'click .o_kanban_add': '_onAddClicked', + 'click .o_kanban_edit': '_onEditClicked', + 'click .o_kanban_cancel': '_onCancelClicked', + 'mousedown': '_onMouseDown', + }, + mouseDownInside: false, + + /** + * @override + * @param {Widget} parent + * @param {Object} options + * @param {Object} options.context + * @param {string|null} options.formViewRef + * @param {string} options.model + */ + init: function (parent, options) { + this._super.apply(this, arguments); + this.context = options.context; + this.formViewRef = options.formViewRef; + this.model = options.model; + this._disabled = false; // to prevent from creating multiple records (e.g. on double-clicks) + }, + /** + * Loads the form fieldsView (if not provided), instantiates the form view + * and starts the form controller. + * + * @override + */ + willStart: function () { + var self = this; + var superWillStart = this._super.apply(this, arguments); + var viewsLoaded; + if (this.formViewRef) { + var views = [[false, 'form']]; + var context = _.extend({}, this.context, { + form_view_ref: this.formViewRef, + }); + viewsLoaded = this.loadViews(this.model, context, views); + } else { + var fieldsView = {}; + fieldsView.arch = '<form>' + + '<field name="display_name" placeholder="Title" modifiers=\'{"required": true}\'/>' + + '</form>'; + var fields = { + display_name: {string: 'Display name', type: 'char'}, + }; + fieldsView.fields = fields; + fieldsView.viewFields = fields; + viewsLoaded = Promise.resolve({form: fieldsView}); + } + viewsLoaded = viewsLoaded.then(function (fieldsViews) { + var formView = new QuickCreateFormView(fieldsViews.form, { + context: self.context, + modelName: self.model, + userContext: session.user_context, + }); + return formView.getController(self).then(function (controller) { + self.controller = controller; + return self.controller.appendTo(document.createDocumentFragment()); + }); + }); + return Promise.all([superWillStart, viewsLoaded]); + }, + /** + * @override + */ + start: function () { + this.$el.append(this.controller.$el); + this.controller.renderButtons(this.$el); + + // focus the first field + this.controller.autofocus(); + + // destroy the quick create when the user clicks outside + core.bus.on('click', this, this._onWindowClicked); + + return this._super.apply(this, arguments); + }, + /** + * Called when the quick create is appended into the DOM. + */ + on_attach_callback: function () { + if (this.controller) { + this.controller.autofocus(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Cancels the quick creation if the record isn't dirty, i.e. if no changes + * have been made yet + * + * @private + * @returns {Promise} + */ + cancel: function () { + var self = this; + return this.controller.commitChanges().then(function () { + if (!self.controller.isDirty()) { + self._cancel(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} [options] + * @param {boolean} [options.openRecord] set to true to directly open the + * newly created record in a form view (in edit mode) + */ + _add: function (options) { + var self = this; + if (this._disabled) { + // don't do anything if we are already creating a record + return; + } + // disable the widget to prevent the user from creating multiple records + // with the current values ; if the create works, the widget will be + // destroyed and another one will be instantiated, so there is no need + // to re-enable it in that case + this._disableQuickCreate(); + this.controller.commitChanges().then(function () { + var canBeSaved = self.controller.canBeSaved(); + if (canBeSaved) { + self.trigger_up('quick_create_add_record', { + openRecord: options && options.openRecord || false, + values: self.controller.getChanges(), + onFailure: self._enableQuickCreate.bind(self), + }); + } else { + self._enableQuickCreate(); + } + }).guardedCatch(this._enableQuickCreate.bind(this)); + }, + /** + * Notifies the environment that the quick creation must be cancelled + * + * @private + * @returns {Promise} + */ + _cancel: function () { + this.trigger_up('cancel_quick_create'); + }, + /** + * Disable the widget to indicate the user that it can't interact with it. + * This function must be called when a record is being created, to prevent + * it from being created twice. + * + * Note that if the record creation works as expected, there is no need to + * re-enable the widget as it will be destroyed anyway (and replaced by a + * new instance). + * + * @private + */ + _disableQuickCreate: function () { + this._disabled = true; // ensures that the record won't be created twice + this.$el.addClass('o_disabled'); + this.$('input:not(:disabled)') + .addClass('o_temporarily_disabled') + .attr('disabled', 'disabled'); + }, + /** + * Re-enable the widget to allow the user to create again. + * + * @private + */ + _enableQuickCreate: function () { + this._disabled = false; // allows to create again + this.$el.removeClass('o_disabled'); + this.$('input.o_temporarily_disabled') + .removeClass('o_temporarily_disabled') + .attr('disabled', false); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onAdd: function (ev) { + ev.stopPropagation(); + this._add(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAddClicked: function (ev) { + ev.stopPropagation(); + this._add(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onCancel: function (ev) { + ev.stopPropagation(); + this._cancel(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onCancelClicked: function (ev) { + ev.stopPropagation(); + this._cancel(); + }, + /** + * Validates the quick creation and directly opens the record in a form + * view in edit mode. + * + * @private + * @param {MouseEvent} ev + */ + _onEditClicked: function (ev) { + ev.stopPropagation(); + this._add({openRecord: true}); + }, + /** + * When a click happens outside the quick create, we want to close the quick + * create. + * + * This is quite tricky, because in some cases a click is performed outside + * the quick create, but is still related to it (e.g. click in a dialog + * opened from the quick create). + * + * @param {MouseEvent} ev + */ + _onWindowClicked: function (ev) { + var mouseDownInside = this.mouseDownInside; + + this.mouseDownInside = false; + // ignore clicks if the quick create is not in the dom + if (!document.contains(this.el)) { + return; + } + + // ignore clicks on elements that open the quick create widget, to + // prevent from closing quick create widget that has just been opened + if ($(ev.target).closest('.o-kanban-button-new, .o_kanban_quick_add').length) { + return; + } + + // ignore clicks in autocomplete dropdowns + if ($(ev.target).parents('.ui-autocomplete').length) { + return; + } + + // ignore clicks in modals + if ($(ev.target).closest('.modal').length) { + return; + } + + // ignore clicks while a modal is just about to open + if ($(document.body).hasClass('modal-open')) { + return; + } + + // ignore clicks if target is no longer in dom (e.g., a click on the + // 'delete' trash icon of a m2m tag) + if (!document.contains(ev.target)) { + return; + } + + // ignore clicks if target is inside the quick create + if (this.el.contains(ev.target) || this.el === ev.target || mouseDownInside) { + return; + } + + this.cancel(); + }, + /** + * Detects if the click is originally from the quick create + * + * @private + * @param {MouseEvent} ev + */ + _onMouseDown: function(ev){ + this.mouseDownInside = true; + } +}); + +return RecordQuickCreate; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js new file mode 100644 index 00000000..dfaba0a1 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -0,0 +1,684 @@ +odoo.define('web.KanbanRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var ColumnQuickCreate = require('web.kanban_column_quick_create'); +var config = require('web.config'); +var core = require('web.core'); +var KanbanColumn = require('web.KanbanColumn'); +var KanbanRecord = require('web.KanbanRecord'); +var QWeb = require('web.QWeb'); +var session = require('web.session'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); + +var qweb = core.qweb; +var _t = core._t; + +function findInNode(node, predicate) { + if (predicate(node)) { + return node; + } + if (!node.children) { + return undefined; + } + for (var i = 0; i < node.children.length; i++) { + if (findInNode(node.children[i], predicate)) { + return node.children[i]; + } + } +} + +function qwebAddIf(node, condition) { + if (node.attrs[qweb.prefix + '-if']) { + condition = _.str.sprintf("(%s) and (%s)", node.attrs[qweb.prefix + '-if'], condition); + } + node.attrs[qweb.prefix + '-if'] = condition; +} + +function transformQwebTemplate(node, fields) { + // Process modifiers + if (node.tag && node.attrs.modifiers) { + var modifiers = node.attrs.modifiers || {}; + if (modifiers.invisible) { + qwebAddIf(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible))); + } + } + switch (node.tag) { + case 'button': + case 'a': + var type = node.attrs.type || ''; + if (_.indexOf('action,object,edit,open,delete,url,set_cover'.split(','), type) !== -1) { + _.each(node.attrs, function (v, k) { + if (_.indexOf('icon,type,name,args,string,context,states,kanban_states'.split(','), k) !== -1) { + node.attrs['data-' + k] = v; + delete(node.attrs[k]); + } + }); + if (node.attrs['data-string']) { + node.attrs.title = node.attrs['data-string']; + } + if (node.tag === 'a' && node.attrs['data-type'] !== "url") { + node.attrs.href = '#'; + } else { + node.attrs.type = 'button'; + } + + var action_classes = " oe_kanban_action oe_kanban_action_" + node.tag; + if (node.attrs['t-attf-class']) { + node.attrs['t-attf-class'] += action_classes; + } else if (node.attrs['t-att-class']) { + node.attrs['t-att-class'] += " + '" + action_classes + "'"; + } else { + node.attrs['class'] = (node.attrs['class'] || '') + action_classes; + } + } + break; + } + if (node.children) { + for (var i = 0, ii = node.children.length; i < ii; i++) { + transformQwebTemplate(node.children[i], fields); + } + } +} + +var KanbanRenderer = BasicRenderer.extend({ + className: 'o_kanban_view', + config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden) + KanbanColumn: KanbanColumn, + KanbanRecord: KanbanRecord, + }, + custom_events: _.extend({}, BasicRenderer.prototype.custom_events || {}, { + close_quick_create: '_onCloseQuickCreate', + cancel_quick_create: '_onCancelQuickCreate', + set_progress_bar_state: '_onSetProgressBarState', + start_quick_create: '_onStartQuickCreate', + quick_create_column_updated: '_onQuickCreateColumnUpdated', + }), + events:_.extend({}, BasicRenderer.prototype.events || {}, { + 'keydown .o_kanban_record' : '_onRecordKeyDown' + }), + sampleDataTargets: [ + '.o_kanban_counter', + '.o_kanban_record', + '.o_kanban_toggle_fold', + '.o_column_folded', + '.o_column_archive_records', + '.o_column_unarchive_records', + ], + + /** + * @override + * @param {Object} params + * @param {boolean} params.quickCreateEnabled set to false to disable the + * quick create feature + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.widgets = []; + this.qweb = new QWeb(config.isDebug(), {_s: session.origin}, false); + var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';}); + transformQwebTemplate(templates, state.fields); + this.qweb.add_template(utils.json_node_to_xml(templates)); + this.examples = params.examples; + this.recordOptions = _.extend({}, params.record_options, { + qweb: this.qweb, + viewType: 'kanban', + }); + this.columnOptions = _.extend({KanbanRecord: this.config.KanbanRecord}, params.column_options); + if (this.columnOptions.hasProgressBar) { + this.columnOptions.progressBarStates = {}; + } + this.quickCreateEnabled = params.quickCreateEnabled; + if (!params.readOnlyMode) { + var handleField = _.findWhere(this.state.fieldsInfo.kanban, {widget: 'handle'}); + this.handleField = handleField && handleField.name; + } + this._setState(state); + }, + /** + * Called each time the renderer is attached into the DOM. + */ + on_attach_callback: function () { + this._super(...arguments); + if (this.quickCreate) { + this.quickCreate.on_attach_callback(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Displays the quick create record in the requested column (first one by + * default) + * + * @params {string} [groupId] local id of the group in which the quick create + * must be inserted + * @returns {Promise} + */ + addQuickCreate: function (groupId) { + let kanbanColumn; + if (groupId) { + kanbanColumn = this.widgets.find(column => column.db_id === groupId); + } + kanbanColumn = kanbanColumn || this.widgets[0]; + return kanbanColumn.addQuickCreate(); + }, + /** + * Focuses the first kanban record + */ + giveFocus: function () { + this.$('.o_kanban_record:first').focus(); + }, + /** + * Toggle fold/unfold the Column quick create widget + */ + quickCreateToggleFold: function () { + this.quickCreate.toggleFold(); + this._toggleNoContentHelper(); + }, + /** + * Updates a given column with its new state. + * + * @param {string} localID the column id + * @param {Object} columnState + * @param {Object} [options] + * @param {Object} [options.state] if set, this represents the new state + * @param {boolean} [options.openQuickCreate] if true, directly opens the + * QuickCreate widget in the updated column + * + * @returns {Promise} + */ + updateColumn: function (localID, columnState, options) { + var self = this; + var KanbanColumn = this.config.KanbanColumn; + var newColumn = new KanbanColumn(this, columnState, this.columnOptions, this.recordOptions); + var index = _.findIndex(this.widgets, {db_id: localID}); + var column = this.widgets[index]; + this.widgets[index] = newColumn; + if (options && options.state) { + this._setState(options.state); + } + return newColumn.appendTo(document.createDocumentFragment()).then(function () { + var def; + if (options && options.openQuickCreate) { + def = newColumn.addQuickCreate(); + } + return Promise.resolve(def).then(function () { + newColumn.$el.insertAfter(column.$el); + self._toggleNoContentHelper(); + // When a record has been quick created, the new column directly + // renders the quick create widget (to allow quick creating several + // records in a row). However, as we render this column in a + // fragment, the quick create widget can't be correctly focused. So + // we manually call on_attach_callback to focus it once in the DOM. + newColumn.on_attach_callback(); + column.destroy(); + }); + }); + }, + /** + * Updates a given record with its new state. + * + * @param {Object} recordState + * @returns {Promise} + */ + updateRecord: function (recordState) { + var isGrouped = !!this.state.groupedBy.length; + var record; + + if (isGrouped) { + // if grouped, this.widgets are kanban columns so we need to find + // the kanban record inside + _.each(this.widgets, function (widget) { + record = record || _.findWhere(widget.records, { + db_id: recordState.id, + }); + }); + } else { + record = _.findWhere(this.widgets, {db_id: recordState.id}); + } + + if (record) { + return record.update(recordState); + } + return Promise.resolve(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {DOMElement} currentColumn + */ + _focusOnNextCard: function (currentCardElement) { + var nextCard = currentCardElement.nextElementSibling; + if (nextCard) { + nextCard.focus(); + } + }, + /** + * Tries to give focus to the previous card, and returns true if successful + * + * @private + * @param {DOMElement} currentColumn + * @returns {boolean} + */ + _focusOnPreviousCard: function (currentCardElement) { + var previousCard = currentCardElement.previousElementSibling; + if (previousCard && previousCard.classList.contains("o_kanban_record")) { //previous element might be column title + previousCard.focus(); + return true; + } + }, + /** + * Returns the default columns for the kanban view example background. + * You can override this method to easily customize the column names. + * + * @private + */ + _getGhostColumns: function () { + if (this.examples && this.examples.ghostColumns) { + return this.examples.ghostColumns; + } + return _.map(_.range(1, 5), function (num) { + return _.str.sprintf(_t("Column %s"), num); + }); + }, + /** + * Render the Example Ghost Kanban card on the background + * + * @private + * @param {DocumentFragment} fragment + */ + _renderExampleBackground: function (fragment) { + var $background = $(qweb.render('KanbanView.ExamplesBackground', {ghostColumns: this._getGhostColumns()})); + $background.appendTo(fragment); + }, + /** + * Renders empty invisible divs in a document fragment. + * + * @private + * @param {DocumentFragment} fragment + * @param {integer} nbDivs the number of divs to append + * @param {Object} [options] + * @param {string} [options.inlineStyle] + */ + _renderGhostDivs: function (fragment, nbDivs, options) { + var ghostDefs = []; + for (var $ghost, i = 0; i < nbDivs; i++) { + $ghost = $('<div>').addClass('o_kanban_record o_kanban_ghost'); + if (options && options.inlineStyle) { + $ghost.attr('style', options.inlineStyle); + } + var def = $ghost.appendTo(fragment); + ghostDefs.push(def); + } + return Promise.all(ghostDefs); + }, + /** + * Renders an grouped kanban view in a fragment. + * + * @private + * @param {DocumentFragment} fragment + */ + _renderGrouped: function (fragment) { + var self = this; + + // Render columns + var KanbanColumn = this.config.KanbanColumn; + _.each(this.state.data, function (group) { + var column = new KanbanColumn(self, group, self.columnOptions, self.recordOptions); + var def; + if (!group.value) { + def = column.prependTo(fragment); // display the 'Undefined' group first + self.widgets.unshift(column); + } else { + def = column.appendTo(fragment); + self.widgets.push(column); + } + self.defs.push(def); + }); + + // remove previous sorting + if(this.$el.sortable('instance') !== undefined) { + this.$el.sortable('destroy'); + } + if (this.groupedByM2O) { + // Enable column sorting + this.$el.sortable({ + axis: 'x', + items: '> .o_kanban_group', + handle: '.o_column_title', + cursor: 'move', + revert: 150, + delay: 100, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function () { + var ids = []; + self.$('.o_kanban_group').each(function (index, u) { + // Ignore 'Undefined' column + if (_.isNumber($(u).data('id'))) { + ids.push($(u).data('id')); + } + }); + self.trigger_up('resequence_columns', {ids: ids}); + }, + }); + + if (this.createColumnEnabled) { + this.quickCreate = new ColumnQuickCreate(this, { + applyExamplesText: this.examples && this.examples.applyExamplesText, + examples: this.examples && this.examples.examples, + }); + this.defs.push(this.quickCreate.appendTo(fragment).then(function () { + // Open it directly if there is no column yet + if (!self.state.data.length) { + self.quickCreate.toggleFold(); + self._renderExampleBackground(fragment); + } + })); + } + } + }, + /** + * Renders an ungrouped kanban view in a fragment. + * + * @private + * @param {DocumentFragment} fragment + */ + _renderUngrouped: function (fragment) { + var self = this; + var KanbanRecord = this.config.KanbanRecord; + var kanbanRecord; + _.each(this.state.data, function (record) { + kanbanRecord = new KanbanRecord(self, record, self.recordOptions); + self.widgets.push(kanbanRecord); + var def = kanbanRecord.appendTo(fragment); + self.defs.push(def); + }); + + // enable record resequencing if there is a field with widget='handle' + // and if there is no orderBy (in this case we assume that the widget + // has been put on the first default order field of the model), or if + // the first orderBy field is the one with widget='handle' + var orderedBy = this.state.orderedBy; + var hasHandle = this.handleField && + (orderedBy.length === 0 || orderedBy[0].name === this.handleField); + if (hasHandle) { + this.$el.sortable({ + items: '.o_kanban_record:not(.o_kanban_ghost)', + cursor: 'move', + revert: 0, + delay: 0, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function (event, ui) { + self._moveRecord(ui.item.data('record').db_id, ui.item.index()); + }, + }); + } + + // append ghost divs to ensure that all kanban records are left aligned + var prom = Promise.all(self.defs).then(function () { + var options = {}; + if (kanbanRecord) { + options.inlineStyle = kanbanRecord.$el.attr('style'); + } + return self._renderGhostDivs(fragment, 6, options); + }); + this.defs.push(prom); + }, + /** + * @override + * @private + */ + _renderView: function () { + var self = this; + + // render the kanban view + var isGrouped = !!this.state.groupedBy.length; + var fragment = document.createDocumentFragment(); + var defs = []; + this.defs = defs; + if (isGrouped) { + this._renderGrouped(fragment); + } else { + this._renderUngrouped(fragment); + } + delete this.defs; + + return this._super.apply(this, arguments).then(function () { + return Promise.all(defs).then(function () { + self.$el.empty(); + self.$el.toggleClass('o_kanban_grouped', isGrouped); + self.$el.toggleClass('o_kanban_ungrouped', !isGrouped); + self.$el.append(fragment); + self._toggleNoContentHelper(); + }); + }); + }, + /** + * @param {boolean} [remove] if true, the nocontent helper is always removed + * @private + */ + _toggleNoContentHelper: function (remove) { + var displayNoContentHelper = + !remove && + !this._hasContent() && + !!this.noContentHelp && + !(this.quickCreate && !this.quickCreate.folded) && + !this.state.isGroupedByM2ONoColumn; + + var $noContentHelper = this.$('.o_view_nocontent'); + + if (displayNoContentHelper && !$noContentHelper.length) { + this._renderNoContentHelper(); + } + if (!displayNoContentHelper && $noContentHelper.length) { + $noContentHelper.remove(); + } + }, + /** + * Sets the current state and updates some internal attributes accordingly. + * + * @override + */ + _setState: function () { + this._super(...arguments); + + var groupByField = this.state.groupedBy[0]; + var cleanGroupByField = this._cleanGroupByField(groupByField); + var groupByFieldAttrs = this.state.fields[cleanGroupByField]; + var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField]; + // Deactivate the drag'n'drop if the groupedBy field: + // - is a date or datetime since we group by month or + // - is readonly (on the field attrs or in the view) + var draggable = true; + var grouped_by_date = false; + if (groupByFieldAttrs) { + if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") { + draggable = false; + grouped_by_date = true; + } else if (groupByFieldAttrs.readonly !== undefined) { + draggable = !(groupByFieldAttrs.readonly); + } + } + if (groupByFieldInfo) { + if (draggable && groupByFieldInfo.readonly !== undefined) { + draggable = !(groupByFieldInfo.readonly); + } + } + this.groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one'); + var relation = this.groupedByM2O && groupByFieldAttrs.relation; + var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip; + this.columnOptions = _.extend(this.columnOptions, { + draggable: draggable, + group_by_tooltip: groupByTooltip, + groupedBy: groupByField, + grouped_by_m2o: this.groupedByM2O, + grouped_by_date: grouped_by_date, + relation: relation, + quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state), + }); + this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable; + }, + /** + * Remove date/datetime magic grouping info to get proper field attrs/info from state + * ex: sent_date:month will become sent_date + * + * @private + * @param {string} groupByField + * @returns {string} + */ + _cleanGroupByField: function (groupByField) { + var cleanGroupByField = groupByField; + if (cleanGroupByField && cleanGroupByField.indexOf(':') > -1) { + cleanGroupByField = cleanGroupByField.substring(0, cleanGroupByField.indexOf(':')); + } + + return cleanGroupByField; + }, + /** + * Moves the focus on the first card of the next column in a given direction + * This ignores the folded columns and skips over the empty columns. + * In ungrouped kanban, moves the focus to the next/previous card + * + * @param {DOMElement} eventTarget the target of the keydown event + * @param {string} direction contains either 'LEFT' or 'RIGHT' + */ + _focusOnCardInColumn: function(eventTarget, direction) { + var currentColumn = eventTarget.parentElement; + var hasSelectedACard = false; + var cannotSelectAColumn = false; + while (!hasSelectedACard && !cannotSelectAColumn) { + var candidateColumn = direction === 'LEFT' ? + currentColumn.previousElementSibling : + currentColumn.nextElementSibling ; + currentColumn = candidateColumn; + if (candidateColumn) { + var allCardsOfCandidateColumn = + candidateColumn.getElementsByClassName('o_kanban_record'); + if (allCardsOfCandidateColumn.length) { + allCardsOfCandidateColumn[0].focus(); + hasSelectedACard = true; + } + } + else { // either there are no more columns in the direction or + // this is not a grouped kanban + direction === 'LEFT' ? + this._focusOnPreviousCard(eventTarget) : + this._focusOnNextCard(eventTarget); + cannotSelectAColumn = true; + } + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onCancelQuickCreate: function () { + this._toggleNoContentHelper(); + }, + /** + * Closes the opened quick create widgets in columns + * + * @private + */ + _onCloseQuickCreate: function () { + if (this.state.groupedBy.length) { + _.invoke(this.widgets, 'cancelQuickCreate'); + } + this._toggleNoContentHelper(); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onQuickCreateColumnUpdated: function (ev) { + ev.stopPropagation(); + this._toggleNoContentHelper(); + this._updateExampleBackground(); + }, + /** + * @private + * @param {KeyboardEvent} e + */ + _onRecordKeyDown: function(e) { + switch(e.which) { + case $.ui.keyCode.DOWN: + this._focusOnNextCard(e.currentTarget); + e.stopPropagation(); + e.preventDefault(); + break; + case $.ui.keyCode.UP: + const previousFocused = this._focusOnPreviousCard(e.currentTarget); + if (!previousFocused) { + this.trigger_up('navigation_move', { direction: 'up' }); + } + e.stopPropagation(); + e.preventDefault(); + break; + case $.ui.keyCode.RIGHT: + this._focusOnCardInColumn(e.currentTarget, 'RIGHT'); + e.stopPropagation(); + e.preventDefault(); + break; + case $.ui.keyCode.LEFT: + this._focusOnCardInColumn(e.currentTarget, 'LEFT'); + e.stopPropagation(); + e.preventDefault(); + break; + } + }, + /** + * Updates progressbar internal states (necessary for animations) with + * received data. + * + * @private + * @param {OdooEvent} ev + */ + _onSetProgressBarState: function (ev) { + if (!this.columnOptions.progressBarStates[ev.data.columnID]) { + this.columnOptions.progressBarStates[ev.data.columnID] = {}; + } + _.extend(this.columnOptions.progressBarStates[ev.data.columnID], ev.data.values); + }, + /** + * Closes the opened quick create widgets in columns + * + * @private + */ + _onStartQuickCreate: function () { + this._toggleNoContentHelper(true); + }, + /** + * Hide or display the background example: + * - displayed when quick create column is display and there is no column else + * - hidden otherwise + * + * @private + **/ + _updateExampleBackground: function () { + var $elem = this.$('.o_kanban_example_background_container'); + if (!this.state.data.length && !$elem.length) { + this._renderExampleBackground(this.$el); + } else { + $elem.remove(); + } + }, +}); + +return KanbanRenderer; + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js new file mode 100644 index 00000000..1add9169 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -0,0 +1,119 @@ +odoo.define('web.KanbanView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var KanbanController = require('web.KanbanController'); +var kanbanExamplesRegistry = require('web.kanban_examples_registry'); +var KanbanModel = require('web.KanbanModel'); +var KanbanRenderer = require('web.KanbanRenderer'); +var utils = require('web.utils'); + +var _lt = core._lt; + +var KanbanView = BasicView.extend({ + accesskey: "k", + display_name: _lt("Kanban"), + icon: 'fa-th-large', + mobile_friendly: true, + config: _.extend({}, BasicView.prototype.config, { + Model: KanbanModel, + Controller: KanbanController, + Renderer: KanbanRenderer, + }), + jsLibs: [], + viewType: 'kanban', + + /** + * @constructor + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + this.loadParams.limit = this.loadParams.limit || 40; + this.loadParams.openGroupByDefault = true; + this.loadParams.type = 'list'; + this.noDefaultGroupby = params.noDefaultGroupby; + var progressBar; + utils.traverse(this.arch, function (n) { + var isProgressBar = (n.tag === 'progressbar'); + if (isProgressBar) { + progressBar = _.clone(n.attrs); + progressBar.colors = JSON.parse(progressBar.colors); + progressBar.sum_field = progressBar.sum_field || false; + } + return !isProgressBar; + }); + if (progressBar) { + this.loadParams.progressBar = progressBar; + } + + var activeActions = this.controllerParams.activeActions; + var archAttrs = this.arch.attrs; + activeActions = _.extend(activeActions, { + group_create: this.arch.attrs.group_create ? !!JSON.parse(archAttrs.group_create) : true, + group_edit: archAttrs.group_edit ? !!JSON.parse(archAttrs.group_edit) : true, + group_delete: archAttrs.group_delete ? !!JSON.parse(archAttrs.group_delete) : true, + }); + + this.rendererParams.column_options = { + editable: activeActions.group_edit, + deletable: activeActions.group_delete, + archivable: archAttrs.archivable ? !!JSON.parse(archAttrs.archivable) : true, + group_creatable: activeActions.group_create, + quickCreateView: archAttrs.quick_create_view || null, + recordsDraggable: archAttrs.records_draggable ? !!JSON.parse(archAttrs.records_draggable) : true, + hasProgressBar: !!progressBar, + }; + this.rendererParams.record_options = { + editable: activeActions.edit, + deletable: activeActions.delete, + read_only_mode: params.readOnlyMode, + selectionMode: params.selectionMode, + }; + this.rendererParams.quickCreateEnabled = this._isQuickCreateEnabled(); + this.rendererParams.readOnlyMode = params.readOnlyMode; + var examples = archAttrs.examples; + if (examples) { + this.rendererParams.examples = kanbanExamplesRegistry.get(examples); + } + + this.controllerParams.on_create = archAttrs.on_create; + this.controllerParams.hasButtons = !params.selectionMode ? true : false; + this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} viewInfo + * @returns {boolean} true iff the quick create feature is not explicitely + * disabled (with create="False" or quick_create="False" in the arch) + */ + _isQuickCreateEnabled: function () { + if (!this.controllerParams.activeActions.create) { + return false; + } + if (this.arch.attrs.quick_create !== undefined) { + return !!JSON.parse(this.arch.attrs.quick_create); + } + return true; + }, + /** + * @override + * @private + */ + _updateMVCParams: function () { + this._super.apply(this, arguments); + if (this.searchMenuTypes.includes('groupBy') && !this.noDefaultGroupby && this.arch.attrs.default_group_by) { + this.loadParams.groupBy = [this.arch.attrs.default_group_by]; + } + }, +}); + +return KanbanView; + +}); diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js new file mode 100644 index 00000000..9286ed82 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js @@ -0,0 +1,123 @@ +odoo.define('web.QuickCreateFormView', function (require) { +"use strict"; + +/** + * This file defines the QuickCreateFormView, an extension of the FormView that + * is used by the RecordQuickCreate in Kanban views. + */ + +var BasicModel = require('web.BasicModel'); +var FormController = require('web.FormController'); +var FormRenderer = require('web.FormRenderer'); +var FormView = require('web.FormView'); +const { qweb } = require("web.core"); + +var QuickCreateFormRenderer = FormRenderer.extend({ + /** + * @override + */ + start: async function () { + await this._super.apply(this, arguments); + this.$el.addClass('o_xxs_form_view'); + this.$el.removeClass('o_xxl_form_view'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Override to do nothing so that the renderer won't resize on window resize + * + * @override + */ + _applyFormSizeClass() {}, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + var direction = ev.data.direction; + if (direction === 'cancel' || direction === 'next_line') { + ev.stopPropagation(); + this.trigger_up(direction === 'cancel' ? 'cancel' : 'add'); + } else { + this._super.apply(this, arguments); + } + }, +}); + +var QuickCreateFormModel = BasicModel.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {Object} the changes of the given resource (server commands for + * x2manys) + */ + getChanges: function (localID) { + var record = this.localData[localID]; + return this._generateChanges(record, {changesOnly: false}); + }, +}); + +var QuickCreateFormController = FormController.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks all field widgets to notify the environment with their current value + * (useful for instance for input fields that still have the focus and that + * could have not notified the environment of their changes yet). + * Synchronizes with the controller's mutex in case there would already be + * pending changes being applied. + * + * @return {Promise} + */ + commitChanges: function () { + var mutexDef = this.mutex.getUnlockedDef(); + return Promise.all([mutexDef, this.renderer.commitChanges(this.handle)]); + }, + /** + * @returns {Object} the changes done on the current record + */ + getChanges: function () { + return this.model.getChanges(this.handle); + }, + + /** + * @override + */ + renderButtons($node) { + this.$buttons = $(qweb.render('KanbanView.RecordQuickCreate.buttons')); + if ($node) { + this.$buttons.appendTo($node); + } + }, + + /** + * @override + */ + updateButtons() {/* No need to update the buttons */}, +}); + +var QuickCreateFormView = FormView.extend({ + withControlPanel: false, + config: _.extend({}, FormView.prototype.config, { + Model: QuickCreateFormModel, + Renderer: QuickCreateFormRenderer, + Controller: QuickCreateFormController, + }), +}); + +return QuickCreateFormView; + +}); diff --git a/addons/web/static/src/js/views/list/list_confirm_dialog.js b/addons/web/static/src/js/views/list/list_confirm_dialog.js new file mode 100644 index 00000000..7fba13c3 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_confirm_dialog.js @@ -0,0 +1,104 @@ +odoo.define('web.ListConfirmDialog', function (require) { +"use strict"; + +const core = require('web.core'); +const Dialog = require('web.Dialog'); +const FieldWrapper = require('web.FieldWrapper'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); +const utils = require('web.utils'); + +const _t = core._t; +const qweb = core.qweb; + +/** + * Multi edition confirmation modal for list views. + * + * Handles the display of the amount of changed records (+ valid ones) and + * of the widget representing the new value. + * + * @class + */ +const ListConfirmDialog = Dialog.extend(WidgetAdapterMixin, { + /** + * @constructor + * @override + * @param {Widget} parent + * @param {Object} record edited record with updated value + * @param {Object} changes changes registered by the list controller + * @param {Object} changes isDomainSelected true iff the user selected the + * whole domain + * @param {string} changes.fieldLabel label of the changed field + * @param {string} changes.fieldName technical name of the changed field + * @param {number} changes.nbRecords number of records (total) + * @param {number} changes.nbValidRecords number of valid records + * @param {Object} [options] + */ + init: function (parent, record, changes, options) { + options = Object.assign({}, options, { + $content: $(qweb.render('ListView.confirmModal', { changes })), + buttons: options.buttons || [{ + text: _t("Ok"), + classes: 'btn-primary', + close: true, + click: options.confirm_callback, + }, { + text: _t("Cancel"), + close: true, + click: options.cancel_callback, + }], + onForceClose: options.cancel_callback, + size: options.size || 'medium', + title: options.title || _t("Confirmation"), + }); + + this._super(parent, options); + + const Widget = record.fieldsInfo.list[changes.fieldName].Widget; + const widgetOptions = { + mode: 'readonly', + viewType: 'list', + noOpen: true, + }; + this.isLegacyWidget = !utils.isComponent(Widget); + if (this.isLegacyWidget) { + this.fieldWidget = new Widget(this, changes.fieldName, record, widgetOptions); + } else { + this.fieldWidget = new FieldWrapper(this, Widget, { + fieldName: changes.fieldName, + record, + options: widgetOptions, + }); + } + }, + /** + * @override + */ + willStart: function () { + let widgetProm; + if (this.isLegacyWidget) { + widgetProm = this.fieldWidget._widgetRenderAndInsert(function () {}); + } else { + widgetProm = this.fieldWidget.mount(document.createDocumentFragment()); + } + return Promise.all([widgetProm, this._super.apply(this, arguments)]); + }, + /** + * @override + */ + start: function () { + this.$content.find('.o_changes_widget').replaceWith(this.fieldWidget.$el); + this.fieldWidget.el.style.pointerEvents = 'none'; + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + WidgetAdapterMixin.destroy.call(this); + this._super(); + }, +}); + +return ListConfirmDialog; + +}); diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js new file mode 100644 index 00000000..a19afb6a --- /dev/null +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -0,0 +1,992 @@ +odoo.define('web.ListController', function (require) { +"use strict"; + +/** + * The List Controller controls the list renderer and the list model. Its role + * is to allow these two components to communicate properly, and also, to render + * and bind all extra buttons/pager in the control panel. + */ + +var core = require('web.core'); +var BasicController = require('web.BasicController'); +var DataExport = require('web.DataExport'); +var Dialog = require('web.Dialog'); +var ListConfirmDialog = require('web.ListConfirmDialog'); +var session = require('web.session'); +const viewUtils = require('web.viewUtils'); + +var _t = core._t; +var qweb = core.qweb; + +var ListController = BasicController.extend({ + /** + * This key contains the name of the buttons template to render on top of + * the list view. It can be overridden to add buttons in specific child views. + */ + buttons_template: 'ListView.buttons', + events: _.extend({}, BasicController.prototype.events, { + 'click .o_list_export_xlsx': '_onDirectExportData', + 'click .o_list_select_domain': '_onSelectDomain', + }), + custom_events: _.extend({}, BasicController.prototype.custom_events, { + activate_next_widget: '_onActivateNextWidget', + add_record: '_onAddRecord', + button_clicked: '_onButtonClicked', + group_edit_button_clicked: '_onEditGroupClicked', + edit_line: '_onEditLine', + save_line: '_onSaveLine', + selection_changed: '_onSelectionChanged', + toggle_column_order: '_onToggleColumnOrder', + toggle_group: '_onToggleGroup', + }), + /** + * @constructor + * @override + * @param {Object} params + * @param {boolean} params.editable + * @param {boolean} params.hasActionMenus + * @param {Object[]} [params.headerButtons=[]]: a list of node descriptors + * for controlPanel's action buttons + * @param {Object} params.toolbarActions + * @param {boolean} params.noLeaf + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.hasActionMenus = params.hasActionMenus; + this.headerButtons = params.headerButtons || []; + this.toolbarActions = params.toolbarActions || {}; + this.editable = params.editable; + this.noLeaf = params.noLeaf; + this.selectedRecords = params.selectedRecords || []; + this.multipleRecordsSavingPromise = null; + this.fieldChangedPrevented = false; + this.lastFieldChangedEvent = null; + this.isPageSelected = false; // true iff all records of the page are selected + this.isDomainSelected = false; // true iff the user selected all records matching the domain + this.isExportEnable = false; + }, + + willStart() { + const sup = this._super(...arguments); + const acl = session.user_has_group('base.group_allow_export').then(hasGroup => { + this.isExportEnable = hasGroup; + }); + return Promise.all([sup, acl]); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /* + * @override + */ + getOwnedQueryParams: function () { + var state = this._super.apply(this, arguments); + var orderedBy = this.model.get(this.handle, {raw: true}).orderedBy || []; + return _.extend({}, state, {orderedBy: orderedBy}); + }, + /** + * Returns the list of currently selected res_ids (with the check boxes on + * the left) + * + * @override + * + * @returns {number[]} list of res_ids + */ + getSelectedIds: function () { + return _.map(this.getSelectedRecords(), function (record) { + return record.res_id; + }); + }, + /** + * Returns the list of currently selected records (with the check boxes on + * the left) + * + * @returns {Object[]} list of records + */ + getSelectedRecords: function () { + var self = this; + return _.map(this.selectedRecords, function (db_id) { + return self.model.get(db_id, {raw: true}); + }); + }, + /** + * Display and bind all buttons in the control panel + * + * Note: clicking on the "Save" button does nothing special. Indeed, all + * editable rows are saved once left and clicking on the "Save" button does + * induce the leaving of the current row. + * + * @override + */ + renderButtons: function ($node) { + if (this.noLeaf || !this.hasButtons) { + this.hasButtons = false; + this.$buttons = $('<div>'); + } else { + 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); + } + }, + /** + * Renders (and updates) the buttons that are described inside the `header` + * node of the list view arch. Those buttons are visible when selecting some + * records. They will be appended to the controlPanel's buttons. + * + * @private + */ + _renderHeaderButtons() { + if (this.$headerButtons) { + this.$headerButtons.remove(); + this.$headerButtons = null; + } + if (!this.headerButtons.length || !this.selectedRecords.length) { + return; + } + const btnClasses = 'btn-primary btn-secondary btn-link btn-success btn-info btn-warning btn-danger'.split(' '); + let $elms = $(); + this.headerButtons.forEach(node => { + const $btn = viewUtils.renderButtonFromNode(node); + $btn.addClass('btn'); + if (!btnClasses.some(cls => $btn.hasClass(cls))) { + $btn.addClass('btn-secondary'); + } + $btn.on("click", this._onHeaderButtonClicked.bind(this, node)); + $elms = $elms.add($btn); + }); + this.$headerButtons = $elms; + this.$headerButtons.appendTo(this.$buttons); + }, + /** + * Overrides to update the list of selected records + * + * @override + */ + update: function (params, options) { + var self = this; + let res_ids; + if (options && options.keepSelection) { + // filter out removed records from selection + res_ids = this.model.get(this.handle).res_ids; + this.selectedRecords = _.filter(this.selectedRecords, function (id) { + return _.contains(res_ids, self.model.get(id).res_id); + }); + } else { + this.selectedRecords = []; + } + if (this.selectedRecords.length === 0 || this.selectedRecords.length < res_ids.length) { + this.isDomainSelected = false; + this.isPageSelected = false; + } + + 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.hasButtons) { + this.$buttons.toggleClass('o-editing', mode === 'edit'); + const state = this.model.get(this.handle, {raw: true}); + if (state.count) { + this.$buttons.find('.o_list_export_xlsx').show(); + } else { + this.$buttons.find('.o_list_export_xlsx').hide(); + } + } + this._updateSelectionBox(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @see BasicController._abandonRecord + * If the given abandoned record is not the main one, notifies the renderer + * to remove the appropriate subrecord (line). + * + * @override + * @private + * @param {string} [recordID] - default to the main recordID + */ + _abandonRecord: function (recordID) { + this._super.apply(this, arguments); + if ((recordID || this.handle) !== this.handle) { + var state = this.model.get(this.handle); + this.renderer.removeLine(state, recordID); + this._updatePaging(state); + } + }, + /** + * Adds a new record to the a dataPoint of type 'list'. + * Disables the buttons to prevent concurrent record creation or edition. + * + * @todo make record creation a basic controller feature + * @private + * @param {string} dataPointId a dataPoint of type 'list' (may be grouped) + * @return {Promise} + */ + _addRecord: function (dataPointId) { + var self = this; + this._disableButtons(); + return this._removeSampleData(() => { + return this.renderer.unselectRow().then(function () { + return self.model.addDefaultRecord(dataPointId, { + position: self.editable, + }); + }).then(function (recordID) { + var state = self.model.get(self.handle); + self._updateRendererState(state, { keepWidths: true }) + .then(function () { + self.renderer.editRecord(recordID); + }) + .then(() => { + self._updatePaging(state); + }); + }).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this)); + }); + }, + /** + * Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard + * + * @param {jQueryElement} $createButton The create button itself + */ + _assignCreateKeyboardBehavior: function($createButton) { + var self = this; + $createButton.on('keydown', function(e) { + $createButton.tooltip('hide'); + switch(e.which) { + case $.ui.keyCode.ENTER: + e.preventDefault(); + self._onCreateRecord.apply(self); + break; + case $.ui.keyCode.DOWN: + e.preventDefault(); + self._giveFocus(); + break; + case $.ui.keyCode.TAB: + if ( + !e.shiftKey && + e.target.classList.contains("btn-primary") && + !self.model.isInSampleMode() + ) { + e.preventDefault(); + $createButton.tooltip('show'); + } + break; + } + }); + }, + /** + * This function is the hook called by the field manager mixin to confirm + * that a record has been saved. + * + * @override + * @param {string} id a basicmodel valid resource handle. It is supposed to + * be a record from the list view. + * @returns {Promise} + */ + _confirmSave: function (id) { + var state = this.model.get(this.handle); + return this._updateRendererState(state, { noRender: true }) + .then(this._setMode.bind(this, 'readonly', id)); + }, + /** + * Deletes records matching the current domain. We limit the number of + * deleted records to the 'active_ids_limit' config parameter. + * + * @private + */ + _deleteRecordsInCurrentDomain: function () { + const doIt = async () => { + const state = this.model.get(this.handle, {raw: true}); + const resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit); + await this._rpc({ + model: this.modelName, + method: 'unlink', + args: [resIds], + context: state.getContext(), + }); + if (resIds.length === session.active_ids_limit) { + const msg = _.str.sprintf( + _t("Only the first %d records have been deleted (out of %d selected)"), + resIds.length, state.count + ); + this.do_notify(false, msg); + } + this.reload(); + }; + if (this.confirmOnDelete) { + Dialog.confirm(this, _t("Are you sure you want to delete these records ?"), { + confirm_callback: doIt, + }); + } else { + doIt(); + } + }, + /** + * To improve performance, list view must not be rerendered if it is asked + * to discard all its changes. Indeed, only the in-edition row needs to be + * discarded in that case. + * + * @override + * @private + * @param {string} [recordID] - default to main recordID + * @returns {Promise} + */ + _discardChanges: function (recordID) { + if ((recordID || this.handle) === this.handle) { + recordID = this.renderer.getEditableRecordID(); + if (recordID === null) { + return Promise.resolve(); + } + } + var self = this; + return this._super(recordID).then(function () { + self.updateButtons('readonly'); + }); + }, + /** + * Returns the ids of records matching the given domain. + * + * @private + * @param {Array[]} domain + * @param {integer} [limit] + * @returns {integer[]} + */ + _domainToResIds: function (domain, limit) { + return this._rpc({ + model: this.modelName, + method: 'search', + args: [domain], + kwargs: { + limit: limit, + }, + }); + }, + /** + * @returns {DataExport} the export dialog widget + * @private + */ + _getExportDialogWidget() { + let state = this.model.get(this.handle); + let defaultExportFields = this.renderer.columns.filter(field => field.tag === 'field' && state.fields[field.attrs.name].exportable !== false).map(field => field.attrs.name); + let groupedBy = this.renderer.state.groupedBy; + const domain = this.isDomainSelected && state.getDomain(); + return new DataExport(this, state, defaultExportFields, groupedBy, + domain, this.getSelectedIds()); + }, + /** + * Only display the pager when there are data to display. + * + * @override + * @private + */ + _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 = []; + if (this.isExportEnable) { + otherActionItems.push({ + 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: state.getContext(), + domain: state.getDomain(), + isDomainSelected: this.isDomainSelected, + }); + }, + /** + * Saves multiple records at once. This method is called by the _onFieldChanged method + * since the record must be confirmed as soon as the focus leaves a dirty cell. + * Pseudo-validation is performed with registered modifiers. + * Returns a promise that is resolved when confirming and rejected in any other case. + * + * @private + * @param {string} recordId + * @param {Object} node + * @param {Object} changes + * @returns {Promise} + */ + _saveMultipleRecords: function (recordId, node, changes) { + var fieldName = Object.keys(changes)[0]; + var value = Object.values(changes)[0]; + var recordIds = _.union([recordId], this.selectedRecords); + var validRecordIds = recordIds.reduce((result, nextRecordId) => { + var record = this.model.get(nextRecordId); + var modifiers = this.renderer._registerModifiers(node, record); + if (!modifiers.readonly && (!modifiers.required || value)) { + result.push(nextRecordId); + } + return result; + }, []); + return new Promise((resolve, reject) => { + const saveRecords = () => { + this.model.saveRecords(this.handle, recordId, validRecordIds, fieldName) + .then(async () => { + 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 + // and the list was multi-editable, we do not want to select + // the next row. + this.selectedRecords = []; + await this._updateRendererState(state, { + keepWidths: true, + selectedRecords: [], + }); + this._updateSelectionBox(); + this.renderer.focusCell(recordId, node); + resolve(!Object.keys(changes).length); + }) + .guardedCatch(discardAndReject); + }; + const discardAndReject = () => { + this.model.discardChanges(recordId); + this._confirmSave(recordId).then(() => { + this.renderer.focusCell(recordId, node); + reject(); + }); + }; + if (validRecordIds.length > 0) { + if (recordIds.length === 1) { + // Save without prompt + return saveRecords(); + } + const dialogOptions = { + confirm_callback: saveRecords, + cancel_callback: discardAndReject, + }; + const record = this.model.get(recordId); + const dialogChanges = { + isDomainSelected: this.isDomainSelected, + fieldLabel: node.attrs.string || record.fields[fieldName].string, + fieldName: node.attrs.name, + nbRecords: recordIds.length, + nbValidRecords: validRecordIds.length, + }; + new ListConfirmDialog(this, record, dialogChanges, dialogOptions) + .open({ shouldFocusButtons: true }); + } else { + Dialog.alert(this, _t("No valid record to save"), { + confirm_callback: discardAndReject, + }); + } + }); + }, + /** + * Overridden to deal with edition of multiple line. + * + * @override + * @param {string} recordId + */ + _saveRecord: function (recordId) { + var record = this.model.get(recordId, { raw: true }); + if (record.isDirty() && this.renderer.isInMultipleRecordEdition(recordId)) { + if (!this.multipleRecordsSavingPromise && this.lastFieldChangedEvent) { + this._onFieldChanged(this.lastFieldChangedEvent); + this.lastFieldChangedEvent = null; + } + // do not save the record (see _saveMultipleRecords) + const prom = this.multipleRecordsSavingPromise || Promise.reject(); + this.multipleRecordsSavingPromise = null; + return prom; + } + return this._super.apply(this, arguments); + }, + /** + * Allows to change the mode of a single row. + * + * @override + * @private + * @param {string} mode + * @param {string} [recordID] - default to main recordID + * @returns {Promise} + */ + _setMode: function (mode, recordID) { + if ((recordID || this.handle) !== this.handle) { + this.mode = mode; + this.updateButtons(mode); + return this.renderer.setRowMode(recordID, mode); + } else { + return this._super.apply(this, arguments); + } + }, + /** + * @override + */ + _shouldBounceOnClick() { + const state = this.model.get(this.handle, {raw: true}); + return !state.count || state.isSample; + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the sidebar. + * + * @private + * @param {boolean} archive + * @returns {Promise} + */ + _toggleArchiveState: async function (archive) { + let resIds; + let displayNotif = false; + const state = this.model.get(this.handle, {raw: true}); + if (this.isDomainSelected) { + resIds = await this._domainToResIds(state.getDomain(), session.active_ids_limit); + displayNotif = (resIds.length === session.active_ids_limit); + } else { + resIds = this.model.localIdsToResIds(this.selectedRecords); + } + await this._archive(resIds, archive); + if (displayNotif) { + const msg = _.str.sprintf( + _t("Of the %d records selected, only the first %d have been archived/unarchived."), + state.count, resIds.length + ); + this.do_notify(_t('Warning'), msg); + } + }, + /** + * Hide the create button in non-empty grouped editable list views, as an + * 'Add an item' link is available in each group. + * + * @private + */ + _toggleCreateButton: function () { + if (this.hasButtons) { + var state = this.model.get(this.handle); + var createHidden = this.editable && state.groupedBy.length && state.data.length; + this.$buttons.find('.o_list_button_add').toggleClass('o_hidden', !!createHidden); + } + }, + /** + * @override + * @returns {Promise} + */ + _update: async function () { + await this._super(...arguments); + this._toggleCreateButton(); + this.updateButtons('readonly'); + }, + /** + * When records are selected, a box is displayed in the control panel (next + * to the buttons). It indicates the number of selected records, and allows + * the user to select the whole domain instead of the current page (when the + * page is selected). This function renders and displays this box when at + * least one record is selected. + * Since header action buttons' display is dependent on the selection, we + * refresh them each time the selection is updated. + * + * @private + */ + _updateSelectionBox() { + if (this.$selectionBox) { + this.$selectionBox.remove(); + this.$selectionBox = null; + } + if (this.selectedRecords.length) { + const state = this.model.get(this.handle, {raw: true}); + this.$selectionBox = $(qweb.render('ListView.selection', { + isDomainSelected: this.isDomainSelected, + isPageSelected: this.isPageSelected, + nbSelected: this.selectedRecords.length, + nbTotal: state.count, + })); + this.$selectionBox.appendTo(this.$buttons); + } + this._renderHeaderButtons(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Triggered when navigating with TAB, when the end of the list has been + * reached. Go back to the first row in that case. + * + * @private + * @param {OdooEvent} ev + */ + _onActivateNextWidget: function (ev) { + ev.stopPropagation(); + this.renderer.editFirstRecord(ev); + }, + /** + * Add a record to the list + * + * @private + * @param {OdooEvent} ev + * @param {string} [ev.data.groupId=this.handle] the id of a dataPoint of + * type list to which the record must be added (default: main list) + */ + _onAddRecord: function (ev) { + ev.stopPropagation(); + var dataPointId = ev.data.groupId || this.handle; + if (this.activeActions.create) { + this._addRecord(dataPointId); + } else if (ev.data.onFail) { + ev.data.onFail(); + } + }, + /** + * Handles a click on a button by performing its action. + * + * @private + * @param {OdooEvent} ev + */ + _onButtonClicked: function (ev) { + ev.stopPropagation(); + this._callButtonAction(ev.data.attrs, ev.data.record); + }, + /** + * When the user clicks on the 'create' button, two things can happen. We + * can switch to the form view with no active res_id, so it is in 'create' + * mode, or we can edit inline. + * + * @private + * @param {MouseEvent} ev + */ + _onCreateRecord: function (ev) { + // we prevent the event propagation because we don't want this event to + // trigger a click on the main bus, which would be then caught by the + // list editable renderer and would unselect the newly created row + if (ev) { + ev.stopPropagation(); + } + var state = this.model.get(this.handle, {raw: true}); + if (this.editable && !state.groupedBy.length) { + this._addRecord(this.handle); + } else { + this.trigger_up('switch_view', {view_type: 'form', res_id: undefined}); + } + }, + /** + * Called when the 'delete' action is clicked on in the side bar. + * + * @private + */ + _onDeleteSelectedRecords: async function () { + if (this.isDomainSelected) { + this._deleteRecordsInCurrentDomain(); + } else { + this._deleteRecords(this.selectedRecords); + } + }, + /** + * Handler called when the user clicked on the 'Discard' button. + * + * @param {Event} ev + */ + _onDiscard: function (ev) { + ev.stopPropagation(); // So that it is not considered as a row leaving + this._discardChanges().then(() => { + this.lastFieldChangedEvent = null; + }); + }, + /** + * Used to detect if the discard button is about to be clicked. + * Some focusout events might occur and trigger a save which + * is not always wanted when clicking "Discard". + * + * @param {MouseEvent} ev + * @private + */ + _onDiscardMousedown: function (ev) { + var self = this; + this.fieldChangedPrevented = true; + window.addEventListener('mouseup', function (mouseupEvent) { + var preventedEvent = self.fieldChangedPrevented; + self.fieldChangedPrevented = false; + // If the user starts clicking (mousedown) on the button and stops clicking + // (mouseup) outside of the button, we want to trigger the original onFieldChanged + // Event that was prevented in the meantime. + if (ev.target !== mouseupEvent.target && preventedEvent.constructor.name === 'OdooEvent') { + self._onFieldChanged(preventedEvent); + } + }, { capture: true, once: true }); + }, + /** + * Called when the user asks to edit a row -> Updates the controller buttons + * + * @param {OdooEvent} ev + */ + _onEditLine: function (ev) { + var self = this; + ev.stopPropagation(); + this.trigger_up('mutexify', { + action: function () { + self._setMode('edit', ev.data.recordId) + .then(ev.data.onSuccess); + }, + }); + }, + /** + * Opens the Export Dialog + * + * @private + */ + _onExportData: function () { + this._getExportDialogWidget().open(); + }, + /** + * Export Records in a xls file + * + * @private + */ + _onDirectExportData() { + // access rights check before exporting data + return this._rpc({ + model: 'ir.exports', + method: 'search_read', + args: [[], ['id']], + limit: 1, + }).then(() => this._getExportDialogWidget().export()) + }, + /** + * Opens the related form view. + * + * @private + * @param {OdooEvent} ev + */ + _onEditGroupClicked: function (ev) { + ev.stopPropagation(); + this.do_action({ + context: {create: false}, + type: 'ir.actions.act_window', + views: [[false, 'form']], + res_model: ev.data.record.model, + res_id: ev.data.record.res_id, + flags: {mode: 'edit'}, + }); + }, + /** + * Overridden to deal with the edition of multiple records. + * + * Note that we don't manage saving multiple records on saveLine + * because we don't want the onchanges to be applied. + * + * @private + * @override + */ + _onFieldChanged: function (ev) { + ev.stopPropagation(); + const recordId = ev.data.dataPointID; + this.lastFieldChangedEvent = ev; + + if (this.fieldChangedPrevented) { + this.fieldChangedPrevented = ev; + } else if (this.renderer.isInMultipleRecordEdition(recordId)) { + const saveMulti = () => { + // if ev.data.__originalComponent is set, it is the field Component + // that triggered the event, otherwise ev.target is the legacy field + // Widget that triggered the event + const target = ev.data.__originalComponent || ev.target; + this.multipleRecordsSavingPromise = + this._saveMultipleRecords(ev.data.dataPointID, target.__node, ev.data.changes); + }; + // deal with edition of multiple lines + ev.data.onSuccess = saveMulti; // will ask confirmation, and save + ev.data.onFailure = saveMulti; // will show the appropriate dialog + // disable onchanges as we'll save directly + ev.data.notifyChange = false; + // In multi edit mode, we will be asked if we want to write on the selected + // records, so the force_save for readonly is not necessary. + ev.data.force_save = false; + } + this._super.apply(this, arguments); + }, + /** + * @private + * @param {Object} node the button's node in the xml + * @returns {Promise} + */ + async _onHeaderButtonClicked(node) { + this._disableButtons(); + const state = this.model.get(this.handle); + try { + let resIds; + if (this.isDomainSelected) { + const limit = session.active_ids_limit; + resIds = await this._domainToResIds(state.getDomain(), limit); + } else { + resIds = this.getSelectedIds(); + } + // add the context of the button node (in the xml) and our custom one + // (active_ids and domain) to the action's execution context + const actionData = Object.assign({}, node.attrs, { + context: state.getContext({ additionalContext: node.attrs.context }), + }); + Object.assign(actionData.context, { + active_domain: state.getDomain(), + active_id: resIds[0], + active_ids: resIds, + active_model: state.model, + }); + // load the action with the correct context and record parameters (resIDs, model etc...) + const recordData = { + context: state.getContext(), + model: state.model, + resIDs: resIds, + }; + await this._executeButtonAction(actionData, recordData); + } finally { + this._enableButtons(); + } + }, + /** + * Called when the renderer displays an editable row and the user tries to + * leave it -> Saves the record associated to that line. + * + * @param {OdooEvent} ev + */ + _onSaveLine: function (ev) { + this.saveRecord(ev.data.recordID) + .then(ev.data.onSuccess) + .guardedCatch(ev.data.onFailure); + }, + /** + * @private + */ + _onSelectDomain: function (ev) { + ev.preventDefault(); + this.isDomainSelected = true; + this._updateSelectionBox(); + this._updateControlPanel(); + }, + /** + * When the current selection changes (by clicking on the checkboxes on the + * left), we need to display (or hide) the 'sidebar'. + * + * @private + * @param {OdooEvent} ev + */ + _onSelectionChanged: function (ev) { + this.selectedRecords = ev.data.selection; + this.isPageSelected = ev.data.allChecked; + this.isDomainSelected = false; + this.$('.o_list_export_xlsx').toggle(!this.selectedRecords.length); + this._updateSelectionBox(); + this._updateControlPanel(); + }, + /** + * If the record is set as dirty while in multiple record edition, + * we want to immediatly discard the change. + * + * @private + * @override + * @param {OdooEvent} ev + */ + _onSetDirty: function (ev) { + var recordId = ev.data.dataPointID; + if (this.renderer.isInMultipleRecordEdition(recordId)) { + ev.stopPropagation(); + Dialog.alert(this, _t("No valid record to save"), { + confirm_callback: async () => { + this.model.discardChanges(recordId); + await this._confirmSave(recordId); + this.renderer.focusCell(recordId, ev.target.__node); + }, + }); + } else { + this._super.apply(this, arguments); + } + }, + /** + * When the user clicks on one of the sortable column headers, we need to + * tell the model to sort itself properly, to update the pager and to + * rerender the view. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleColumnOrder: function (ev) { + ev.stopPropagation(); + var state = this.model.get(this.handle); + if (!state.groupedBy) { + this._updatePaging(state, { currentMinimum: 1 }); + } + var self = this; + this.model.setSort(state.id, ev.data.name).then(function () { + self.update({}); + }); + }, + /** + * In a grouped list view, each group can be clicked on to open/close them. + * This method just transfer the request to the model, then update the + * renderer. + * + * @private + * @param {OdooEvent} ev + */ + _onToggleGroup: function (ev) { + ev.stopPropagation(); + var self = this; + this.model + .toggleGroup(ev.data.group.id) + .then(function () { + self.update({}, {keepSelection: true, reload: false}).then(function () { + if (ev.data.onSuccess) { + ev.data.onSuccess(); + } + }); + }); + }, +}); + +return ListController; + +}); diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js new file mode 100644 index 00000000..7afe0425 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -0,0 +1,1851 @@ +odoo.define('web.EditableListRenderer', function (require) { +"use strict"; + +/** + * Editable List renderer + * + * The list renderer is reasonably complex, so we split it in two files. This + * file simply 'includes' the basic ListRenderer to add all the necessary + * behaviors to enable editing records. + * + * Unlike Odoo v10 and before, this list renderer is independant from the form + * view. It uses the same widgets, but the code is totally stand alone. + */ +var core = require('web.core'); +var dom = require('web.dom'); +var ListRenderer = require('web.ListRenderer'); +var utils = require('web.utils'); +const { WidgetAdapterMixin } = require('web.OwlCompatibility'); + +var _t = core._t; + +ListRenderer.include({ + RESIZE_DELAY: 200, + custom_events: _.extend({}, ListRenderer.prototype.custom_events, { + navigation_move: '_onNavigationMove', + }), + events: _.extend({}, ListRenderer.prototype.events, { + 'click .o_field_x2many_list_row_add a': '_onAddRecord', + 'click .o_group_field_row_add a': '_onAddRecordToGroup', + 'keydown .o_field_x2many_list_row_add a': '_onKeyDownAddRecord', + 'click tbody td.o_data_cell': '_onCellClick', + 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick', + 'click tfoot': '_onFooterClick', + 'click tr .o_list_record_remove': '_onRemoveIconClick', + }), + /** + * @override + * @param {Object} params + * @param {boolean} params.addCreateLine + * @param {boolean} params.addCreateLineInGroups + * @param {boolean} params.addTrashIcon + * @param {boolean} params.isMany2Many + * @param {boolean} params.isMultiEditable + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.editable = params.editable; + this.isMultiEditable = params.isMultiEditable; + this.columnWidths = false; + + // if addCreateLine (resp. addCreateLineInGroups) is true, the renderer + // will add a 'Add a line' link at the bottom of the list view (resp. + // at the bottom of each group) + this.addCreateLine = params.addCreateLine; + this.addCreateLineInGroups = params.addCreateLineInGroups; + + // Controls allow overriding "add a line" by custom controls. + + // Each <control> (only one is actually needed) is a container for (multiple) <create>. + // Each <create> will be a "add a line" button with custom text and context. + + // The following code will browse the arch to find + // all the <create> that are inside <control> + this.creates = []; + this.arch.children.forEach(child => { + if (child.tag !== 'control') { + return; + } + child.children.forEach(child => { + if (child.tag !== 'create' || child.attrs.invisible) { + return; + } + this.creates.push({ + context: child.attrs.context, + string: child.attrs.string, + }); + }); + }); + + // Add the default button if we didn't find any custom button. + if (this.creates.length === 0) { + this.creates.push({ + string: _t("Add a line"), + }); + } + + // if addTrashIcon is true, there will be a small trash icon at the end + // of each line, so the user can delete a record. + this.addTrashIcon = params.addTrashIcon; + + // replace the trash icon by X in case of many2many relations + // so that it means 'unlink' instead of 'remove' + this.isMany2Many = params.isMany2Many; + + this.currentRow = null; + this.currentFieldIndex = null; + this.isResizing = false; + this.eventListeners = []; + }, + /** + * @override + * @returns {Promise} + */ + start: function () { + core.bus.on('click', this, this._onWindowClicked.bind(this)); + core.bus.on('resize', this, _.debounce(this._onResize.bind(this), this.RESIZE_DELAY)); + core.bus.on('DOM_updated', this, () => this._freezeColumnWidths()); + return this._super(); + }, + /** + * Overriden to unbind all attached listeners + * + * @override + */ + destroy: function () { + this.eventListeners.forEach(listener => { + const { type, el, callback, options } = listener; + el.removeEventListener(type, callback, options); + }); + return this._super.apply(this, arguments); + }, + /** + * The list renderer needs to know if it is in the DOM, and to be notified + * when it is attached to the DOM to properly compute column widths. + * + * @override + */ + on_attach_callback: function () { + this.isInDOM = true; + this._super(); + // _freezeColumnWidths requests style information, which produces a + // repaint, so we call it after _super to prevent flickering (in case + // other code would also modify the DOM post rendering/before repaint) + this._freezeColumnWidths(); + }, + /** + * The list renderer needs to know if it is in the DOM to properly compute + * column widths. + * + * @override + */ + on_detach_callback: function () { + this.isInDOM = false; + this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * If the given recordID is the list main one (or that no recordID is + * given), then the whole view can be saved if one of the two following + * conditions is true: + * - There is no line in edition (all lines are saved so they are all valid) + * - The line in edition can be saved + * + * If the given recordID is a record in the list, toggle a className on its + * row's cells for invalid fields, so that we can style those cells + * differently. + * + * @override + * @param {string} [recordID] + * @returns {string[]} + */ + canBeSaved: function (recordID) { + if ((recordID || this.state.id) === this.state.id) { + recordID = this.getEditableRecordID(); + if (recordID === null) { + return []; + } + } + var fieldNames = this._super(recordID); + this.$('.o_selected_row .o_data_cell').removeClass('o_invalid_cell'); + this.$('.o_selected_row .o_data_cell:has(> .o_field_invalid)').addClass('o_invalid_cell'); + return fieldNames; + }, + /** + * We need to override the confirmChange method from BasicRenderer to + * reevaluate the row decorations. Since they depends on the current value + * of the row, they might have changed between each edit. + * + * @override + */ + confirmChange: function (state, recordID) { + var self = this; + return this._super.apply(this, arguments).then(function (widgets) { + if (widgets.length) { + var $row = self._getRow(recordID); + var record = self._getRecord(recordID); + self._setDecorationClasses($row, self.rowDecorations, record); + self._updateFooter(); + } + return widgets; + }); + }, + /** + * This is a specialized version of confirmChange, meant to be called when + * the change may have affected more than one line (so, for example, an + * onchange which add/remove a few lines in a x2many. This does not occur + * in a normal list view). + * + * The update is more difficult when other rows could have been changed. We + * need to potentially remove some lines, add some other lines, update some + * other lines and maybe reorder a few of them. This problem would neatly + * be solved by using a virtual dom, but we do not have this luxury yet. + * So, in the meantime, what we do is basically remove every current row + * except the 'main' one (the row which caused the update), then rerender + * every new row and add them before/after the main one. + * + * Note that this function assumes that the list isn't grouped, which is + * fine as it's never the case for x2many lists. + * + * @param {Object} state + * @param {string} id + * @param {string[]} fields + * @param {OdooEvent} ev + * @returns {Promise<AbstractField[]>} resolved with the list of widgets + * that have been reset + */ + confirmUpdate: function (state, id, fields, ev) { + var self = this; + + var oldData = this.state.data; + this._setState(state); + return this.confirmChange(state, id, fields, ev).then(function () { + // If no record with 'id' can be found in the state, the + // confirmChange method will have rerendered the whole view already, + // so no further work is necessary. + var record = self._getRecord(id); + if (!record) { + return; + } + + _.each(oldData, function (rec) { + if (rec.id !== id) { + self._destroyFieldWidgets(rec.id); + } + }); + + // re-render whole body (outside the dom) + self.defs = []; + var $newBody = self._renderBody(); + var defs = self.defs; + delete self.defs; + + return Promise.all(defs).then(function () { + // update registered modifiers to edit 'mode' because the call to + // _renderBody set baseModeByRecord as 'readonly' + _.each(self.columns, function (node) { + self._registerModifiers(node, record, null, {mode: 'edit'}); + }); + + // store the selection range to restore it once the table will + // be re-rendered, and the current cell re-selected + var currentRowID; + var currentWidget; + var focusedElement; + var selectionRange; + if (self.currentRow !== null) { + currentRowID = self._getRecordID(self.currentRow); + currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; + if (currentWidget) { + focusedElement = currentWidget.getFocusableElement().get(0); + if (currentWidget.formatType !== 'boolean' && focusedElement) { + selectionRange = dom.getSelectionRange(focusedElement); + } + } + } + + // remove all data rows except the one being edited, and insert + // data rows of the re-rendered body before and after it + var $editedRow = self._getRow(id); + $editedRow.nextAll('.o_data_row').remove(); + $editedRow.prevAll('.o_data_row').remove(); + var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]'); + $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertBefore($editedRow); + }); + $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) { + $(row).insertAfter($editedRow); + }); + + if (self.currentRow !== null) { + var newRowIndex = $editedRow.prop('rowIndex') - 1; + self.currentRow = newRowIndex; + return self._selectCell(newRowIndex, self.currentFieldIndex, {force: true}) + .then(function () { + // restore the selection range + currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex]; + if (currentWidget) { + focusedElement = currentWidget.getFocusableElement().get(0); + if (selectionRange) { + dom.setSelectionRange(focusedElement, selectionRange); + } + } + }); + } + }); + }); + }, + /** + * Edit the first record in the list + */ + editFirstRecord: function (ev) { + const $borderRow = this._getBorderRow(ev.data.side || 'first'); + this._selectCell($borderRow.prop('rowIndex') - 1, ev.data.cellIndex || 0); + }, + /** + * Edit a given record in the list + * + * @param {string} recordID + */ + editRecord: function (recordID) { + var $row = this._getRow(recordID); + var rowIndex = $row.prop('rowIndex') - 1; + this._selectCell(rowIndex, 0); + }, + /** + * Gives focus to a specific cell, given its row and its related column. + * + * @param {string} recordId + * @param {Object} column + */ + focusCell: function (recordId, column) { + var $row = this._getRow(recordId); + var cellIndex = this.columns.indexOf(column); + $row.find('.o_data_cell')[cellIndex].focus(); + }, + /** + * Returns the recordID associated to the line which is currently in edition + * or null if there is no line in edition. + * + * @returns {string|null} + */ + getEditableRecordID: function () { + if (this.currentRow !== null) { + return this._getRecordID(this.currentRow); + } + return null; + }, + /** + * Returns whether the list is in multiple record edition from a given record. + * + * @private + * @param {string} recordId + * @returns {boolean} + */ + isInMultipleRecordEdition: function (recordId) { + return this.isEditable() && this.isMultiEditable && this.selection.includes(recordId); + }, + /** + * Returns whether the list can be edited. + * It's true when: + * - the list `editable` property is set, + * - or at least one record is selected (becomes partially editable) + * + * @returns {boolean} + */ + isEditable: function () { + return this.editable || (this.isMultiEditable && this.selection.length); + }, + /** + * Removes the line associated to the given recordID (the index of the row + * is found thanks to the old state), then updates the state. + * + * @param {Object} state + * @param {string} recordID + */ + removeLine: function (state, recordID) { + this._setState(state); + var $row = this._getRow(recordID); + if ($row.length === 0) { + return; + } + if ($row.prop('rowIndex') - 1 === this.currentRow) { + this.currentRow = null; + this._enableRecordSelectors(); + } + + // destroy widgets first + this._destroyFieldWidgets(recordID); + // remove the row + if (this.state.count >= 4) { + $row.remove(); + } else { + // we want to always keep at least 4 (possibly empty) rows + var $emptyRow = this._renderEmptyRow(); + $row.replaceWith($emptyRow); + // move the empty row we just inserted after last data row + const $lastDataRow = this.$('.o_data_row:last'); + if ($lastDataRow.length) { + $emptyRow.insertAfter($lastDataRow); + } + } + }, + /** + * Updates the already rendered row associated to the given recordID so that + * it fits the given mode. + * + * @param {string} recordID + * @param {string} mode + * @returns {Promise} + */ + setRowMode: function (recordID, mode) { + var self = this; + var record = self._getRecord(recordID); + if (!record) { + return Promise.resolve(); + } + + var editMode = (mode === 'edit'); + var $row = this._getRow(recordID); + this.currentRow = editMode ? $row.prop('rowIndex') - 1 : null; + var $tds = $row.children('.o_data_cell'); + var oldWidgets = _.clone(this.allFieldWidgets[record.id]); + + // Prepare options for cell rendering (this depends on the mode) + var options = { + renderInvisible: editMode, + renderWidgets: editMode, + }; + options.mode = editMode ? 'edit' : 'readonly'; + + // Switch each cell to the new mode; note: the '_renderBodyCell' + // function might fill the 'this.defs' variables with multiple promise + // so we create the array and delete it after the rendering. + var defs = []; + this.defs = defs; + _.each(this.columns, function (node, colIndex) { + var $td = $tds.eq(colIndex); + var $newTd = self._renderBodyCell(record, node, colIndex, options); + + // Widgets are unregistered of modifiers data when they are + // destroyed. This is not the case for simple buttons so we have to + // do it here. + if ($td.hasClass('o_list_button')) { + self._unregisterModifiersElement(node, recordID, $td.children()); + } + + // For edit mode we only replace the content of the cell with its + // new content (invisible fields, editable fields, ...). + // For readonly mode, we replace the whole cell so that the + // dimensions of the cell are not forced anymore. + if (editMode) { + $td.empty().append($newTd.contents()); + } else { + self._unregisterModifiersElement(node, recordID, $td); + $td.replaceWith($newTd); + } + }); + delete this.defs; + + // Destroy old field widgets + _.each(oldWidgets, this._destroyFieldWidget.bind(this, recordID)); + + // Toggle selected class here so that style is applied at the end + $row.toggleClass('o_selected_row', editMode); + if (editMode) { + this._disableRecordSelectors(); + } else { + this._enableRecordSelectors(); + } + + return Promise.all(defs).then(function () { + // mark Owl sub components as mounted + WidgetAdapterMixin.on_attach_callback.call(self); + + // necessary to trigger resize on fieldtexts + core.bus.trigger('DOM_updated'); + }); + }, + /** + * This method is called whenever we click/move outside of a row that was + * in edit mode. This is the moment we save all accumulated changes on that + * row, if needed (@see BasicController.saveRecord). + * + * Note that we have to disable the focusable elements (inputs, ...) to + * prevent subsequent editions. These edits would be lost, because the list + * view only saves records when unselecting a row. + * + * @returns {Promise} The promise resolves if the row was unselected (and + * possibly removed). If may be rejected, when the row is dirty and the + * user refuses to discard its changes. + */ + unselectRow: function () { + // Protect against calling this method when no row is selected + if (this.currentRow === null) { + return Promise.resolve(); + } + var recordID = this._getRecordID(this.currentRow); + var recordWidgets = this.allFieldWidgets[recordID]; + function toggleWidgets(disabled) { + _.each(recordWidgets, function (widget) { + var $el = widget.getFocusableElement(); + $el.prop('disabled', disabled); + }); + } + + toggleWidgets(true); + return new Promise((resolve, reject) => { + this.trigger_up('save_line', { + recordID: recordID, + onSuccess: resolve, + onFailure: reject, + }); + }).then(selectNextRow => { + this._enableRecordSelectors(); + // If any field has changed and if the list is in multiple edition, + // we send a truthy boolean to _selectRow to tell it not to select + // the following record. + return selectNextRow; + }).guardedCatch(() => { + toggleWidgets(false); + }); + }, + /** + * @override + */ + updateState: function (state, params) { + // There are some cases where a record is added to an invisible list + // e.g. set a quotation template with optionnal products + if (params.keepWidths && this.$el.is(':visible')) { + this._storeColumnWidths(); + } + if (params.noRender) { + // the state changed, but we won't do a re-rendering right now, so + // remove computed modifiers data (as they are obsolete) to force + // them to be recomputed at next (sub-)rendering + this.allModifiersData = []; + } + if ('addTrashIcon' in params) { + if (this.addTrashIcon !== params.addTrashIcon) { + this.columnWidths = false; // columns changed, so forget stored widths + } + this.addTrashIcon = params.addTrashIcon; + } + if ('addCreateLine' in params) { + this.addCreateLine = params.addCreateLine; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Used to bind event listeners so that they can be unbound when the list + * is destroyed. + * There is no reverse method (list._removeEventListener) because there is + * no issue with removing an non-existing listener. + * + * @private + * @param {string} type event name + * @param {EventTarget} el event target + * @param {Function} callback callback function to attach + * @param {Object} options event listener options + */ + _addEventListener: function (type, el, callback, options) { + el.addEventListener(type, callback, options); + this.eventListeners.push({ type, el, callback, options }); + }, + /** + * Handles the assignation of default widths for each column header. + * If the list is empty, an arbitrary absolute or relative width will be + * given to the header + * + * @see _getColumnWidth for detailed information about which width is + * given to a certain field type. + * + * @private + */ + _computeDefaultWidths: function () { + const isListEmpty = !this._hasVisibleRecords(this.state); + const relativeWidths = []; + this.columns.forEach(column => { + const th = this._getColumnHeader(column); + if (th.offsetParent === null) { + relativeWidths.push(false); + } else { + const width = this._getColumnWidth(column); + if (width.match(/[a-zA-Z]/)) { // absolute width with measure unit (e.g. 100px) + if (isListEmpty) { + th.style.width = width; + } else { + // If there are records, we force a min-width for fields with an absolute + // width to ensure a correct rendering in edition + th.style.minWidth = width; + } + relativeWidths.push(false); + } else { // relative width expressed as a weight (e.g. 1.5) + relativeWidths.push(parseFloat(width, 10)); + } + } + }); + + // Assignation of relative widths + if (isListEmpty) { + const totalWidth = this._getColumnsTotalWidth(relativeWidths); + for (let i in this.columns) { + if (relativeWidths[i]) { + const th = this._getColumnHeader(this.columns[i]); + th.style.width = (relativeWidths[i] / totalWidth * 100) + '%'; + } + } + // Manualy assigns trash icon header width since it's not in the columns + const trashHeader = this.el.getElementsByClassName('o_list_record_remove_header')[0]; + if (trashHeader) { + trashHeader.style.width = '32px'; + } + } + }, + /** + * Destroy all field widgets corresponding to a record. Useful when we are + * removing a useless row. + * + * @param {string} recordID + */ + _destroyFieldWidgets: function (recordID) { + if (recordID in this.allFieldWidgets) { + var widgetsToDestroy = this.allFieldWidgets[recordID].slice(); + _.each(widgetsToDestroy, this._destroyFieldWidget.bind(this, recordID)); + delete this.allFieldWidgets[recordID]; + } + }, + /** + * When editing a row, we want to disable all record selectors. + * + * @private + */ + _disableRecordSelectors: function () { + this.$('.o_list_record_selector input').attr('disabled', 'disabled'); + }, + /** + * @private + */ + _enableRecordSelectors: function () { + this.$('.o_list_record_selector input').attr('disabled', false); + }, + /** + * This function freezes the column widths and forces a fixed table-layout, + * once the browser has computed the optimal width of each column according + * to the displayed records. We want to freeze widths s.t. it doesn't + * flicker when we switch a row in edition. + * + * We skip this when there is no record as we don't want to fix widths + * according to column's labels. In this case, we fallback on the 'weight' + * heuristic, which assigns to each column a fixed or relative width + * depending on the widget or field type. + * + * Note that the list must be in the DOM when this function is called. + * + * @private + */ + _freezeColumnWidths: function () { + if (!this.columnWidths && this.el.offsetParent === null) { + // there is no record nor widths to restore or the list is not visible + // -> don't force column's widths w.r.t. their label + return; + } + const thElements = [...this.el.querySelectorAll('table thead th')]; + if (!thElements.length) { + return; + } + const table = this.el.getElementsByClassName('o_list_table')[0]; + let columnWidths = this.columnWidths; + + if (!columnWidths || !columnWidths.length) { // no column widths to restore + // Set table layout auto and remove inline style to make sure that css + // rules apply (e.g. fixed width of record selector) + table.style.tableLayout = 'auto'; + thElements.forEach(th => { + th.style.width = null; + th.style.maxWidth = null; + }); + + // Resets the default widths computation now that the table is visible. + this._computeDefaultWidths(); + + // Squeeze the table by applying a max-width on largest columns to + // ensure that it doesn't overflow + columnWidths = this._squeezeTable(); + } + + thElements.forEach((th, index) => { + // Width already set by default relative width computation + if (!th.style.width) { + th.style.width = `${columnWidths[index]}px`; + } + }); + + // Set the table layout to fixed + table.style.tableLayout = 'fixed'; + }, + /** + * Returns the first or last editable row of the list + * + * @private + * @returns {integer} + */ + _getBorderRow: function (side) { + let $borderDataRow = this.$(`.o_data_row:${side}`); + if (!this._isRecordEditable($borderDataRow.data('id'))) { + $borderDataRow = this._getNearestEditableRow($borderDataRow, side === 'first'); + } + return $borderDataRow; + }, + /** + * Compute the sum of the weights for each column, given an array containing + * all relative widths. param `$thead` is useful for studio, in order to + * show column hooks. + * + * @private + * @param {jQuery} $thead + * @param {number[]} relativeWidths + * @return {integer} + */ + _getColumnsTotalWidth(relativeWidths) { + return relativeWidths.reduce((acc, width) => acc + width, 0); + }, + /** + * Returns the width of a column according the 'width' attribute set in the + * arch, the widget or the field type. A fixed width is harcoded for some + * field types (e.g. date and numeric fields). By default, the remaining + * space is evenly distributed between the other fields (with a factor '1'). + * + * This is only used when there is no record in the list (i.e. when we can't + * let the browser compute the optimal width of each column). + * + * @see _renderHeader + * @private + * @param {Object} column an arch node + * @returns {string} either a weight factor (e.g. '1.5') or a css width + * description (e.g. '120px') + */ + _getColumnWidth: function (column) { + if (column.attrs.width) { + return column.attrs.width; + } + const fieldsInfo = this.state.fieldsInfo.list; + const name = column.attrs.name; + if (!fieldsInfo[name]) { + // Unnamed columns get default value + return '1'; + } + const widget = fieldsInfo[name].Widget.prototype; + if ('widthInList' in widget) { + return widget.widthInList; + } + const field = this.state.fields[name]; + if (!field) { + // this is not a field. Probably a button or something of unknown + // width. + return '1'; + } + const fixedWidths = { + boolean: '70px', + date: '92px', + datetime: '146px', + float: '92px', + integer: '74px', + monetary: '104px', + }; + let type = field.type; + if (fieldsInfo[name].widget in fixedWidths) { + type = fieldsInfo[name].widget; + } + return fixedWidths[type] || '1'; + }, + /** + * Gets the th element corresponding to a given column. + * + * @private + * @param {Object} column + * @returns {HTMLElement} + */ + _getColumnHeader: function (column) { + const { icon, name, string } = column.attrs; + if (name) { + return this.el.querySelector(`thead th[data-name="${name}"]`); + } else if (string) { + return this.el.querySelector(`thead th[data-string="${string}"]`); + } else if (icon) { + return this.el.querySelector(`thead th[data-icon="${icon}"]`); + } + }, + /** + * Returns the nearest editable row starting from a given table row. + * If the list is grouped, jumps to the next unfolded group + * + * @private + * @param {jQuery} $row starting point + * @param {boolean} next whether the requested row should be the next or the previous one + * @return {jQuery|null} + */ + _getNearestEditableRow: function ($row, next) { + const direction = next ? 'next' : 'prev'; + let $nearestRow; + if (this.editable) { + $nearestRow = $row[direction](); + if (!$nearestRow.hasClass('o_data_row')) { + var $nextBody = $row.closest('tbody')[direction](); + while ($nextBody.length && !$nextBody.find('.o_data_row').length) { + $nextBody = $nextBody[direction](); + } + $nearestRow = $nextBody.find(`.o_data_row:${next ? 'first' : 'last'}`); + } + } else { + // In readonly lists, look directly into selected records + const recordId = $row.data('id'); + const rowSelectionIndex = this.selection.indexOf(recordId); + let nextRowIndex; + if (rowSelectionIndex < 0) { + nextRowIndex = next ? 0 : this.selection.length - 1; + } else { + nextRowIndex = rowSelectionIndex + (next ? 1 : -1); + } + // Index might be out of range, will then return an empty jQuery object + $nearestRow = this._getRow(this.selection[nextRowIndex]); + } + return $nearestRow; + }, + /** + * Returns the current number of columns. The editable renderer may add a + * trash icon on the right of a record, so we need to take this into account + * + * @override + * @returns {number} + */ + _getNumberOfCols: function () { + var n = this._super(); + if (this.addTrashIcon) { + n++; + } + return n; + }, + /** + * Traverse this.state to find and return the record with given dataPoint id + * (for grouped list views, the record could be deep down in state tree). + * + * @override + * @private + */ + _getRecord: function (recordId) { + var record; + utils.traverse_records(this.state, function (r) { + if (r.id === recordId) { + record = r; + } + }); + return record; + }, + /** + * Retrieve the record dataPoint id from a rowIndex as the row DOM element + * stores the record id in data. + * + * @private + * @param {integer} rowIndex + * @returns {string} record dataPoint id + */ + _getRecordID: function (rowIndex) { + var $tr = this.$('table.o_list_table > tbody tr').eq(rowIndex); + return $tr.data('id'); + }, + /** + * Return the jQuery tr element corresponding to the given record dataPoint + * id. + * + * @private + * @param {string} [recordId] + * @returns {jQueryElement} + */ + _getRow: function (recordId) { + return this.$('.o_data_row[data-id="' + recordId + '"]'); + }, + /** + * This function returns true iff records are visible in the list, i.e. + * if the list is ungrouped: true iff the list isn't empty; + * if the list is grouped: true iff there is at least one unfolded group + * containing records. + * + * @param {Object} list a datapoint + * @returns {boolean} + */ + _hasVisibleRecords: function (list) { + if (!list.groupedBy.length) { + return !!list.data.length; + } else { + var hasVisibleRecords = false; + for (var i = 0; i < list.data.length; i++) { + hasVisibleRecords = hasVisibleRecords || this._hasVisibleRecords(list.data[i]); + } + return hasVisibleRecords; + } + }, + /** + * Returns whether a recordID is currently editable. + * + * @param {string} recordID + * @returns {boolean} + */ + _isRecordEditable: function (recordID) { + return this.editable || (this.isMultiEditable && this.selection.includes(recordID)); + }, + /** + * Moves to the next row in the list + * + * @private + * @params {Object} [options] see @_moveToSideLine + */ + _moveToNextLine: function (options) { + this._moveToSideLine(true, options); + }, + /** + * Moves to the previous row in the list + * + * @private + * @params {Object} [options] see @_moveToSideLine + */ + _moveToPreviousLine: function (options) { + this._moveToSideLine(false, options); + }, + /** + * Moves the focus to the nearest editable row before or after the current one. + * If we arrive at the end of the list (or of a group in the grouped case) and the list + * is editable="bottom", we create a new record, otherwise, we move the + * cursor to the first row (of the next group in the grouped case). + * + * @private + * @param {number} next whether to move to the next or previous row + * @param {Object} [options] + * @param {boolean} [options.forceCreate=false] typically set to true when + * navigating with ENTER ; in this case, if the next row is the 'Add a + * row' one, always create a new record (never skip it, like TAB does + * under some conditions) + */ + _moveToSideLine: function (next, options) { + options = options || {}; + const recordID = this._getRecordID(this.currentRow); + this.commitChanges(recordID).then(() => { + const record = this._getRecord(recordID); + const multiEdit = this.isInMultipleRecordEdition(recordID); + if (!multiEdit) { + const fieldNames = this.canBeSaved(recordID); + if (fieldNames.length && (record.isDirty() || options.forceCreate)) { + // the current row is invalid, we only leave it if it is not dirty + // (we didn't make any change on this row, which is a new one) and + // we are navigating with TAB (forceCreate=false) + return; + } + } + // compute the index of the next (record) row to select, if any + const side = next ? 'first' : 'last'; + const borderRowIndex = this._getBorderRow(side).prop('rowIndex') - 1; + const cellIndex = next ? 0 : this.allFieldWidgets[recordID].length - 1; + const cellOptions = { inc: next ? 1 : -1, force: true }; + const $currentRow = this._getRow(recordID); + const $nextRow = this._getNearestEditableRow($currentRow, next); + let nextRowIndex = null; + let groupId; + + if (!this.isGrouped) { + // ungrouped case + if ($nextRow.length) { + nextRowIndex = $nextRow.prop('rowIndex') - 1; + } else if (!this.editable) { + nextRowIndex = borderRowIndex; + } else if (!options.forceCreate && !record.isDirty()) { + this.trigger_up('discard_changes', { + recordID: recordID, + onSuccess: this.trigger_up.bind(this, 'activate_next_widget', { side: side }), + }); + return; + } + } else { + // grouped case + var $directNextRow = $currentRow.next(); + if (next && this.editable === "bottom" && $directNextRow.hasClass('o_add_record_row')) { + // the next row is the 'Add a line' row (i.e. the current one is the last record + // row of the group) + if (options.forceCreate || record.isDirty()) { + // if we modified the current record, add a row to create a new record + groupId = $directNextRow.data('group-id'); + } else { + // if we didn't change anything to the current line (e.g. we pressed TAB on + // each cell without modifying/entering any data), we discard that line (if + // it was a new one) and move to the first record of the next group + nextRowIndex = ($nextRow.prop('rowIndex') - 1) || null; + this.trigger_up('discard_changes', { + recordID: recordID, + onSuccess: () => { + if (nextRowIndex !== null) { + if (!record.res_id) { + // the current record was a new one, so we decrement + // nextRowIndex as that row has been removed meanwhile + nextRowIndex--; + } + this._selectCell(nextRowIndex, cellIndex, cellOptions); + } else { + // we were in the last group, so go back to the top + this._selectCell(borderRowIndex, cellIndex, cellOptions); + } + }, + }); + return; + } + } else { + // there is no 'Add a line' row (i.e. the create feature is disabled), or the + // list is editable="top", we focus the first record of the next group if any, + // or we go back to the top of the list + nextRowIndex = $nextRow.length ? + ($nextRow.prop('rowIndex') - 1) : + borderRowIndex; + } + } + + // if there is a (record) row to select, select it, otherwise, add a new record (in the + // correct group, if the view is grouped) + if (nextRowIndex !== null) { + // cellOptions.force = true; + this._selectCell(nextRowIndex, cellIndex, cellOptions); + } else if (this.editable) { + // if for some reason (e.g. create feature is disabled) we can't add a new + // record, select the first record row + this.unselectRow().then(this.trigger_up.bind(this, 'add_record', { + groupId: groupId, + onFail: this._selectCell.bind(this, borderRowIndex, cellIndex, cellOptions), + })); + } + }); + }, + /** + * Override to compute the (relative or absolute) width of each column. + * + * @override + * @private + */ + _processColumns: function () { + const oldColumns = this.columns; + this._super.apply(this, arguments); + // check if stored widths still apply + if (this.columnWidths && oldColumns && oldColumns.length === this.columns.length) { + for (let i = 0; i < oldColumns.length; i++) { + if (oldColumns[i] !== this.columns[i]) { + this.columnWidths = false; // columns changed, so forget stored widths + break; + } + } + } else { + this.columnWidths = false; // columns changed, so forget stored widths + } + }, + /** + * @override + * @returns {Promise} + */ + _render: function () { + this.currentRow = null; + this.currentFieldIndex = null; + return this._super.apply(this, arguments); + }, + /** + * Override to add the 'Add an item' link to the end of last-level opened + * groups. + * + * @override + * @private + */ + _renderGroup: function (group) { + var result = this._super.apply(this, arguments); + if (!group.groupedBy.length && this.addCreateLineInGroups) { + var $groupBody = result[0]; + var $a = $('<a href="#" role="button">') + .text(_t("Add a line")) + .attr('data-group-id', group.id); + var $td = $('<td>') + .attr('colspan', this._getNumberOfCols()) + .addClass('o_group_field_row_add') + .attr('tabindex', -1) + .append($a); + var $tr = $('<tr>', {class: 'o_add_record_row'}) + .attr('data-group-id', group.id) + .append($td); + $groupBody.append($tr.prepend($('<td>').html(' '))); + } + return result; + }, + /** + * The renderer needs to support reordering lines. This is only active in + * edit mode. The handleField attribute is set when there is a sequence + * widget. + * + * @override + */ + _renderBody: function () { + var self = this; + var $body = this._super.apply(this, arguments); + if (this.hasHandle) { + $body.sortable({ + axis: 'y', + items: '> tr.o_data_row', + helper: 'clone', + handle: '.o_row_handle', + stop: function (event, ui) { + // update currentID taking moved line into account + if (self.currentRow !== null) { + var currentID = self.state.data[self.currentRow].id; + self.currentRow = self._getRow(currentID).index(); + } + self.unselectRow().then(function () { + self._moveRecord(ui.item.data('id'), ui.item.index()); + }); + }, + }); + } + return $body; + }, + /** + * @override + * @private + */ + _renderFooter: function () { + const $footer = this._super.apply(this, arguments); + if (this.addTrashIcon) { + $footer.find('tr').append($('<td>')); + } + return $footer; + }, + /** + * Override to optionally add a th in the header for the remove icon column. + * + * @override + * @private + */ + _renderHeader: function () { + var $thead = this._super.apply(this, arguments); + if (this.addTrashIcon) { + $thead.find('tr').append($('<th>', {class: 'o_list_record_remove_header'})); + } + return $thead; + }, + /** + * Overriden to add a resize handle in editable list column headers. + * Only applies to headers containing text. + * + * @override + * @private + */ + _renderHeaderCell: function () { + const $th = this._super.apply(this, arguments); + if ($th[0].innerHTML.length && this._hasVisibleRecords(this.state)) { + const resizeHandle = document.createElement('span'); + resizeHandle.classList = 'o_resize'; + resizeHandle.onclick = this._onClickResize.bind(this); + resizeHandle.onmousedown = this._onStartResize.bind(this); + $th.append(resizeHandle); + } + return $th; + }, + /** + * Editable rows are possibly extended with a trash icon on their right, to + * allow deleting the corresponding record. + * For many2many editable lists, the trash bin is replaced by X. + * + * @override + * @param {any} record + * @param {any} index + * @returns {jQueryElement} + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + if (this.addTrashIcon) { + var $icon = this.isMany2Many ? + $('<button>', {'class': 'fa fa-times', 'name': 'unlink', 'aria-label': _t('Unlink row ') + (index + 1)}) : + $('<button>', {'class': 'fa fa-trash-o', 'name': 'delete', 'aria-label': _t('Delete row ') + (index + 1)}); + var $td = $('<td>', {class: 'o_list_record_remove'}).append($icon); + $row.append($td); + } + return $row; + }, + /** + * If the editable list view has the parameter addCreateLine, we need to + * add a last row with the necessary control. + * + * If the list has a handleField, we want to left-align the first button + * on the first real column. + * + * @override + * @returns {jQueryElement[]} + */ + _renderRows: function () { + var $rows = this._super(); + if (this.addCreateLine) { + var $tr = $('<tr>'); + var colspan = this._getNumberOfCols(); + + if (this.handleField) { + colspan = colspan - 1; + $tr.append('<td>'); + } + + var $td = $('<td>') + .attr('colspan', colspan) + .addClass('o_field_x2many_list_row_add'); + $tr.append($td); + $rows.push($tr); + + if (this.addCreateLine) { + _.each(this.creates, function (create, index) { + var $a = $('<a href="#" role="button">') + .attr('data-context', create.context) + .text(create.string); + if (index > 0) { + $a.addClass('ml16'); + } + $td.append($a); + }); + } + } + return $rows; + }, + /** + * @override + * @private + * @returns {Promise} this promise is resolved immediately + */ + _renderView: function () { + this.currentRow = null; + return this._super.apply(this, arguments).then(() => { + const table = this.el.getElementsByClassName('o_list_table')[0]; + if (table) { + table.classList.toggle('o_empty_list', !this._hasVisibleRecords(this.state)); + this._freezeColumnWidths(); + } + }); + }, + /** + * This is one of the trickiest method in the editable renderer. It has to + * do a lot of stuff: it has to determine which cell should be selected (if + * the target cell is readonly, we need to find another suitable cell), then + * unselect the current row, and activate the line where the selected cell + * is, if necessary. + * + * @param {integer} rowIndex + * @param {integer} fieldIndex + * @param {Object} [options] + * @param {Event} [options.event] original target of the event which + * @param {boolean} [options.wrap=true] if true and no widget could be + * triggered the cell selection + * selected from the fieldIndex to the last column, then we wrap around and + * try to select a widget starting from the beginning + * @param {boolean} [options.force=false] if true, force selecting the cell + * even if seems to be already the selected one (useful after a re- + * rendering, to reset the focus on the correct field) + * @param {integer} [options.inc=1] the increment to use when searching for + * the "next" possible cell (if the cell to select can't be selected) + * @return {Promise} fails if no cell could be selected + */ + _selectCell: function (rowIndex, fieldIndex, options) { + options = options || {}; + // Do nothing if the user tries to select current cell + if (!options.force && rowIndex === this.currentRow && fieldIndex === this.currentFieldIndex) { + return Promise.resolve(); + } + var wrap = options.wrap === undefined ? true : options.wrap; + var recordID = this._getRecordID(rowIndex); + + // Select the row then activate the widget in the correct cell + var self = this; + return this._selectRow(rowIndex).then(function () { + var record = self._getRecord(recordID); + if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) { + return Promise.reject(); + } + // _activateFieldWidget might trigger an onchange, + // which requires currentFieldIndex to be set + // so that the cursor can be restored + var oldFieldIndex = self.currentFieldIndex; + self.currentFieldIndex = fieldIndex; + fieldIndex = self._activateFieldWidget(record, fieldIndex, { + inc: options.inc || 1, + wrap: wrap, + event: options && options.event, + }); + if (fieldIndex < 0) { + self.currentFieldIndex = oldFieldIndex; + return Promise.reject(); + } + self.currentFieldIndex = fieldIndex; + }); + }, + /** + * Activates the row at the given row index. + * + * @param {integer} rowIndex + * @returns {Promise} + */ + _selectRow: function (rowIndex) { + // Do nothing if already selected + if (rowIndex === this.currentRow) { + return Promise.resolve(); + } + if (!this.columnWidths) { + // we don't want the column widths to change when selecting rows + this._storeColumnWidths(); + } + var recordId = this._getRecordID(rowIndex); + // To select a row, the currently selected one must be unselected first + var self = this; + return this.unselectRow().then((selectNextRow = true) => { + if (!selectNextRow) { + return Promise.resolve(); + } + if (!recordId) { + // The row to selected doesn't exist anymore (probably because + // an onchange triggered when unselecting the previous one + // removes rows) + return Promise.reject(); + } + // Notify the controller we want to make a record editable + return new Promise(function (resolve) { + self.trigger_up('edit_line', { + recordId: recordId, + onSuccess: function () { + self._disableRecordSelectors(); + resolve(); + }, + }); + }); + }); + }, + /** + * Set a maximum width on the largest columns in the list in case the table + * is overflowing. The idea is to shrink largest columns first, but to + * ensure that they are still the largest at the end (maybe in equal measure + * with other columns). Button columns aren't impacted by this function, as + * we assume that they can't be squeezed (we want all buttons to always be + * available, not being replaced by ellipsis). + * + * @private + * @returns {integer[]} width (in px) of each column s.t. the table doesn't + * overflow + */ + _squeezeTable: function () { + const table = this.el.getElementsByClassName('o_list_table')[0]; + + // Toggle a className used to remove style that could interfer with the ideal width + // computation algorithm (e.g. prevent text fields from being wrapped during the + // computation, to prevent them from being completely crushed) + table.classList.add('o_list_computing_widths'); + + const thead = table.getElementsByTagName('thead')[0]; + const thElements = [...thead.getElementsByTagName('th')]; + const columnWidths = thElements.map(th => th.offsetWidth); + const getWidth = th => columnWidths[thElements.indexOf(th)] || 0; + const getTotalWidth = () => thElements.reduce((tot, th, i) => tot + columnWidths[i], 0); + const shrinkColumns = (columns, width) => { + let thresholdReached = false; + columns.forEach(th => { + const index = thElements.indexOf(th); + let maxWidth = columnWidths[index] - Math.ceil(width / columns.length); + if (maxWidth < 92) { // prevent the columns from shrinking under 92px (~ date field) + maxWidth = 92; + thresholdReached = true; + } + th.style.maxWidth = `${maxWidth}px`; + columnWidths[index] = maxWidth; + }); + return thresholdReached; + }; + // Sort columns, largest first + const sortedThs = [...thead.querySelectorAll('th:not(.o_list_button)')] + .sort((a, b) => getWidth(b) - getWidth(a)); + const allowedWidth = table.parentNode.offsetWidth; + + let totalWidth = getTotalWidth(); + let stop = false; + let index = 0; + while (totalWidth > allowedWidth && !stop) { + // Find the largest columns + index++; + const largests = sortedThs.slice(0, index); + while (getWidth(largests[0]) === getWidth(sortedThs[index])) { + largests.push(sortedThs[index]); + index++; + } + + // Compute the number of px to remove from the largest columns + const nextLargest = sortedThs[index]; // largest column when omitting those in largests + const totalToRemove = totalWidth - allowedWidth; + const canRemove = (getWidth(largests[0]) - getWidth(nextLargest)) * largests.length; + + // Shrink the largests columns + stop = shrinkColumns(largests, Math.min(totalToRemove, canRemove)); + + totalWidth = getTotalWidth(); + } + + // We are no longer computing widths, so restore the normal style + table.classList.remove('o_list_computing_widths'); + + return columnWidths; + }, + /** + * @private + */ + _storeColumnWidths: function () { + this.columnWidths = this.$('thead th').toArray().map(function (th) { + return $(th).outerWidth(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method is called when we click on the 'Add a line' button in a groupby + * list view. + * + * @param {MouseEvent} ev + */ + _onAddRecordToGroup: function (ev) { + ev.preventDefault(); + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + var self = this; + // This method can be called when selecting the parent of the link. + // We need to ensure that the link is the actual target + const target = ev.target.tagName !== 'A' ? ev.target.getElementsByTagName('A')[0] : ev.target; + const groupId = target.dataset.groupId; + this.currentGroupId = groupId; + this.unselectRow().then(function () { + self.trigger_up('add_record', { + groupId: groupId, + }); + }); + }, + /** + * This method is called when we click on the 'Add a line' button in a sub + * list such as a one2many in a form view. + * + * @private + * @param {MouseEvent} ev + */ + _onAddRecord: function (ev) { + // we don't want the browser to navigate to a the # url + ev.preventDefault(); + + // we don't want the click to cause other effects, such as unselecting + // the row that we are creating, because it counts as a click on a tr + ev.stopPropagation(); + + // but we do want to unselect current row + var self = this; + this.unselectRow().then(function () { + self.trigger_up('add_record', {context: ev.currentTarget.dataset.context && [ev.currentTarget.dataset.context]}); // TODO write a test, the promise was not considered + }); + }, + /** + * When the user clicks on a cell, we simply select it. + * + * @private + * @param {MouseEvent} event + */ + _onCellClick: function (event) { + // The special_click property explicitely allow events to bubble all + // the way up to bootstrap's level rather than being stopped earlier. + var $td = $(event.currentTarget); + var $tr = $td.parent(); + var rowIndex = $tr.prop('rowIndex') - 1; + if (!this._isRecordEditable($tr.data('id')) || $(event.target).prop('special_click')) { + return; + } + var fieldIndex = Math.max($tr.find('.o_field_cell').index($td), 0); + this._selectCell(rowIndex, fieldIndex, {event: event}); + }, + /** + * We want to override any default mouse behaviour when clicking on the resize handles + * + * @private + * @param {MouseEvent} ev + */ + _onClickResize: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + }, + /** + * We need to manually unselect row, because no one else would do it + */ + _onEmptyRowClick: function () { + this.unselectRow(); + }, + /** + * Clicking on a footer should unselect (and save) the currently selected + * row. It has to be done this way, because this is a click inside this.el, + * and _onWindowClicked ignore those clicks. + */ + _onFooterClick: function () { + this.unselectRow(); + }, + /** + * Manages the keyboard events on the list. If the list is not editable, when the user navigates to + * a cell using the keyboard, if he presses enter, enter the model represented by the line + * + * @private + * @param {KeyboardEvent} ev + * @override + */ + _onKeyDown: function (ev) { + const $target = $(ev.currentTarget); + const $tr = $target.closest('tr'); + const recordEditable = this._isRecordEditable($tr.data('id')); + + if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && $tr.hasClass('o_selected_row')) { + // enter on a textarea for example, let it bubble + return; + } + + if (recordEditable && ev.keyCode === $.ui.keyCode.ENTER && + !$tr.hasClass('o_selected_row') && !$tr.hasClass('o_group_header')) { + ev.stopPropagation(); + ev.preventDefault(); + if ($target.closest('td').hasClass('o_group_field_row_add')) { + this._onAddRecordToGroup(ev); + } else { + this._onCellClick(ev); + } + } else { + this._super.apply(this, arguments); + } + }, + /** + * @private + * @param {KeyDownEvent} e + */ + _onKeyDownAddRecord: function (e) { + switch (e.keyCode) { + case $.ui.keyCode.ENTER: + e.stopPropagation(); + e.preventDefault(); + this._onAddRecord(e); + break; + } + }, + /** + * Handles the keyboard navigation according to events triggered by field + * widgets. + * - previous: move to the first activable cell on the left if any, if not + * move to the rightmost activable cell on the row above. + * - next: move to the first activable cell on the right if any, if not move + * to the leftmost activable cell on the row below. + * - next_line: move to leftmost activable cell on the row below. + * + * Note: moving to a line below if on the last line or moving to a line + * above if on the first line automatically creates a new line. + * + * @private + * @param {OdooEvent} ev + */ + _onNavigationMove: function (ev) { + var self = this; + // Don't stop the propagation when navigating up while not editing any row + if (this.currentRow === null && ev.data.direction === 'up') { + return; + } + ev.stopPropagation(); // stop the event, the action is done by this renderer + if (ev.data.originalEvent && ['next', 'previous'].includes(ev.data.direction)) { + ev.data.originalEvent.preventDefault(); + ev.data.originalEvent.stopPropagation(); + } + switch (ev.data.direction) { + case 'previous': + if (this.currentFieldIndex > 0) { + this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false}) + .guardedCatch(this._moveToPreviousLine.bind(this)); + } else { + this._moveToPreviousLine(); + } + break; + case 'next': + if (this.currentFieldIndex + 1 < this.columns.length) { + this._selectCell(this.currentRow, this.currentFieldIndex + 1, {wrap: false}) + .guardedCatch(this._moveToNextLine.bind(this)); + } else { + this._moveToNextLine(); + } + break; + case 'next_line': + // If the list is readonly and the current is the only record editable, we unselect the line + if (!this.editable && this.selection.length === 1 && + this._getRecordID(this.currentRow) === ev.target.dataPointID) { + this.unselectRow(); + } else { + this._moveToNextLine({ forceCreate: true }); + } + break; + case 'cancel': + // stop the original event (typically an ESCAPE keydown), to + // prevent from closing the potential dialog containing this list + // also auto-focus the 1st control, if any. + ev.data.originalEvent.stopPropagation(); + var rowIndex = this.currentRow; + var cellIndex = this.currentFieldIndex + 1; + this.trigger_up('discard_changes', { + recordID: ev.target.dataPointID, + onSuccess: function () { + self._enableRecordSelectors(); + var recordId = self._getRecordID(rowIndex); + if (recordId) { + var correspondingRow = self._getRow(recordId); + correspondingRow.children().eq(cellIndex).focus(); + } else if (self.currentGroupId) { + self.$('a[data-group-id="' + self.currentGroupId + '"]').focus(); + } else { + self.$('.o_field_x2many_list_row_add a:first').focus(); // FIXME + } + } + }); + break; + } + }, + /** + * Triggers a remove event. I don't know why we stop the propagation of the + * event. + * + * @param {MouseEvent} event + */ + _onRemoveIconClick: function (event) { + event.stopPropagation(); + var $row = $(event.target).closest('tr'); + var id = $row.data('id'); + if ($row.hasClass('o_selected_row')) { + this.trigger_up('list_record_remove', {id: id}); + } else { + var self = this; + this.unselectRow().then(function () { + self.trigger_up('list_record_remove', {id: id}); + }); + } + }, + /** + * React to window resize events by recomputing the width of each column. + * + * @private + */ + _onResize: function () { + this.columnWidths = false; + this._freezeColumnWidths(); + }, + /** + * If the list view editable, just let the event bubble. We don't want to + * open the record in this case anyway. + * + * @override + * @private + */ + _onRowClicked: function (ev) { + if (!this._isRecordEditable(ev.currentTarget.dataset.id)) { + // If there is an edited record, tries to save it and do not open the clicked record + if (this.getEditableRecordID()) { + this.unselectRow(); + } else { + this._super.apply(this, arguments); + } + } + }, + /** + * Overrides to prevent from sorting if we are currently editing a record. + * + * @override + * @private + */ + _onSortColumn: function () { + if (this.currentRow === null && !this.isResizing) { + this._super.apply(this, arguments); + } + }, + /** + * Handles the resize feature on the column headers + * + * @private + * @param {MouseEvent} ev + */ + _onStartResize: function (ev) { + // Only triggered by left mouse button + if (ev.which !== 1) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + + this.isResizing = true; + + const table = this.el.getElementsByClassName('o_list_table')[0]; + const th = ev.target.closest('th'); + table.style.width = `${table.offsetWidth}px`; + const thPosition = [...th.parentNode.children].indexOf(th); + const resizingColumnElements = [...table.getElementsByTagName('tr')] + .filter(tr => tr.children.length === th.parentNode.children.length) + .map(tr => tr.children[thPosition]); + const optionalDropdown = this.el.getElementsByClassName('o_optional_columns')[0]; + const initialX = ev.pageX; + const initialWidth = th.offsetWidth; + const initialTableWidth = table.offsetWidth; + const initialDropdownX = optionalDropdown ? optionalDropdown.offsetLeft : null; + const resizeStoppingEvents = [ + 'keydown', + 'mousedown', + 'mouseup', + ]; + + // Fix container width to prevent the table from overflowing when being resized + if (!this.el.style.width) { + this.el.style.width = `${this.el.offsetWidth}px`; + } + + // Apply classes to table and selected column + table.classList.add('o_resizing'); + resizingColumnElements.forEach(el => el.classList.add('o_column_resizing')); + + // Mousemove event : resize header + const resizeHeader = ev => { + ev.preventDefault(); + ev.stopPropagation(); + const delta = ev.pageX - initialX; + const newWidth = Math.max(10, initialWidth + delta); + const tableDelta = newWidth - initialWidth; + th.style.width = `${newWidth}px`; + th.style.maxWidth = `${newWidth}px`; + table.style.width = `${initialTableWidth + tableDelta}px`; + if (optionalDropdown) { + optionalDropdown.style.left = `${initialDropdownX + tableDelta}px`; + } + }; + this._addEventListener('mousemove', window, resizeHeader); + + // Mouse or keyboard events : stop resize + const stopResize = ev => { + // Ignores the initial 'left mouse button down' event in order + // to not instantly remove the listener + if (ev.type === 'mousedown' && ev.which === 1) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + // We need a small timeout to not trigger a click on column header + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + this.isResizing = false; + }, 100); + window.removeEventListener('mousemove', resizeHeader); + table.classList.remove('o_resizing'); + resizingColumnElements.forEach(el => el.classList.remove('o_column_resizing')); + resizeStoppingEvents.forEach(stoppingEvent => { + window.removeEventListener(stoppingEvent, stopResize); + }); + + // we remove the focus to make sure that the there is no focus inside + // the tr. If that is the case, there is some css to darken the whole + // thead, and it looks quite weird with the small css hover effect. + document.activeElement.blur(); + }; + // We have to listen to several events to properly stop the resizing function. Those are: + // - mousedown (e.g. pressing right click) + // - mouseup : logical flow of the resizing feature (drag & drop) + // - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key) + resizeStoppingEvents.forEach(stoppingEvent => { + this._addEventListener(stoppingEvent, window, stopResize); + }); + }, + /** + * Unselect the row before adding the optional column to the listview + * + * @override + * @private + */ + _onToggleOptionalColumnDropdown: function (ev) { + this.unselectRow().then(this._super.bind(this, ev)); + }, + /** + * When a click happens outside the list view, or outside a currently + * selected row, we want to unselect it. + * + * This is quite tricky, because in many cases, such as an autocomplete + * dropdown opened by a many2one in a list editable row, we actually don't + * want to unselect (and save) the current row. + * + * So, we try to ignore clicks on subelements of the renderer that are + * appended in the body, outside the table) + * + * @param {MouseEvent} event + */ + _onWindowClicked: function (event) { + // ignore clicks on readonly lists with no selected rows + if (!this.isEditable()) { + return; + } + + // ignore clicks if this renderer is not in the dom. + if (!document.contains(this.el)) { + return; + } + + // there is currently no selected row + if (this.currentRow === null) { + return; + } + + // ignore clicks in autocomplete dropdowns + if ($(event.target).closest('.ui-autocomplete').length) { + return; + } + + // ignore clicks if there is a modal, except if the list is in the last + // (active) modal + var $modal = $('body > .modal:last'); + if ($modal.length) { + var $listModal = this.$el.closest('.modal'); + if ($modal.prop('id') !== $listModal.prop('id')) { + return; + } + } + + // ignore clicks if target is no longer in dom. For example, a click on + // the 'delete' trash icon of a m2m tag. + if (!document.contains(event.target)) { + return; + } + + // ignore clicks if target is inside the list. In that case, they are + // handled directly by the renderer. + if (this.el.contains(event.target) && this.el !== event.target) { + return; + } + + // ignore click if search facet is removed as it will re-render whole + // listview again + if ($(event.target).hasClass('o_facet_remove')) { + return; + } + + this.unselectRow(); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/list/list_model.js b/addons/web/static/src/js/views/list/list_model.js new file mode 100644 index 00000000..b119e7da --- /dev/null +++ b/addons/web/static/src/js/views/list/list_model.js @@ -0,0 +1,175 @@ +odoo.define('web.ListModel', function (require) { + "use strict"; + + var BasicModel = require('web.BasicModel'); + + var ListModel = BasicModel.extend({ + + /** + * @override + * @param {Object} params.groupbys + */ + init: function (parent, params) { + this._super.apply(this, arguments); + + this.groupbys = params.groupbys; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * overridden to add `groupData` when performing get on list datapoints. + * + * @override + * @see _readGroupExtraFields + */ + __get: function () { + var result = this._super.apply(this, arguments); + var dp = result && this.localData[result.id]; + if (dp && dp.groupData) { + result.groupData = this.get(dp.groupData); + } + return result; + }, + /** + * For a list of records, performs a write with all changes and fetches + * all data. + * + * @param {string} listDatapointId id of the parent list + * @param {string} referenceRecordId the record datapoint used to + * generate the changes to apply to recordIds + * @param {string[]} recordIds a list of record datapoint ids + * @param {string} fieldName the field to write + */ + saveRecords: function (listDatapointId, referenceRecordId, recordIds, fieldName) { + var self = this; + var referenceRecord = this.localData[referenceRecordId]; + var list = this.localData[listDatapointId]; + // generate all record values to ensure that we'll write something + // (e.g. 2 records selected, edit a many2one in the first one, but + // reset same value, we still want to save this value on the other + // record) + var allChanges = this._generateChanges(referenceRecord, {changesOnly: false}); + var changes = _.pick(allChanges, fieldName); + var records = recordIds.map(function (recordId) { + return self.localData[recordId]; + }); + var model = records[0].model; + var recordResIds = _.pluck(records, 'res_id'); + var fieldNames = records[0].getFieldNames(); + var context = records[0].getContext(); + + return this._rpc({ + model: model, + method: 'write', + args: [recordResIds, changes], + context: context, + }).then(function () { + return self._rpc({ + model: model, + method: 'read', + args: [recordResIds, fieldNames], + context: context, + }); + }).then(function (results) { + results.forEach(function (data) { + var record = _.findWhere(records, {res_id: data.id}); + record.data = _.extend({}, record.data, data); + record._changes = {}; + record._isDirty = false; + self._parseServerData(fieldNames, record, record.data); + }); + }).then(function () { + if (!list.groupedBy.length) { + return Promise.all([ + self._fetchX2ManysBatched(list), + self._fetchReferencesBatched(list) + ]); + } else { + return Promise.all([ + self._fetchX2ManysSingleBatch(list), + self._fetchReferencesSingleBatch(list) + ]); + } + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * + * @override + * @private + */ + _readGroup: function (list, options) { + var self = this; + options = options || {}; + options.fetchRecordsWithGroups = true; + return this._super(list, options).then(function (result) { + return self._readGroupExtraFields(list).then(_.constant(result)); + }); + }, + /** + * Fetches group specific fields on the group by relation and stores it + * in the column datapoint in a special key `groupData`. + * Data for the groups are fetched in batch for all groups, to avoid + * doing multiple calls. + * Note that the option is only for m2o fields. + * + * @private + * @param {Object} list + * @returns {Promise} + */ + _readGroupExtraFields: function (list) { + var self = this; + var groupByFieldName = list.groupedBy[0].split(':')[0]; + var groupedByField = list.fields[groupByFieldName]; + if (groupedByField.type !== 'many2one' || !this.groupbys[groupByFieldName]) { + return Promise.resolve(); + } + var groupIds = _.reduce(list.data, function (groupIds, id) { + var resId = self.get(id, { raw: true }).res_id; + if (resId) { // the field might be undefined when grouping + groupIds.push(resId); + } + return groupIds; + }, []); + var groupFields = Object.keys(this.groupbys[groupByFieldName].viewFields); + var prom; + if (groupIds.length && groupFields.length) { + prom = this._rpc({ + model: groupedByField.relation, + method: 'read', + args: [groupIds, groupFields], + context: list.context, + }); + } + return Promise.resolve(prom).then(function (result) { + var fvg = self.groupbys[groupByFieldName]; + _.each(list.data, function (id) { + var dp = self.localData[id]; + var groupData = result && _.findWhere(result, { + id: dp.res_id, + }); + var groupDp = self._makeDataPoint({ + context: dp.context, + data: groupData, + fields: fvg.fields, + fieldsInfo: fvg.fieldsInfo, + modelName: groupedByField.relation, + parentID: dp.id, + res_id: dp.res_id, + viewType: 'groupby', + }); + dp.groupData = groupDp.id; + self._parseServerData(groupFields, groupDp, groupDp.data); + }); + }); + }, + }); + return ListModel; +}); diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js new file mode 100644 index 00000000..4e24ce54 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -0,0 +1,1470 @@ +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'); +var field_utils = require('web.field_utils'); +var Pager = require('web.Pager'); +var utils = require('web.utils'); +var viewUtils = require('web.viewUtils'); + +var _t = core._t; + +// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes +var DECORATIONS = [ + 'decoration-bf', + 'decoration-it', + 'decoration-danger', + 'decoration-info', + 'decoration-muted', + 'decoration-primary', + 'decoration-success', + 'decoration-warning' +]; + +var FIELD_CLASSES = { + char: 'o_list_char', + float: 'o_list_number', + integer: 'o_list_number', + monetary: 'o_list_number', + text: 'o_list_text', + many2one: 'o_list_many2one', +}; + +var ListRenderer = BasicRenderer.extend({ + className: 'o_list_view', + events: { + "mousedown": "_onMouseDown", + "click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn", + "click .o_optional_columns_dropdown_toggle": "_onToggleOptionalColumnDropdown", + 'click tbody tr': '_onRowClicked', + 'change tbody .o_list_record_selector': '_onSelectRecord', + 'click thead th.o_column_sortable': '_onSortColumn', + 'click .o_list_record_selector': '_onToggleCheckbox', + 'click .o_group_header': '_onToggleGroup', + 'change thead .o_list_record_selector input': '_onToggleSelection', + 'keypress thead tr td': '_onKeyPress', + 'keydown td': '_onKeyDown', + 'keydown th': '_onKeyDown', + }, + sampleDataTargets: [ + '.o_data_row', + '.o_group_header', + '.o_list_table > tfoot', + '.o_list_table > thead .o_list_record_selector', + ], + /** + * @constructor + * @param {Widget} parent + * @param {any} state + * @param {Object} params + * @param {boolean} params.hasSelectors + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this._preprocessColumns(); + this.columnInvisibleFields = params.columnInvisibleFields || {}; + this.rowDecorations = this._extractDecorationAttrs(this.arch); + this.fieldDecorations = {}; + for (const field of this.arch.children.filter(c => c.tag === 'field')) { + const decorations = this._extractDecorationAttrs(field); + this.fieldDecorations[field.attrs.name] = decorations; + } + this.hasSelectors = params.hasSelectors; + this.selection = params.selectedRecords || []; + this.pagers = []; // instantiated pagers (only for grouped lists) + this.isGrouped = this.state.groupedBy.length > 0; + this.groupbys = params.groupbys; + }, + /** + * Compute columns visilibity. This can't be done earlier as we need the + * controller to respond to the load_optional_fields call of processColumns. + * + * @override + */ + willStart: function () { + this._processColumns(this.columnInvisibleFields); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Order to focus to be given to the content of the current view + * + * @override + */ + giveFocus: function () { + this.$('th:eq(0) input, th:eq(1)').first().focus(); + }, + /** + * @override + */ + updateState: function (state, params) { + this._setState(state); + this.isGrouped = this.state.groupedBy.length > 0; + this.columnInvisibleFields = params.columnInvisibleFields || {}; + this._processColumns(this.columnInvisibleFields); + if (params.selectedRecords) { + this.selection = params.selectedRecords; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method does a in-memory computation of the aggregate values, for + * each columns that corresponds to a numeric field with a proper aggregate + * function. + * + * The result of these computations is stored in the 'aggregate' key of each + * column of this.columns. This will be then used by the _renderFooter + * method to display the appropriate amount. + * + * @private + */ + _computeAggregates: function () { + var self = this; + var data = []; + if (this.selection.length) { + utils.traverse_records(this.state, function (record) { + if (_.contains(self.selection, record.id)) { + data.push(record); // find selected records + } + }); + } else { + data = this.state.data; + } + + _.each(this.columns, this._computeColumnAggregates.bind(this, data)); + }, + /** + * Compute the aggregate values for a given column and a set of records. + * The aggregate values are then written, if applicable, in the 'aggregate' + * key of the column object. + * + * @private + * @param {Object[]} data a list of selected/all records + * @param {Object} column + */ + _computeColumnAggregates: function (data, column) { + var attrs = column.attrs; + var field = this.state.fields[attrs.name]; + if (!field) { + return; + } + var type = field.type; + if (type !== 'integer' && type !== 'float' && type !== 'monetary') { + return; + } + var func = (attrs.sum && 'sum') || (attrs.avg && 'avg') || + (attrs.max && 'max') || (attrs.min && 'min'); + if (func) { + var count = 0; + var aggregateValue = 0; + if (func === 'max') { + aggregateValue = -Infinity; + } else if (func === 'min') { + aggregateValue = Infinity; + } + _.each(data, function (d) { + count += 1; + var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name]; + if (func === 'avg') { + aggregateValue += value; + } else if (func === 'sum') { + aggregateValue += value; + } else if (func === 'max') { + aggregateValue = Math.max(aggregateValue, value); + } else if (func === 'min') { + aggregateValue = Math.min(aggregateValue, value); + } + }); + if (func === 'avg') { + aggregateValue = count ? aggregateValue / count : aggregateValue; + } + column.aggregate = { + help: attrs[func], + value: aggregateValue, + }; + } + }, + /** + * Extract the decoration attributes (e.g. decoration-danger) of a node. The + * condition is processed such that it is ready to be evaluated. + * + * @private + * @param {Object} node the <tree> or a <field> node + * @returns {Object} + */ + _extractDecorationAttrs: function (node) { + const decorations = {}; + for (const [key, expr] of Object.entries(node.attrs)) { + if (DECORATIONS.includes(key)) { + const cssClass = key.replace('decoration', 'text'); + decorations[cssClass] = py.parse(py.tokenize(expr)); + } + } + return decorations; + }, + /** + * + * @private + * @param {jQuery} $cell + * @param {string} direction + * @param {integer} colIndex + * @returns {jQuery|null} + */ + _findConnectedCell: function ($cell, direction, colIndex) { + var $connectedRow = $cell.closest('tr')[direction]('tr'); + + if (!$connectedRow.length) { + // Is there another group ? Look at our parent's sibling + // We can have th in tbody so we can't simply look for thead + // if cell is a th and tbody instead + var tbody = $cell.closest('tbody, thead'); + var $connectedGroup = tbody[direction]('tbody, thead'); + if ($connectedGroup.length) { + // Found another group + var $connectedRows = $connectedGroup.find('tr'); + var rowIndex; + if (direction === 'prev') { + rowIndex = $connectedRows.length - 1; + } else { + rowIndex = 0; + } + $connectedRow = $connectedRows.eq(rowIndex); + } else { + // End of the table + return; + } + } + + var $connectedCell; + if ($connectedRow.hasClass('o_group_header')) { + $connectedCell = $connectedRow.children(); + this.currentColIndex = colIndex; + } else if ($connectedRow.has('td.o_group_field_row_add').length) { + $connectedCell = $connectedRow.find('.o_group_field_row_add'); + this.currentColIndex = colIndex; + } else { + var connectedRowChildren = $connectedRow.children(); + if (colIndex === -1) { + colIndex = connectedRowChildren.length - 1; + } + $connectedCell = connectedRowChildren.eq(colIndex); + } + + return $connectedCell; + }, + /** + * return the number of visible columns. Note that this number depends on + * the state of the renderer. For example, in editable mode, it could be + * one more that in non editable mode, because there may be a visible 'trash + * icon'. + * + * @private + * @returns {integer} + */ + _getNumberOfCols: function () { + var n = this.columns.length; + return this.hasSelectors ? n + 1 : n; + }, + /** + * Returns the local storage key for stored enabled optional columns + * + * @private + * @returns {string} + */ + _getOptionalColumnsStorageKeyParts: function () { + var self = this; + return { + fields: _.map(this.state.fieldsInfo[this.viewType], function (_, fieldName) { + return {name: fieldName, type: self.state.fields[fieldName].type}; + }), + }; + }, + /** + * Adjacent buttons (in the arch) are displayed in a single column. This + * function iterates over the arch's nodes and replaces "button" nodes by + * "button_group" nodes, with a single "button_group" node for adjacent + * "button" nodes. A "button_group" node has a "children" attribute + * containing all "button" nodes in the group. + * + * @private + */ + _groupAdjacentButtons: function () { + const children = []; + let groupId = 0; + let buttonGroupNode = null; + for (const c of this.arch.children) { + if (c.tag === 'button') { + if (!buttonGroupNode) { + buttonGroupNode = { + tag: 'button_group', + children: [c], + attrs: { + name: `button_group_${groupId++}`, + modifiers: {}, + }, + }; + children.push(buttonGroupNode); + } else { + buttonGroupNode.children.push(c); + } + } else { + buttonGroupNode = null; + children.push(c); + } + } + this.arch.children = children; + }, + /** + * Processes arch's child nodes for the needs of the list view: + * - detects oe_read_only/oe_edit_only classnames + * - groups adjacent buttons in a single column. + * This function is executed only once, at initialization. + * + * @private + */ + _preprocessColumns: function () { + this._processModeClassNames(); + this._groupAdjacentButtons(); + + // set as readOnly (resp. editOnly) button groups containing only + // readOnly (resp. editOnly) buttons, s.t. no column is rendered + this.arch.children.filter(c => c.tag === 'button_group').forEach(c => { + c.attrs.editOnly = c.children.every(n => n.attrs.editOnly); + c.attrs.readOnly = c.children.every(n => n.attrs.readOnly); + }); + }, + /** + * Removes the columns which should be invisible. This function is executed + * at each (re-)rendering of the list. + * + * @param {Object} columnInvisibleFields contains the column invisible modifier values + */ + _processColumns: function (columnInvisibleFields) { + var self = this; + this.handleField = null; + this.columns = []; + this.optionalColumns = []; + this.optionalColumnsEnabled = []; + var storedOptionalColumns; + this.trigger_up('load_optional_fields', { + keyParts: this._getOptionalColumnsStorageKeyParts(), + callback: function (res) { + storedOptionalColumns = res; + }, + }); + _.each(this.arch.children, function (c) { + if (c.tag !== 'control' && c.tag !== 'groupby' && c.tag !== 'header') { + var reject = c.attrs.modifiers.column_invisible; + // If there is an evaluated domain for the field we override the node + // attribute to have the evaluated modifier value. + if (c.tag === "button_group") { + // FIXME: 'column_invisible' attribute is available for fields *and* buttons, + // so 'columnInvisibleFields' variable name is misleading, it should be renamed + reject = c.children.every(child => columnInvisibleFields[child.attrs.name]); + } else if (c.attrs.name in columnInvisibleFields) { + reject = columnInvisibleFields[c.attrs.name]; + } + if (!reject && c.attrs.widget === 'handle') { + self.handleField = c.attrs.name; + if (self.isGrouped) { + reject = true; + } + } + + if (!reject && c.attrs.optional) { + self.optionalColumns.push(c); + var enabled; + if (storedOptionalColumns === undefined) { + enabled = c.attrs.optional === 'show'; + } else { + enabled = _.contains(storedOptionalColumns, c.attrs.name); + } + if (enabled) { + self.optionalColumnsEnabled.push(c.attrs.name); + } + reject = !enabled; + } + + if (!reject) { + self.columns.push(c); + } + } + }); + }, + /** + * Classnames "oe_edit_only" and "oe_read_only" aim to only display the cell + * in the corresponding mode. This only concerns lists inside form views + * (for x2many fields). This function detects the className and stores a + * flag on the node's attrs accordingly, to ease further computations. + * + * @private + */ + _processModeClassNames: function () { + this.arch.children.forEach(c => { + if (c.attrs.class) { + c.attrs.editOnly = /\boe_edit_only\b/.test(c.attrs.class); + c.attrs.readOnly = /\boe_read_only\b/.test(c.attrs.class); + } + }); + }, + /** + * Render a list of <td>, with aggregates if available. It can be displayed + * in the footer, or for each open groups. + * + * @private + * @param {any} aggregateValues + * @returns {jQueryElement[]} a list of <td> with the aggregate values + */ + _renderAggregateCells: function (aggregateValues) { + var self = this; + + return _.map(this.columns, function (column) { + var $cell = $('<td>'); + if (config.isDebug()) { + $cell.addClass(column.attrs.name); + } + if (column.attrs.editOnly) { + $cell.addClass('oe_edit_only'); + } + if (column.attrs.readOnly) { + $cell.addClass('oe_read_only'); + } + if (column.attrs.name in aggregateValues) { + var field = self.state.fields[column.attrs.name]; + var value = aggregateValues[column.attrs.name].value; + var help = aggregateValues[column.attrs.name].help; + var formatFunc = field_utils.format[column.attrs.widget]; + if (!formatFunc) { + formatFunc = field_utils.format[field.type]; + } + var formattedValue = formatFunc(value, field, { + escape: true, + digits: column.attrs.digits ? JSON.parse(column.attrs.digits) : undefined, + }); + $cell.addClass('o_list_number').attr('title', help).html(formattedValue); + } + return $cell; + }); + }, + /** + * Render the main body of the table, with all its content. Note that it + * has been decided to always render at least 4 rows, even if we have less + * data. The reason is that lists with 0 or 1 lines don't really look like + * a table. + * + * @private + * @returns {jQueryElement} a jquery element <tbody> + */ + _renderBody: function () { + var self = this; + var $rows = this._renderRows(); + while ($rows.length < 4) { + $rows.push(self._renderEmptyRow()); + } + return $('<tbody>').append($rows); + }, + /** + * Render a cell for the table. For most cells, we only want to display the + * formatted value, with some appropriate css class. However, when the + * node was explicitely defined with a 'widget' attribute, then we + * instantiate the corresponding widget. + * + * @private + * @param {Object} record + * @param {Object} node + * @param {integer} colIndex + * @param {Object} [options] + * @param {Object} [options.mode] + * @param {Object} [options.renderInvisible=false] + * force the rendering of invisible cell content + * @param {Object} [options.renderWidgets=false] + * force the rendering of the cell value thanks to a widget + * @returns {jQueryElement} a <td> element + */ + _renderBodyCell: function (record, node, colIndex, options) { + var tdClassName = 'o_data_cell'; + if (node.tag === 'button_group') { + tdClassName += ' o_list_button'; + } else if (node.tag === 'field') { + tdClassName += ' o_field_cell'; + var typeClass = FIELD_CLASSES[this.state.fields[node.attrs.name].type]; + if (typeClass) { + tdClassName += (' ' + typeClass); + } + if (node.attrs.widget) { + tdClassName += (' o_' + node.attrs.widget + '_cell'); + } + } + if (node.attrs.editOnly) { + tdClassName += ' oe_edit_only'; + } + if (node.attrs.readOnly) { + tdClassName += ' oe_read_only'; + } + var $td = $('<td>', { class: tdClassName, tabindex: -1 }); + + // We register modifiers on the <td> element so that it gets the correct + // modifiers classes (for styling) + var modifiers = this._registerModifiers(node, record, $td, _.pick(options, 'mode')); + // If the invisible modifiers is true, the <td> element is left empty. + // Indeed, if the modifiers was to change the whole cell would be + // rerendered anyway. + if (modifiers.invisible && !(options && options.renderInvisible)) { + return $td; + } + + if (node.tag === 'button_group') { + for (const buttonNode of node.children) { + if (!this.columnInvisibleFields[buttonNode.attrs.name]) { + $td.append(this._renderButton(record, buttonNode)); + } + } + return $td; + } else if (node.tag === 'widget') { + return $td.append(this._renderWidget(record, node)); + } + if (node.attrs.widget || (options && options.renderWidgets)) { + var $el = this._renderFieldWidget(node, record, _.pick(options, 'mode')); + return $td.append($el); + } + this._handleAttributes($td, node); + this._setDecorationClasses($td, this.fieldDecorations[node.attrs.name], record); + + var name = node.attrs.name; + var field = this.state.fields[name]; + var value = record.data[name]; + var formatter = field_utils.format[field.type]; + var formatOptions = { + escape: true, + data: record.data, + isPassword: 'password' in node.attrs, + digits: node.attrs.digits && JSON.parse(node.attrs.digits), + }; + var formattedValue = formatter(value, field, formatOptions); + var title = ''; + if (field.type !== 'boolean') { + title = formatter(value, field, _.extend(formatOptions, {escape: false})); + } + return $td.html(formattedValue).attr('title', title); + }, + /** + * Renders the button element associated to the given node and record. + * + * @private + * @param {Object} record + * @param {Object} node + * @returns {jQuery} a <button> element + */ + _renderButton: function (record, node) { + var self = this; + var nodeWithoutWidth = Object.assign({}, node); + delete nodeWithoutWidth.attrs.width; + + let extraClass = ''; + if (node.attrs.icon) { + // if there is an icon, we force the btn-link style, unless a btn-xxx + // style class is explicitely provided + const btnStyleRegex = /\bbtn-[a-z]+\b/; + if (!btnStyleRegex.test(nodeWithoutWidth.attrs.class)) { + extraClass = 'btn-link o_icon_button'; + } + } + var $button = viewUtils.renderButtonFromNode(nodeWithoutWidth, { + extraClass: extraClass, + }); + this._handleAttributes($button, node); + this._registerModifiers(node, record, $button); + + if (record.res_id) { + // TODO this should be moved to a handler + $button.on("click", function (e) { + e.stopPropagation(); + self.trigger_up('button_clicked', { + attrs: node.attrs, + record: record, + }); + }); + } else { + if (node.attrs.options.warn) { + $button.on("click", function (e) { + e.stopPropagation(); + self.do_warn(false, _t('Please click on the "save" button first')); + }); + } else { + $button.prop('disabled', true); + } + } + + return $button; + }, + /** + * Render a complete empty row. This is used to fill in the blanks when we + * have less than 4 lines to display. + * + * @private + * @returns {jQueryElement} a <tr> element + */ + _renderEmptyRow: function () { + var $td = $('<td> </td>').attr('colspan', this._getNumberOfCols()); + return $('<tr>').append($td); + }, + /** + * Render the footer. It is a <tfoot> with a single row, containing all + * aggregates, if applicable. + * + * @private + * @returns {jQueryElement} a <tfoot> element + */ + _renderFooter: function () { + var aggregates = {}; + _.each(this.columns, function (column) { + if ('aggregate' in column) { + aggregates[column.attrs.name] = column.aggregate; + } + }); + var $cells = this._renderAggregateCells(aggregates); + if (this.hasSelectors) { + $cells.unshift($('<td>')); + } + return $('<tfoot>').append($('<tr>').append($cells)); + }, + /** + * Renders the group button element. + * + * @private + * @param {Object} record + * @param {Object} group + * @returns {jQuery} a <button> element + */ + _renderGroupButton: function (list, node) { + var $button = viewUtils.renderButtonFromNode(node, { + extraClass: node.attrs.icon ? 'o_icon_button' : undefined, + textAsTitle: !!node.attrs.icon, + }); + this._handleAttributes($button, node); + this._registerModifiers(node, list.groupData, $button); + + // TODO this should be moved to event handlers + $button.on("click", this._onGroupButtonClicked.bind(this, list.groupData, node)); + $button.on("keydown", this._onGroupButtonKeydown.bind(this)); + + return $button; + }, + /** + * Renders the group buttons. + * + * @private + * @param {Object} record + * @param {Object} group + * @returns {jQuery} a <button> element + */ + _renderGroupButtons: function (list, group) { + var self = this; + var $buttons = $(); + if (list.value) { + // buttons make no sense for 'Undefined' group + group.arch.children.forEach(function (child) { + if (child.tag === 'button') { + $buttons = $buttons.add(self._renderGroupButton(list, child)); + } + }); + } + return $buttons; + }, + /** + * Render the row that represent a group + * + * @private + * @param {Object} group + * @param {integer} groupLevel the nesting level (0 for root groups) + * @returns {jQueryElement} a <tr> element + */ + _renderGroupRow: function (group, groupLevel) { + var cells = []; + + var name = group.value === undefined ? _t('Undefined') : group.value; + var groupBy = this.state.groupedBy[groupLevel]; + if (group.fields[groupBy.split(':')[0]].type !== 'boolean') { + name = name || _t('Undefined'); + } + var $th = $('<th>') + .addClass('o_group_name') + .attr('tabindex', -1) + .text(name + ' (' + group.count + ')'); + var $arrow = $('<span>') + .css('padding-left', 2 + (groupLevel * 20) + 'px') + .css('padding-right', '5px') + .addClass('fa'); + if (group.count > 0) { + $arrow.toggleClass('fa-caret-right', !group.isOpen) + .toggleClass('fa-caret-down', group.isOpen); + } + $th.prepend($arrow); + cells.push($th); + + var aggregateKeys = Object.keys(group.aggregateValues); + var aggregateValues = _.mapObject(group.aggregateValues, function (value) { + return { value: value }; + }); + var aggregateCells = this._renderAggregateCells(aggregateValues); + var firstAggregateIndex = _.findIndex(this.columns, function (column) { + return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name); + }); + var colspanBeforeAggregate; + if (firstAggregateIndex !== -1) { + // if there are aggregates, the first $th goes until the first + // aggregate then all cells between aggregates are rendered + colspanBeforeAggregate = firstAggregateIndex; + var lastAggregateIndex = _.findLastIndex(this.columns, function (column) { + return column.tag === 'field' && _.contains(aggregateKeys, column.attrs.name); + }); + cells = cells.concat(aggregateCells.slice(firstAggregateIndex, lastAggregateIndex + 1)); + var colSpan = this.columns.length - 1 - lastAggregateIndex; + if (colSpan > 0) { + cells.push($('<th>').attr('colspan', colSpan)); + } + } else { + var colN = this.columns.length; + colspanBeforeAggregate = colN > 1 ? colN - 1 : 1; + if (colN > 1) { + cells.push($('<th>')); + } + } + if (this.hasSelectors) { + colspanBeforeAggregate += 1; + } + $th.attr('colspan', colspanBeforeAggregate); + + if (group.isOpen && !group.groupedBy.length && (group.count > group.data.length)) { + 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]); + if ($buttons.length) { + var $buttonSection = $('<div>', { + class: 'o_group_buttons', + }).append($buttons); + $th.append($buttonSection); + } + } + return $('<tr>') + .addClass('o_group_header') + .toggleClass('o_group_open', group.isOpen) + .toggleClass('o_group_has_content', group.count > 0) + .data('group', group) + .append(cells); + }, + /** + * Render the content of a given opened group. + * + * @private + * @param {Object} group + * @param {integer} groupLevel the nesting level (0 for root groups) + * @returns {jQueryElement} a <tr> element + */ + _renderGroup: function (group, groupLevel) { + var self = this; + if (group.groupedBy.length) { + // the opened group contains subgroups + return this._renderGroups(group.data, groupLevel + 1); + } else { + // the opened group contains records + var $records = _.map(group.data, function (record) { + return self._renderRow(record); + }); + return [$('<tbody>').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. + * + * Note that each group is rendered inside a <tbody>, which contains a group + * row, then possibly a bunch of rows for each record. + * + * @private + * @param {Object} data the dataPoint containing the groups + * @param {integer} [groupLevel=0] the nesting level. 0 is for the root group + * @returns {jQueryElement[]} a list of <tbody> + */ + _renderGroups: function (data, groupLevel) { + var self = this; + groupLevel = groupLevel || 0; + var result = []; + var $tbody = $('<tbody>'); + _.each(data, function (group) { + if (!$tbody) { + $tbody = $('<tbody>'); + } + $tbody.append(self._renderGroupRow(group, groupLevel)); + if (group.data.length) { + result.push($tbody); + result = result.concat(self._renderGroup(group, groupLevel)); + $tbody = null; + } + }); + if ($tbody) { + result.push($tbody); + } + return result; + }, + /** + * Render the main header for the list view. It is basically just a <thead> + * with the name of each fields + * + * @private + * @returns {jQueryElement} a <thead> element + */ + _renderHeader: function () { + var $tr = $('<tr>') + .append(_.map(this.columns, this._renderHeaderCell.bind(this))); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('th')); + } + return $('<thead>').append($tr); + }, + /** + * Render a single <th> with the informations for a column. If it is not a + * field or nolabel attribute is set to "1", the th will be empty. + * Otherwise, it will contains all relevant information for the field. + * + * @private + * @param {Object} node + * @returns {jQueryElement} a <th> element + */ + _renderHeaderCell: function (node) { + const { icon, name, string } = node.attrs; + var order = this.state.orderedBy; + var isNodeSorted = order[0] && order[0].name === name; + var field = this.state.fields[name]; + var $th = $('<th>'); + if (name) { + $th.attr('data-name', name); + } else if (string) { + $th.attr('data-string', string); + } else if (icon) { + $th.attr('data-icon', icon); + } + if (node.attrs.editOnly) { + $th.addClass('oe_edit_only'); + } + if (node.attrs.readOnly) { + $th.addClass('oe_read_only'); + } + if (node.tag === 'button_group') { + $th.addClass('o_list_button'); + } + if (!field || node.attrs.nolabel === '1') { + return $th; + } + var description = string || field.string; + if (node.attrs.widget) { + $th.addClass(' o_' + node.attrs.widget + '_cell'); + const FieldWidget = this.state.fieldsInfo.list[name].Widget; + if (FieldWidget.prototype.noLabel) { + description = ''; + } else if (FieldWidget.prototype.label) { + description = FieldWidget.prototype.label; + } + } + $th.text(description) + .attr('tabindex', -1) + .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false) + .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false) + .addClass((field.sortable || this.state.fieldsInfo.list[name].options.allow_order || false) && 'o_column_sortable'); + + if (isNodeSorted) { + $th.attr('aria-sort', order[0].asc ? 'ascending' : 'descending'); + } + + if (field.type === 'float' || field.type === 'integer' || field.type === 'monetary') { + $th.addClass('o_list_number_th'); + } + + if (config.isDebug()) { + var fieldDescr = { + field: field, + name: name, + string: description || name, + record: this.state, + attrs: _.extend({}, node.attrs, this.state.fieldsInfo.list[name]), + }; + this._addFieldTooltip(fieldDescr, $th); + } else { + $th.attr('title', description); + } + return $th; + }, + /** + * Render a row, corresponding to a record. + * + * @private + * @param {Object} record + * @returns {jQueryElement} a <tr> element + */ + _renderRow: function (record) { + var self = this; + var $cells = this.columns.map(function (node, index) { + return self._renderBodyCell(record, node, index, { mode: 'readonly' }); + }); + + var $tr = $('<tr/>', { class: 'o_data_row' }) + .attr('data-id', record.id) + .append($cells); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('td', !record.res_id)); + } + this._setDecorationClasses($tr, this.rowDecorations, record); + return $tr; + }, + /** + * Render all rows. This method should only called when the view is not + * grouped. + * + * @private + * @returns {jQueryElement[]} a list of <tr> + */ + _renderRows: function () { + return this.state.data.map(this._renderRow.bind(this)); + }, + /** + * Render a single <th> with dropdown menu to display optional columns of view. + * + * @private + * @returns {jQueryElement} a <th> element + */ + _renderOptionalColumnsDropdown: function () { + var self = this; + var $optionalColumnsDropdown = $('<div>', { + class: 'o_optional_columns text-center dropdown', + }); + var $a = $("<a>", { + 'class': "dropdown-toggle text-dark o-no-caret", + 'href': "#", + 'role': "button", + 'data-toggle': "dropdown", + 'data-display': "static", + 'aria-expanded': false, + 'aria-label': _t('Optional columns'), + }); + $a.appendTo($optionalColumnsDropdown); + + // Set the expansion direction of the dropdown + // The button is located at the end of the list headers + // We want the dropdown to expand towards the list rather than away from it + // https://getbootstrap.com/docs/4.0/components/dropdowns/#menu-alignment + var direction = _t.database.parameters.direction; + var dropdownMenuClass = direction === 'rtl' ? 'dropdown-menu-left' : 'dropdown-menu-right'; + var $dropdown = $("<div>", { + class: 'dropdown-menu o_optional_columns_dropdown ' + dropdownMenuClass, + role: 'menu', + }); + this.optionalColumns.forEach(function (col) { + var txt = (col.attrs.string || self.state.fields[col.attrs.name].string) + + (config.isDebug() ? (' (' + col.attrs.name + ')') : ''); + var $checkbox = dom.renderCheckbox({ + text: txt, + role: "menuitemcheckbox", + prop: { + name: col.attrs.name, + checked: _.contains(self.optionalColumnsEnabled, col.attrs.name), + } + }); + $dropdown.append($("<div>", { + class: "dropdown-item", + }).append($checkbox)); + }); + $dropdown.appendTo($optionalColumnsDropdown); + return $optionalColumnsDropdown; + }, + /** + * A 'selector' is the small checkbox on the left of a record in a list + * view. This is rendered as an input inside a div, so we can properly + * style it. + * + * Note that it takes a tag in argument, because selectors in the header + * are renderd in a th, and those in the tbody are in a td. + * + * @private + * @param {string} tag either th or td + * @param {boolean} disableInput if true, the input generated will be disabled + * @returns {jQueryElement} + */ + _renderSelector: function (tag, disableInput) { + var $content = dom.renderCheckbox(); + if (disableInput) { + $content.find("input[type='checkbox']").prop('disabled', disableInput); + } + return $('<' + tag + '>') + .addClass('o_list_record_selector') + .append($content); + }, + /** + * Main render function for the list. It is rendered as a table. For now, + * this method does not wait for the field widgets to be ready. + * + * @override + * @returns {Promise} resolved when the view has been rendered + */ + async _renderView() { + const oldPagers = this.pagers; + let prom; + let tableWrapper; + if (this.state.count > 0 || !this.noContentHelp) { + // render a table if there are records, or if there is no no content + // helper (empty table in this case) + this.pagers = []; + + const orderedBy = this.state.orderedBy; + this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField; + this._computeAggregates(); + + const $table = $( + '<table class="o_list_table table table-sm table-hover table-striped"/>' + ); + $table.toggleClass('o_list_table_grouped', this.isGrouped); + $table.toggleClass('o_list_table_ungrouped', !this.isGrouped); + const defs = []; + this.defs = defs; + if (this.isGrouped) { + $table.append(this._renderHeader()); + $table.append(this._renderGroups(this.state.data)); + $table.append(this._renderFooter()); + + } else { + $table.append(this._renderHeader()); + $table.append(this._renderBody()); + $table.append(this._renderFooter()); + } + tableWrapper = Object.assign(document.createElement('div'), { + className: 'table-responsive', + }); + tableWrapper.appendChild($table[0]); + delete this.defs; + prom = Promise.all(defs); + } + + await Promise.all([this._super.apply(this, arguments), prom]); + + this.el.innerHTML = ""; + this.el.classList.remove('o_list_optional_columns'); + + // destroy the previously instantiated pagers, if any + oldPagers.forEach(pager => pager.destroy()); + + // append the table (if any) to the main element + if (tableWrapper) { + this.el.appendChild(tableWrapper); + if (document.body.contains(this.el)) { + this.pagers.forEach(pager => pager.on_attach_callback()); + } + if (this.optionalColumns.length) { + this.el.classList.add('o_list_optional_columns'); + this.$('table').append( + $('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>') + ); + this.$el.append(this._renderOptionalColumnsDropdown()); + } + if (this.selection.length) { + const $checked_rows = this.$('tr').filter( + (i, el) => this.selection.includes(el.dataset.id) + ); + $checked_rows.find('.o_list_record_selector input').prop('checked', true); + if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked + this.$('thead .o_list_record_selector input').prop('checked', true); + } + } + } + + // display the no content helper if necessary + if (!this._hasContent() && !!this.noContentHelp) { + this._renderNoContentHelper(); + } + }, + /** + * Each line or cell can be decorated according to a few simple rules. The + * arch description of the list or the field nodes may have one of the + * decoration-X attributes with a python expression as value. Then, for each + * record, we evaluate the python expression, and conditionnaly add the + * text-X css class to the element. This method is concerned with the + * computation of the list of css classes for a given record. + * + * @private + * @param {jQueryElement} $el the element to which to add the classes (a tr + * or td) + * @param {Object} decorations keys are the decoration classes (e.g. + * 'text-bf') and values are the python expressions to evaluate + * @param {Object} record a basic model record + */ + _setDecorationClasses: function ($el, decorations, record) { + for (const [cssClass, expr] of Object.entries(decorations)) { + $el.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 + * in sync. + * + * @private + */ + _updateFooter: function () { + this._computeAggregates(); + this.$('tfoot').replaceWith(this._renderFooter()); + }, + /** + * Whenever we change the state of the selected rows, we need to call this + * method to keep the this.selection variable in sync, and also to recompute + * the aggregates. + * + * @private + */ + _updateSelection: function () { + const previousSelection = JSON.stringify(this.selection); + this.selection = []; + var self = this; + var $inputs = this.$('tbody .o_list_record_selector input:visible:not(:disabled)'); + var allChecked = $inputs.length > 0; + $inputs.each(function (index, input) { + if (input.checked) { + self.selection.push($(input).closest('tr').data('id')); + } else { + allChecked = false; + } + }); + this.$('thead .o_list_record_selector input').prop('checked', allChecked); + if (JSON.stringify(this.selection) !== previousSelection) { + this.trigger_up('selection_changed', { allChecked, selection: this.selection }); + } + this._updateFooter(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} record a record dataPoint on which the button applies + * @param {Object} node arch node of the button + * @param {Object} node.attrs the attrs of the button in the arch + * @param {jQueryEvent} ev + */ + _onGroupButtonClicked: function (record, node, ev) { + ev.stopPropagation(); + if (node.attrs.type === 'edit') { + this.trigger_up('group_edit_button_clicked', { + record: record, + }); + } else { + this.trigger_up('button_clicked', { + attrs: node.attrs, + record: record, + }); + } + }, + /** + * If the user presses ENTER on a group header button, we want to execute + * the button action. This is done automatically as the click handler is + * called. However, we have to stop the propagation of the event to prevent + * another handler from closing the group (see _onKeyDown). + * + * @private + * @param {jQueryEvent} ev + */ + _onGroupButtonKeydown: function (ev) { + if (ev.keyCode === $.ui.keyCode.ENTER) { + ev.stopPropagation(); + } + }, + /** + * When the user clicks on the checkbox in optional fields dropdown the + * column is added to listview and displayed + * + * @private + * @param {MouseEvent} ev + */ + _onToggleOptionalColumn: function (ev) { + var self = this; + ev.stopPropagation(); + // when the input's label is clicked, the click event is also raised on the + // input itself (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/label), + // so this handler is executed twice (except if the rendering is quick enough, + // as when we render, we empty the HTML) + ev.preventDefault(); + var input = ev.currentTarget.querySelector('input'); + var fieldIndex = this.optionalColumnsEnabled.indexOf(input.name); + if (fieldIndex >= 0) { + this.optionalColumnsEnabled.splice(fieldIndex, 1); + } else { + this.optionalColumnsEnabled.push(input.name); + } + this.trigger_up('save_optional_fields', { + keyParts: this._getOptionalColumnsStorageKeyParts(), + optionalColumnsEnabled: this.optionalColumnsEnabled, + }); + this._processColumns(this.columnInvisibleFields); + this._render().then(function () { + self._onToggleOptionalColumnDropdown(ev); + }); + }, + /** + * When the user clicks on the three dots (ellipsis), toggle the optional + * fields dropdown. + * + * @private + */ + _onToggleOptionalColumnDropdown: function (ev) { + // The dropdown toggle is inside the overflow hidden container because + // the ellipsis is always in the last column, but we want the actual + // dropdown to be outside of the overflow hidden container since it + // could easily have a higher height than the table. However, separating + // the toggle and the dropdown itself is not supported by popper.js by + // default, which is why we need to toggle the dropdown manually. + ev.stopPropagation(); + this.$('.o_optional_columns .dropdown-toggle').dropdown('toggle'); + // Explicitly set left/right of the optional column dropdown as it is pushed + // inside this.$el, so we need to position it at the end of top left corner. + var position = (this.$(".table-responsive").css('overflow') === "auto" ? this.$el.width() : + this.$('table').width()); + var direction = "left"; + if (_t.database.parameters.direction === 'rtl') { + position = position - this.$('.o_optional_columns .o_optional_columns_dropdown').width(); + direction = "right"; + } + this.$('.o_optional_columns').css(direction, position); + }, + /** + * Manages the keyboard events on the list. If the list is not editable, when the user navigates to + * a cell using the keyboard, if he presses enter, enter the model represented by the line + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyDown: function (ev) { + var $cell = $(ev.currentTarget); + var $tr; + var $futureCell; + var colIndex; + if (this.state.isSample) { + return; // we disable keyboard navigation inside the table in "sample" mode + } + switch (ev.keyCode) { + case $.ui.keyCode.LEFT: + ev.preventDefault(); + $tr = $cell.closest('tr'); + $tr.closest('tbody').addClass('o_keyboard_navigation'); + if ($tr.hasClass('o_group_header') && $tr.hasClass('o_group_open')) { + this._onToggleGroup(ev); + } else { + $futureCell = $cell.prev(); + } + break; + case $.ui.keyCode.RIGHT: + ev.preventDefault(); + $tr = $cell.closest('tr'); + $tr.closest('tbody').addClass('o_keyboard_navigation'); + if ($tr.hasClass('o_group_header') && !$tr.hasClass('o_group_open')) { + this._onToggleGroup(ev); + } else { + $futureCell = $cell.next(); + } + break; + case $.ui.keyCode.UP: + ev.preventDefault(); + $cell.closest('tbody').addClass('o_keyboard_navigation'); + colIndex = this.currentColIndex || $cell.index(); + $futureCell = this._findConnectedCell($cell, 'prev', colIndex); + if (!$futureCell) { + this.trigger_up('navigation_move', { direction: 'up' }); + } + break; + case $.ui.keyCode.DOWN: + ev.preventDefault(); + $cell.closest('tbody').addClass('o_keyboard_navigation'); + colIndex = this.currentColIndex || $cell.index(); + $futureCell = this._findConnectedCell($cell, 'next', colIndex); + break; + case $.ui.keyCode.ENTER: + ev.preventDefault(); + $tr = $cell.closest('tr'); + if ($tr.hasClass('o_group_header')) { + this._onToggleGroup(ev); + } else { + var id = $tr.data('id'); + if (id) { + this.trigger_up('open_record', { id: id, target: ev.target }); + } + } + break; + } + if ($futureCell) { + // If the cell contains activable elements, focus them instead (except if it is in a + // group header, in which case we want to activate the whole header, so that we can + // open/close it with RIGHT/LEFT keystrokes) + var isInGroupHeader = $futureCell.closest('tr').hasClass('o_group_header'); + var $activables = !isInGroupHeader && $futureCell.find(':focusable'); + if ($activables.length) { + $activables[0].focus(); + } else { + $futureCell.focus(); + } + } + }, + /** + * @private + */ + _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._render(); + }, + }); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onRowClicked: function (ev) { + // The special_click property explicitely allow events to bubble all + // the way up to bootstrap's level rather than being stopped earlier. + if (!ev.target.closest('.o_list_record_selector') && !$(ev.target).prop('special_click')) { + var id = $(ev.currentTarget).data('id'); + if (id) { + this.trigger_up('open_record', { id: id, target: ev.target }); + } + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onSelectRecord: function (ev) { + ev.stopPropagation(); + this._updateSelection(); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onSortColumn: function (ev) { + var name = $(ev.currentTarget).data('name'); + this.trigger_up('toggle_column_order', { id: this.state.id, name: name }); + }, + /** + * When the user clicks on the whole record selector cell, we want to toggle + * the checkbox, to make record selection smooth. + * + * @private + * @param {MouseEvent} ev + */ + _onToggleCheckbox: function (ev) { + const $recordSelector = $(ev.target).find('input[type=checkbox]:not(":disabled")'); + $recordSelector.prop('checked', !$recordSelector.prop("checked")); + $recordSelector.change(); // s.t. th and td checkbox cases are handled by their own handler + }, + /** + * @private + * @param {DOMEvent} ev + */ + _onToggleGroup: function (ev) { + ev.preventDefault(); + var group = $(ev.currentTarget).closest('tr').data('group'); + if (group.count) { + this.trigger_up('toggle_group', { + group: group, + onSuccess: () => { + this._updateSelection(); + // Refocus the header after re-render unless the user + // already focused something else by now + if (document.activeElement.tagName === 'BODY') { + var groupHeaders = $('tr.o_group_header:data("group")'); + var header = groupHeaders.filter(function () { + return $(this).data('group').id === group.id; + }); + header.find('.o_group_name').focus(); + } + }, + }); + } + }, + /** + * When the user clicks on the row selection checkbox in the header, we + * need to update the checkbox of the row selection checkboxes in the body. + * + * @private + * @param {MouseEvent} ev + */ + _onToggleSelection: function (ev) { + var checked = $(ev.currentTarget).prop('checked') || false; + this.$('tbody .o_list_record_selector input:not(":disabled")').prop('checked', checked); + this._updateSelection(); + }, +}); + +return ListRenderer; +}); diff --git a/addons/web/static/src/js/views/list/list_view.js b/addons/web/static/src/js/views/list/list_view.js new file mode 100644 index 00000000..5c36d01e --- /dev/null +++ b/addons/web/static/src/js/views/list/list_view.js @@ -0,0 +1,137 @@ +odoo.define('web.ListView', function (require) { +"use strict"; + +/** + * The list view is one of the core and most basic view: it is used to look at + * a list of records in a table. + * + * Note that a list view is not instantiated to display a one2many field in a + * form view. Only a ListRenderer is used in that case. + */ + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var ListModel = require('web.ListModel'); +var ListRenderer = require('web.ListRenderer'); +var ListController = require('web.ListController'); +var pyUtils = require('web.py_utils'); + +var _lt = core._lt; + +var ListView = BasicView.extend({ + accesskey: "l", + display_name: _lt('List'), + icon: 'fa-list-ul', + config: _.extend({}, BasicView.prototype.config, { + Model: ListModel, + Renderer: ListRenderer, + Controller: ListController, + }), + viewType: 'list', + /** + * @override + * + * @param {Object} viewInfo + * @param {Object} params + * @param {boolean} params.hasActionMenus + * @param {boolean} [params.hasSelectors=true] + */ + init: function (viewInfo, params) { + var self = this; + this._super.apply(this, arguments); + var selectedRecords = []; // there is no selected records by default + + var pyevalContext = py.dict.fromJSON(_.pick(params.context, function(value, key, object) {return !_.isUndefined(value)}) || {}); + var expandGroups = !!JSON.parse(pyUtils.py_eval(this.arch.attrs.expand || "0", {'context': pyevalContext})); + + this.groupbys = {}; + this.headerButtons = []; + this.arch.children.forEach(function (child) { + if (child.tag === 'groupby') { + self._extractGroup(child); + } + if (child.tag === 'header') { + self._extractHeaderButtons(child); + } + }); + + let editable = false; + if ((!this.arch.attrs.edit || !!JSON.parse(this.arch.attrs.edit)) && !params.readonly) { + editable = this.arch.attrs.editable; + } + + this.controllerParams.activeActions.export_xlsx = this.arch.attrs.export_xlsx ? !!JSON.parse(this.arch.attrs.export_xlsx): true; + this.controllerParams.editable = editable; + this.controllerParams.hasActionMenus = params.hasActionMenus; + this.controllerParams.headerButtons = this.headerButtons; + this.controllerParams.toolbarActions = viewInfo.toolbar; + this.controllerParams.mode = 'readonly'; + this.controllerParams.selectedRecords = selectedRecords; + + this.rendererParams.arch = this.arch; + this.rendererParams.groupbys = this.groupbys; + this.rendererParams.hasSelectors = + 'hasSelectors' in params ? params.hasSelectors : true; + this.rendererParams.editable = editable; + this.rendererParams.selectedRecords = selectedRecords; + this.rendererParams.addCreateLine = false; + this.rendererParams.addCreateLineInGroups = editable && this.controllerParams.activeActions.create; + this.rendererParams.isMultiEditable = this.arch.attrs.multi_edit && !!JSON.parse(this.arch.attrs.multi_edit); + + this.modelParams.groupbys = this.groupbys; + + this.loadParams.limit = this.loadParams.limit || 80; + this.loadParams.openGroupByDefault = expandGroups; + this.loadParams.type = 'list'; + var groupsLimit = parseInt(this.arch.attrs.groups_limit, 10); + this.loadParams.groupsLimit = groupsLimit || (expandGroups ? 10 : 80); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Object} node + */ + _extractGroup: function (node) { + var innerView = this.fields[node.attrs.name].views.groupby; + this.groupbys[node.attrs.name] = this._processFieldsView(innerView, 'groupby'); + }, + /** + * Extracts action buttons definitions from the <header> node of the list + * view definition + * + * @private + * @param {Object} node + */ + _extractHeaderButtons(node) { + node.children.forEach(child => { + if (child.tag === 'button' && !child.attrs.modifiers.invisible) { + this.headerButtons.push(child); + } + }); + }, + /** + * @override + */ + _extractParamsFromAction: function (action) { + var params = this._super.apply(this, arguments); + var inDialog = action.target === 'new'; + var inline = action.target === 'inline'; + params.hasActionMenus = !inDialog && !inline; + return params; + }, + /** + * @override + */ + _updateMVCParams: function () { + this._super.apply(this, arguments); + this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; + }, +}); + +return ListView; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js new file mode 100644 index 00000000..11387781 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -0,0 +1,325 @@ +odoo.define('web.PivotController', function (require) { + "use strict"; + /** + * Odoo Pivot Table Controller + * + * This class is the Controller for the pivot table view. It has to coordinate + * the actions coming from the search view (through the update method), from + * the renderer, from the model, and from the control panel. + * + * It can display action buttons in the control panel, to select a different + * measure, or to perform some other actions such as download/expand/flip the + * view. + */ + + const AbstractController = require('web.AbstractController'); + const core = require('web.core'); + const framework = require('web.framework'); + const session = require('web.session'); + + const _t = core._t; + const QWeb = core.qweb; + + const PivotController = AbstractController.extend({ + custom_events: Object.assign({}, AbstractController.prototype.custom_events, { + closed_header_click: '_onClosedHeaderClicked', + open_view: '_onOpenView', + opened_header_click: '_onOpenedHeaderClicked', + sort_rows: '_onSortRows', + groupby_menu_selection: '_onGroupByMenuSelection', + }), + + /** + * @override + * @param parent + * @param model + * @param renderer + * @param {Object} params + * @param {Object} params.groupableFields a map from field names to field + * props + */ + init: function (parent, model, renderer, params) { + this._super(...arguments); + + this.disableLinking = params.disableLinking; + this.measures = params.measures; + this.title = params.title; + // views to use in the action triggered when a data cell is clicked + this.views = params.views; + this.groupSelected = null; + }, + /** + * @override + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off(); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the current measures and groupbys, so we can restore the view + * when we save the current state in the search view, or when we add it to + * the dashboard. + * + * @override method from AbstractController + * @returns {Object} + */ + getOwnedQueryParams: function () { + const state = this.model.get({ raw: true }); + return { + context: { + pivot_measures: state.measures, + pivot_column_groupby: state.colGroupBys, + pivot_row_groupby: state.rowGroupBys, + } + }; + }, + /** + * Render the buttons according to the PivotView.buttons template and + * 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 = this._getRenderButtonContext(); + this.$buttons = $(QWeb.render('PivotView.buttons', context)); + this.$buttons.click(this._onButtonClick.bind(this)); + this.$buttons.find('button').tooltip(); + if ($node) { + this.$buttons.appendTo($node); + } + }, + /** + * @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 + //-------------------------------------------------------------------------- + + /** + * Export the current pivot table data in a xls file. For this, we have to + * serialize the current state, then call the server /web/pivot/export_xlsx. + * Force a reload before exporting to ensure to export up-to-date data. + * + * @private + */ + _downloadTable: function () { + if (this.model.getTableWidth() > 16384) { + this.call('crash_manager', 'show_message', _t("For Excel compatibility, data cannot be exported if there are more than 16384 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + framework.unblockUI(); + return; + } + const table = this.model.exportData(); + table.title = this.title; + session.get_file({ + url: '/web/pivot/export_xlsx', + data: { data: JSON.stringify(table) }, + complete: framework.unblockUI, + error: (error) => this.call('crash_manager', 'rpc_error', error), + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This handler is called when the user clicked on a button in the control + * panel. We then have to react properly: it can either be a change in the + * current measures, or a request to flip/expand/download data. + * + * @private + * @param {MouseEvent} ev + */ + _onButtonClick: async function (ev) { + const $target = $(ev.target); + if ($target.hasClass('o_pivot_flip_button')) { + this.model.flip(); + this.update({}, { reload: false }); + } + if ($target.hasClass('o_pivot_expand_button')) { + await this.model.expandAll(); + this.update({}, { reload: false }); + } + if (ev.target.closest('.o_pivot_measures_list')) { + ev.preventDefault(); + ev.stopPropagation(); + const field = ev.target.dataset.field; + if (field) { + this.update({ measure: field }); + } + } + if ($target.hasClass('o_pivot_download')) { + this._downloadTable(); + } + + await this._addIncludedButtons(ev); + }, + + /** + * Declared to be overwritten in includes of pivot controller + * + * @param {MouseEvent} ev + * @returns {Promise<void>} + * @private + */ + _addIncludedButtons: async function(ev) {}, + /** + * Get the context of rendering of the buttons + * + * @returns {Object} + * @private + */ + _getRenderButtonContext: function () { + return { + measures: Object.entries(this.measures) + .filter(x => x[0] !== '__count') + .sort((a, b) => a[1].string.toLowerCase() > b[1].string.toLowerCase() ? 1 : -1), + }; + }, + /** + * + * @private + * @param {OdooEvent} ev + */ + _onCloseGroup: function (ev) { + this.model.closeGroup(ev.data.groupId, ev.data.type); + this.update({}, { reload: false }); + }, + /** + * @param {CustomEvent} ev + * @private + * */ + _onOpenedHeaderClicked: function (ev) { + this.model.closeGroup(ev.data.cell.groupId, ev.data.type); + this.update({}, { reload: false }); + }, + /** + * @param {CustomEvent} ev + * @private + * */ + _onClosedHeaderClicked: async function (ev) { + const cell = ev.data.cell; + const groupId = cell.groupId; + const type = ev.data.type; + + const group = { + rowValues: groupId[0], + colValues: groupId[1], + type: type + }; + + const state = this.model.get({ raw: true }); + const groupValues = type === 'row' ? groupId[0] : groupId[1]; + const groupBys = type === 'row' ? + state.rowGroupBys : + state.colGroupBys; + this.selectedGroup = group; + if (groupValues.length < groupBys.length) { + const groupBy = groupBys[groupValues.length]; + await this.model.expandGroup(this.selectedGroup, groupBy); + this.update({}, { reload: false }); + } + }, + /** + * This handler is called when the user selects a groupby in the dropdown menu. + * + * @private + * @param {CustomEvent} ev + */ + _onGroupByMenuSelection: async function (ev) { + ev.stopPropagation(); + + let groupBy = ev.data.field.name; + const interval = ev.data.interval; + if (interval) { + groupBy = groupBy + ':' + interval; + } + this.model.addGroupBy(groupBy, this.selectedGroup.type); + await this.model.expandGroup(this.selectedGroup, groupBy); + this.update({}, { reload: false }); + }, + /** + * @private + * @param {CustomEvent} ev + */ + _onOpenView: function (ev) { + ev.stopPropagation(); + const cell = ev.data; + if (cell.value === undefined || this.disableLinking) { + return; + } + + const context = Object.assign({}, this.model.data.context); + Object.keys(context).forEach(x => { + if (x === 'group_by' || x.startsWith('search_default_')) { + delete context[x]; + } + }); + + const group = { + rowValues: cell.groupId[0], + colValues: cell.groupId[1], + originIndex: cell.originIndexes[0] + }; + + const domain = this.model._getGroupDomain(group); + + this.do_action({ + type: 'ir.actions.act_window', + name: this.title, + res_model: this.modelName, + views: this.views, + view_mode: 'list', + target: 'current', + context: context, + domain: domain, + }); + }, + /** + * @private + * @param {CustomEvent} ev + */ + _onSortRows: function (ev) { + this.model.sortRows({ + groupId: ev.data.groupId, + measure: ev.data.measure, + order: (ev.data.order || 'desc') === 'asc' ? 'desc' : 'asc', + originIndexes: ev.data.originIndexes, + }); + this.update({}, { reload: false }); + }, + }); + + return PivotController; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js new file mode 100644 index 00000000..b4af34e0 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -0,0 +1,1569 @@ +odoo.define('web.PivotModel', function (require) { +"use strict"; + +/** + * Pivot Model + * + * The pivot model keeps an in-memory representation of the pivot table that is + * displayed on the screen. The exact layout of this representation is not so + * simple, because a pivot table is at its core a 2-dimensional object, but + * with a 'tree' component: some rows/cols can be expanded so we zoom into the + * structure. + * + * However, we need to be able to manipulate the data in a somewhat efficient + * way, and to transform it into a list of lines to be displayed by the renderer. + * + * Basicaly the pivot table presents aggregated values for various groups of records + * in one domain. If a comparison is asked for, two domains are considered. + * + * Let us consider a simple example and let us fix the vocabulary (let us suppose we are in June 2020): + * ___________________________________________________________________________________________________________________________________________ + * | | Total | + * | |_____________________________________________________________________________________________________________________| + * | | Sale Team 1 | Sale Team 2 | | + * | |_______________________________________|______________________________________|______________________________________| + * | | Sales total | Sales total | Sales total | + * | |_______________________________________|______________________________________|______________________________________| + * | | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | May 2020 | June 2020 | Variation | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * | Total | 85 | 110 | 29.4% | 40 | 30 | -25% | 125 | 140 | 12% | + * | Europe | 25 | 35 | 40% | 40 | 30 | -25% | 65 | 65 | 0% | + * | Brussels | 0 | 15 | 100% | 30 | 30 | 0% | 30 | 45 | 50% | + * | Paris | 25 | 20 | -20% | 10 | 0 | -100% | 35 | 20 | -42.8% | + * | North America | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * | Washington | 60 | 75 | 25% | | | | 60 | 75 | 25% | + * |____________________|______________|____________|___________|_____________|____________|___________|_____________|____________|___________| + * + * + * META DATA: + * + * In the above pivot table, the records have been grouped using the fields + * + * continent_id, city_id + * + * for rows and + * + * sale_team_id + * + * for columns. + * + * The measure is the field 'sales_total'. + * + * Two domains are considered: 'May 2020' and 'June 2020'. + * + * In the model, + * + * - rowGroupBys is the list [continent_id, city_id] + * - colGroupBys is the list [sale_team_id] + * - measures is the list [sales_total] + * - domains is the list [d1, d2] with d1 and d2 domain expressions + * for say sale_date in May 2020 and June 2020, for instance + * d1 = [['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31]] + * - origins is the list ['May 2020', 'June 2020'] + * + * DATA: + * + * Recall that a group is constituted by records (in a given domain) + * that have the same (raw) values for a list of fields. + * Thus the group itself is identified by this list and the domain. + * In comparison mode, the same group (forgetting the domain part or 'originIndex') + * can be eventually found in the two domains. + * This defines the way in which the groups are identified or not. + * + * In the above table, (forgetting the domain) the following groups are found: + * + * the 'row groups' + * - Total + * - Europe + * - America + * - Europe, Brussels + * - Europe, Paris + * - America, Washington + * + * the 'col groups' + * + * - Total + * - Sale Team 1 + * - Sale Team 2 + * + * and all non trivial combinations of row groups and col groups + * + * - Europe, Sale Team 1 + * - Europe, Brussels, Sale Team 2 + * - America, Washington, Sale Team 1 + * - ... + * + * The list of fields is created from the concatenation of two lists of fields, the first in + * + * [], [f1], [f1, f2], ... [f1, f2, ..., fn] for [f1, f2, ..., fn] the full list of groupbys + * (called rowGroupBys) used to create row groups + * + * In the example: [], [continent_id], [continent_id, city_id]. + * + * and the second in + * [], [g1], [g1, g2], ... [g1, g2, ..., gm] for [g1, g2, ..., gm] the full list of groupbys + * (called colGroupBys) used to create col groups. + * + * In the example: [], [sale_team_id]. + * + * Thus there are (n+1)*(m+1) lists of fields possible. + * + * In the example: 6 lists possible, namely [], + * [continent_id], [sale_team_id], + * [continent_id, sale_team_id], [continent_id, city_id], + * [continent_id, city_id, sale_team_id] + * + * A given list is thus of the form [f1,..., fi, g1,..., gj] or better [[f1,...,fi], [g1,...,gj]] + * + * For each list of fields possible and each domain considered, one read_group is done + * and gives results of the form (an exception for list []) + * + * g = { + * f1: v1, ..., fi: vi, + * g1: w1, ..., gj: wj, + * m1: x1, ..., mk: xk, + * __count: c, + * __domain: d + * } + * + * where v1,...,vi,w1,...,Wj are 'values' for the corresponding fields and + * m1,...,mk are the fields selected as measures. + * + * For example, g = { + * continent_id: [1, 'Europe'] + * sale_team_id: [1, 'Sale Team 1'] + * sales_count: 25, + * __count: 4 + * __domain: [ + * ['sale_date', >=, 2020-05-01], ['sale_date', '<=', 2020-05-31], + * ['continent_id', '=', 1], + * ['sale_team_id', '=', 1] + * ] + * } + * + * Thus the above group g is fully determined by [[v1,...,vi], [w1,...,wj]] and the base domain + * or the corresponding 'originIndex'. + * + * When j=0, g corresponds to a row group (or also row header) and is of the form [[v1,...,vi], []] or more simply [v1,...vi] + * (not forgetting the list [v1,...vi] comes from left). + * When i=0, g corresponds to a col group (or col header) and is of the form [[], [w1,...,wj]] or more simply [w1,...,wj]. + * + * A generic group g as above [[v1,...,vi], [w1,...,wj]] corresponds to the two headers [[v1,...,vi], []] + * and [[], [w1,...,wj]]. + * + * Here is a description of the data structure manipulated by the pivot model. + * + * Five objects contain all the data from the read_groups + * + * - rowGroupTree: contains information on row headers + * the nodes correspond to the groups of the form [[v1,...,vi], []] + * The root is [[], []]. + * A node [[v1,...,vl], []] has as direct children the nodes of the form [[v1,...,vl,v], []], + * this means that a direct child is obtained by grouping records using the single field fi+1 + * + * The structure at each level is of the form + * + * { + * root: { + * values: [v1,...,vl], + * labels: [la1,...,lal] + * }, + * directSubTrees: { + * v => { + * root: { + * values: [v1,...,vl,v] + * labels: [label1,...,labell,label] + * }, + * directSubTrees: {...} + * }, + * v' => {...}, + * ... + * } + * } + * + * (directSubTrees is a Map instance) + * + * In the example, the rowGroupTree is: + * + * { + * root: { + * values: [], + * labels: [] + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1], + * labels: ['Europe'], + * }, + * directSubTrees: { + * 1 => { + * root: { + * values: [1, 1], + * labels: ['Europe', 'Brussels'], + * }, + * directSubTrees: new Map(), + * }, + * 2 => { + * root: { + * values: [1, 2], + * labels: ['Europe', 'Paris'], + * }, + * directSubTrees: new Map(), + * }, + * }, + * }, + * 2 => { + * root: { + * values: [2], + * labels: ['America'], + * }, + * directSubTrees: { + * 3 => { + * root: { + * values: [2, 3], + * labels: ['America', 'Washington'], + * } + * directSubTrees: new Map(), + * }, + * }, + * }, + * }, + * } + * + * - colGroupTree: contains information on col headers + * The same as above with right instead of left + * + * - measurements: contains information on measure values for all the groups + * + * the object keys are of the form JSON.stringify([[v1,...,vi], [w1,...,wj]]) + * and values are arrays of length equal to number of origins containing objects of the form + * {m1: x1,...,mk: xk} + * The structure looks like + * + * { + * JSON.stringify([[], []]): [{m1: x1,...,mk: xk}, {m1: x1',...,mk: xk'},...] + * .... + * JSON.stringify([[v1,...,vi], [w1,...,wj]]): [{m1: y1',...,mk: yk'}, {m1: y1',...,mk: yk'},...], + * .... + * JSON.stringify([[v1,...,vn], [w1,...,wm]]): [{m1: z1',...,mk: zk'}, {m1: z1',...,mk: zk'},...], + * } + * Thus the structure contains all information for all groups and all origins on measure values. + * + * + * this.measurments["[[], []]"][0]['foo'] gives the value of the measure 'foo' for the group 'Total' and the + * first domain (origin). + * + * In the example: + * { + * "[[], []]": [{'sales_total': 125}, {'sales_total': 140}] (total/total) + * ... + * "[[1, 2], [2]]": [{'sales_total': 10}, {'sales_total': 0}] (Europe/Paris/Sale Team 2) + * ... + * } + * + * - counts: contains information on the number of records in each groups + * The structure is similar to the above but the arrays contains numbers (counts) + * - groupDomains: + * The structure is similar to the above but the arrays contains domains + * + * With this light data structures, all manipulation done by the model are eased and redundancies are limited. + * Each time a rendering or an export of the data has to be done, the pivot table is generated by the _getTable function. + */ + +var AbstractModel = require('web.AbstractModel'); +var concurrency = require('web.concurrency'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); +const Domain = require('web.Domain'); +var mathUtils = require('web.mathUtils'); +var session = require('web.session'); + + +var _t = core._t; +var cartesian = mathUtils.cartesian; +var computeVariation = dataComparisonUtils.computeVariation; +var sections = mathUtils.sections; + +var PivotModel = AbstractModel.extend({ + /** + * @override + * @param {Object} params + */ + init: function () { + this._super.apply(this, arguments); + this.numbering = {}; + this.data = null; + this._loadDataDropPrevious = new concurrency.DropPrevious(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a groupBy to rowGroupBys or colGroupBys according to provided type. + * + * @param {string} groupBy + * @param {'row'|'col'} type + */ + addGroupBy: function (groupBy, type) { + if (type === 'row') { + this.data.expandedRowGroupBys.push(groupBy); + } else { + this.data.expandedColGroupBys.push(groupBy); + } + }, + /** + * Close the group with id given by groupId. A type must be specified + * in case groupId is [[], []] (the id of the group 'Total') because this + * group is present in both colGroupTree and rowGroupTree. + * + * @param {Array[]} groupId + * @param {'row'|'col'} type + */ + closeGroup: function (groupId, type) { + var groupBys; + var expandedGroupBys; + let keyPart; + var group; + var tree; + if (type === 'row') { + groupBys = this.data.rowGroupBys; + expandedGroupBys = this.data.expandedRowGroupBys; + tree = this.rowGroupTree; + group = this._findGroup(this.rowGroupTree, groupId[0]); + keyPart = 0; + } else { + groupBys = this.data.colGroupBys; + expandedGroupBys = this.data.expandedColGroupBys; + tree = this.colGroupTree; + group = this._findGroup(this.colGroupTree, groupId[1]); + keyPart = 1; + } + + const groupIdPart = groupId[keyPart]; + const range = groupIdPart.map((_, index) => index); + function keep(key) { + const idPart = JSON.parse(key)[keyPart]; + return range.some(index => groupIdPart[index] !== idPart[index]) || + idPart.length === groupIdPart.length; + } + function omitKeys(object) { + const newObject = {}; + for (const key in object) { + if (keep(key)) { + newObject[key] = object[key]; + } + } + return newObject; + } + this.measurements = omitKeys(this.measurements); + this.counts = omitKeys(this.counts); + this.groupDomains = omitKeys(this.groupDomains); + + group.directSubTrees.clear(); + delete group.sortedKeys; + var newGroupBysLength = this._getTreeHeight(tree) - 1; + if (newGroupBysLength <= groupBys.length) { + expandedGroupBys.splice(0); + groupBys.splice(newGroupBysLength); + } else { + expandedGroupBys.splice(newGroupBysLength - groupBys.length); + } + }, + /** + * Reload the view with the current rowGroupBys and colGroupBys + * This is the easiest way to expand all the groups that are not expanded + * + * @returns {Promise} + */ + expandAll: function () { + return this._loadData(); + }, + /** + * Expand a group by using groupBy to split it. + * + * @param {Object} group + * @param {string} groupBy + * @returns {Promise} + */ + expandGroup: async function (group, groupBy) { + var leftDivisors; + var rightDivisors; + + if (group.type === 'row') { + leftDivisors = [[groupBy]]; + rightDivisors = sections(this._getGroupBys().colGroupBys); + } else { + leftDivisors = sections(this._getGroupBys().rowGroupBys); + rightDivisors = [[groupBy]]; + } + var divisors = cartesian(leftDivisors, rightDivisors); + + delete group.type; + return this._subdivideGroup(group, divisors); + }, + /** + * Export model data in a form suitable for an easy encoding of the pivot + * table in excell. + * + * @returns {Object} + */ + exportData: function () { + var measureCount = this.data.measures.length; + var originCount = this.data.origins.length; + + var table = this._getTable(); + + // process headers + var headers = table.headers; + var colGroupHeaderRows; + var measureRow = []; + var originRow = []; + + function processHeader(header) { + var inTotalColumn = header.groupId[1].length === 0; + return { + title: header.title, + width: header.width, + height: header.height, + is_bold: !!header.measure && inTotalColumn + }; + } + + if (originCount > 1) { + colGroupHeaderRows = headers.slice(0, headers.length - 2); + measureRow = headers[headers.length - 2].map(processHeader); + originRow = headers[headers.length - 1].map(processHeader); + } else { + colGroupHeaderRows = headers.slice(0, headers.length - 1); + measureRow = headers[headers.length - 1].map(processHeader); + } + + // remove the empty headers on left side + colGroupHeaderRows[0].splice(0, 1); + + colGroupHeaderRows = colGroupHeaderRows.map(function (headerRow) { + return headerRow.map(processHeader); + }); + + // process rows + var tableRows = table.rows.map(function (row) { + return { + title: row.title, + indent: row.indent, + values: row.subGroupMeasurements.map(function (measurement) { + var value = measurement.value; + if (value === undefined) { + value = ""; + } else if (measurement.originIndexes.length > 1) { + // in that case the value is a variation and a + // number between 0 and 1 + value = value * 100; + } + return { + is_bold: measurement.isBold, + value: value, + }; + }), + }; + }); + + return { + col_group_headers: colGroupHeaderRows, + measure_headers: measureRow, + origin_headers: originRow, + rows: tableRows, + measure_count: measureCount, + origin_count: originCount, + }; + }, + /** + * Swap the pivot columns and the rows. It is a synchronous operation. + */ + flip: function () { + // swap the data: the main column and the main row + var temp = this.rowGroupTree; + this.rowGroupTree = this.colGroupTree; + this.colGroupTree = temp; + + // we need to update the record metadata: (expanded) row and col groupBys + temp = this.data.rowGroupBys; + this.data.groupedBy = this.data.colGroupBys; + this.data.rowGroupBys = this.data.colGroupBys; + this.data.colGroupBys = temp; + temp = this.data.expandedColGroupBys; + this.data.expandedColGroupBys = this.data.expandedRowGroupBys; + this.data.expandedRowGroupBys = temp; + + function twistKey(key) { + return JSON.stringify(JSON.parse(key).reverse()); + } + + function twist(object) { + var newObject = {}; + Object.keys(object).forEach(function (key) { + var value = object[key]; + newObject[twistKey(key)] = value; + }); + return newObject; + } + + this.measurements = twist(this.measurements); + this.counts = twist(this.counts); + this.groupDomains = twist(this.groupDomains); + }, + /** + * @override + * + * @param {Object} [options] + * @param {boolean} [options.raw=false] + * @returns {Object} + */ + __get: function (options) { + options = options || {}; + var raw = options.raw || false; + var groupBys = this._getGroupBys(); + var state = { + colGroupBys: groupBys.colGroupBys, + context: this.data.context, + domain: this.data.domain, + fields: this.fields, + hasData: this._hasData(), + isSample: this.isSampleModel, + measures: this.data.measures, + origins: this.data.origins, + rowGroupBys: groupBys.rowGroupBys, + selectionGroupBys: this._getSelectionGroupBy(groupBys), + modelName: this.modelName + }; + if (!raw && state.hasData) { + state.table = this._getTable(); + state.tree = this.rowGroupTree; + } + return state; + }, + /** + * Returns the total number of columns of the pivot table. + * + * @returns {integer} + */ + getTableWidth: function () { + var leafCounts = this._getLeafCounts(this.colGroupTree); + return leafCounts[JSON.stringify(this.colGroupTree.root.values)] + 2; + }, + /** + * @override + * + * @param {Object} params + * @param {boolean} [params.compare=false] + * @param {Object} params.context + * @param {Object} params.fields + * @param {string[]} [params.groupedBy] + * @param {string[]} params.colGroupBys + * @param {Array[]} params.domain + * @param {string[]} params.measures + * @param {string[]} params.rowGroupBys + * @param {string} [params.default_order] + * @param {string} params.modelName + * @param {Object[]} params.groupableFields + * @param {Object} params.timeRanges + * @returns {Promise} + */ + __load: function (params) { + this.initialDomain = params.domain; + this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys; + this.defaultGroupedBy = params.groupedBy; + + this.fields = params.fields; + this.modelName = params.modelName; + this.groupableFields = params.groupableFields; + const measures = this._processMeasures(params.context.pivot_measures) || + params.measures.map(m => m); + this.data = { + expandedRowGroupBys: [], + expandedColGroupBys: [], + domain: this.initialDomain, + context: _.extend({}, session.user_context, params.context), + groupedBy: params.context.pivot_row_groupby || params.groupedBy, + colGroupBys: params.context.pivot_column_groupby || params.colGroupBys, + measures, + timeRanges: params.timeRanges, + }; + this._computeDerivedParams(); + + this.data.groupedBy = this.data.groupedBy.slice(); + this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice(); + + var defaultOrder = params.default_order && params.default_order.split(' '); + if (defaultOrder) { + this.data.sortedColumn = { + groupId: [[], []], + measure: defaultOrder[0], + order: defaultOrder[1] ? defaultOrder [1] : 'asc', + }; + } + return this._loadData(); + }, + /** + * @override + * + * @param {any} handle this parameter is ignored + * @param {Object} params + * @param {boolean} [params.compare=false] + * @param {Object} params.context + * @param {string[]} [params.groupedBy] + * @param {Array[]} params.domain + * @param {string[]} params.groupBy + * @param {string[]} params.measures + * @param {Object} [params.timeRanges] + * @returns {Promise} + */ + __reload: function (handle, params) { + var self = this; + var oldColGroupBys = this.data.colGroupBys; + var oldRowGroupBys = this.data.rowGroupBys; + if ('context' in params) { + this.data.context = params.context; + this.data.colGroupBys = params.context.pivot_column_groupby || this.data.colGroupBys; + 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; + } + if ('domain' in params) { + this.data.domain = params.domain; + this.initialDomain = params.domain; + } else { + this.data.domain = this.initialDomain; + } + 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.groupedBy = this.data.groupedBy.slice(); + this.data.rowGroupBys = !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys.slice(); + + if (!_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { + this.data.expandedRowGroupBys = []; + } + if (!_.isEqual(oldColGroupBys, self.data.colGroupBys)) { + this.data.expandedColGroupBys = []; + } + + if ('measure' in params) { + return this._toggleMeasure(params.measure); + } + + if (!this._hasData()) { + return this._loadData(); + } + + var oldRowGroupTree = this.rowGroupTree; + var oldColGroupTree = this.colGroupTree; + return this._loadData().then(function () { + if (_.isEqual(oldRowGroupBys, self.data.rowGroupBys)) { + self._pruneTree(self.rowGroupTree, oldRowGroupTree); + } + if (_.isEqual(oldColGroupBys, self.data.colGroupBys)) { + self._pruneTree(self.colGroupTree, oldColGroupTree); + } + }); + }, + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * @param {Object} sortedColumn + * @param {number[]} sortedColumn.groupId + */ + sortRows: function (sortedColumn) { + var self = this; + var colGroupValues = sortedColumn.groupId[1]; + sortedColumn.originIndexes = sortedColumn.originIndexes || [0]; + this.data.sortedColumn = sortedColumn; + + var sortFunction = function (tree) { + return function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + var groupIntersectionId = [subTree.root.values, colGroupValues]; + var value = self._getCellValue( + groupIntersectionId, + sortedColumn.measure, + sortedColumn.originIndexes + ) || 0; + return sortedColumn.order === 'asc' ? value : -value; + }; + }; + + this._sortTree(sortFunction, this.rowGroupTree); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add labels/values in the provided groupTree. A new leaf is created in + * the groupTree with a root object corresponding to the group with given + * labels/values. + * + * @private + * @param {Object} groupTree, either this.rowGroupTree or this.colGroupTree + * @param {string[]} labels + * @param {Array} values + */ + _addGroup: function (groupTree, labels, values) { + var tree = groupTree; + // we assume here that the group with value value.slice(value.length - 2) has already been added. + values.slice(0, values.length - 1).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + tree.directSubTrees.set(values[values.length - 1], { + root: { + labels: labels, + values: values, + }, + directSubTrees: new Map(), + }); + }, + /** + * Compute what should be used as rowGroupBys by the pivot view + * + * @private + * @returns {string[]} + */ + _computeRowGroupBys: function () { + return !_.isEmpty(this.data.groupedBy) ? this.data.groupedBy : this.initialRowGroupBys; + }, + /** + * Find a group with given values in the provided groupTree, either + * this.rowGrouptree or this.colGroupTree. + * + * @private + * @param {Object} groupTree + * @param {Array} values + * @returns {Object} + */ + _findGroup: function (groupTree, values) { + var tree = groupTree; + values.slice(0, values.length).forEach(function (value) { + tree = tree.directSubTrees.get(value); + }); + return tree; + }, + /** + * In case originIndex is an array of length 1, thus a single origin + * index, returns the given measure for a group determined by the id + * groupId and the origin index. + * If originIndexes is an array of length 2, we compute the variation + * ot the measure values for the groups determined by groupId and the + * different origin indexes. + * + * @private + * @param {Array[]} groupId + * @param {string} measure + * @param {number[]} originIndexes + * @returns {number} + */ + _getCellValue: function (groupId, measure, originIndexes) { + var self = this; + var key = JSON.stringify(groupId); + if (!self.measurements[key]) { + return; + } + var values = originIndexes.map(function (originIndex) { + return self.measurements[key][originIndex][measure]; + }); + if (originIndexes.length > 1) { + return computeVariation(values[1], values[0]); + } else { + return values[0]; + } + }, + /** + * Returns the rowGroupBys and colGroupBys arrays that + * are actually used by the pivot view internally + * (for read_group or other purpose) + * + * @private + * @returns {Object} with keys colGroupBys and rowGroupBys + */ + _getGroupBys: function () { + return { + colGroupBys: this.data.colGroupBys.concat(this.data.expandedColGroupBys), + rowGroupBys: this.data.rowGroupBys.concat(this.data.expandedRowGroupBys), + }; + }, + /** + * Returns a domain representation of a group + * + * @private + * @param {Object} group + * @param {Array} group.colValues + * @param {Array} group.rowValues + * @param {number} group.originIndex + * @returns {Array[]} + */ + _getGroupDomain: function (group) { + var key = JSON.stringify([group.rowValues, group.colValues]); + return this.groupDomains[key][group.originIndex]; + }, + /** + * Returns the group sanitized labels. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {string[]} + */ + _getGroupLabels: function (group, groupBys) { + var self = this; + return groupBys.map(function (groupBy) { + return self._sanitizeLabel(group[groupBy], groupBy); + }); + }, + /** + * Returns a promise that returns the annotated read_group results + * corresponding to a partition of the given group obtained using the given + * rowGroupBy and colGroupBy. + * + * @private + * @param {Object} group + * @param {string[]} rowGroupBy + * @param {string[]} colGroupBy + * @returns {Promise} + */ + _getGroupSubdivision: function (group, rowGroupBy, colGroupBy) { + var groupDomain = this._getGroupDomain(group); + var measureSpecs = this._getMeasureSpecs(); + var groupBy = rowGroupBy.concat(colGroupBy); + return this._rpc({ + model: this.modelName, + method: 'read_group', + context: this.data.context, + domain: groupDomain, + fields: measureSpecs, + groupBy: groupBy, + lazy: false, + }).then(function (subGroups) { + return { + group: group, + subGroups: subGroups, + rowGroupBy: rowGroupBy, + colGroupBy: colGroupBy + }; + }); + }, + /** + * Returns the group sanitized values. + * + * @private + * @param {Object} group + * @param {string[]} groupBys + * @returns {Array} + */ + _getGroupValues: function (group, groupBys) { + var self = this; + return groupBys.map(function (groupBy) { + return self._sanitizeValue(group[groupBy]); + }); + }, + /** + * Returns the leaf counts of each group inside the given tree. + * + * @private + * @param {Object} tree + * @returns {Object} keys are group ids + */ + _getLeafCounts: function (tree) { + var self = this; + var leafCounts = {}; + var leafCount; + if (!tree.directSubTrees.size) { + leafCount = 1; + } else { + leafCount = [...tree.directSubTrees.values()].reduce( + function (acc, subTree) { + var subLeafCounts = self._getLeafCounts(subTree); + _.extend(leafCounts, subLeafCounts); + return acc + leafCounts[JSON.stringify(subTree.root.values)]; + }, + 0 + ); + } + + leafCounts[JSON.stringify(tree.root.values)] = leafCount; + return leafCounts; + }, + /** + * Returns the group sanitized measure values for the measures in + * this.data.measures (that migth contain '__count', not really a fieldName). + * + * @private + * @param {Object} group + * @returns {Array} + */ + _getMeasurements: function (group) { + var self = this; + return this.data.measures.reduce( + function (measurements, fieldName) { + var measurement = group[fieldName]; + if (measurement instanceof Array) { + // case field is many2one and used as measure and groupBy simultaneously + measurement = 1; + } + if (self.fields[fieldName].type === 'boolean' && measurement instanceof Boolean) { + measurement = measurement ? 1 : 0; + } + if (self.data.origins.length > 1 && !measurement) { + measurement = 0; + } + measurements[fieldName] = measurement; + return measurements; + }, + {} + ); + }, + /** + * Returns a description of the measures row of the pivot table + * + * @private + * @param {Object[]} columns for which measure cells must be generated + * @returns {Object[]} + */ + _getMeasuresRow: function (columns) { + var self = this; + var sortedColumn = this.data.sortedColumn || {}; + var measureRow = []; + + columns.forEach(function (column) { + self.data.measures.forEach(function (measure) { + var measureCell = { + groupId: column.groupId, + height: 1, + measure: measure, + title: self.fields[measure].string, + width: 2 * self.data.origins.length - 1, + }; + if (sortedColumn.measure === measure && + _.isEqual(sortedColumn.groupId, column.groupId)) { + measureCell.order = sortedColumn.order; + } + measureRow.push(measureCell); + }); + }); + + return measureRow; + }, + /** + * Returns the list of measure specs associated with data.measures, i.e. + * a measure 'fieldName' becomes 'fieldName:groupOperator' where + * groupOperator is the value specified on the field 'fieldName' for + * the key group_operator. + * + * @private + * @return {string[]} + */ + _getMeasureSpecs: function () { + var self = this; + return this.data.measures.reduce( + function (acc, measure) { + if (measure === '__count') { + acc.push(measure); + return acc; + } + var type = self.fields[measure].type; + var groupOperator = self.fields[measure].group_operator; + if (type === 'many2one') { + groupOperator = 'count_distinct'; + } + if (groupOperator === undefined) { + throw new Error("No aggregate function has been provided for the measure '" + measure + "'"); + } + acc.push(measure + ':' + groupOperator); + return acc; + }, + [] + ); + }, + /** + * Make sure that the labels of different many2one values are distinguished + * by numbering them if necessary. + * + * @private + * @param {Array} label + * @param {string} fieldName + * @returns {string} + */ + _getNumberedLabel: function (label, fieldName) { + var id = label[0]; + var name = label[1]; + this.numbering[fieldName] = this.numbering[fieldName] || {}; + this.numbering[fieldName][name] = this.numbering[fieldName][name] || {}; + var numbers = this.numbering[fieldName][name]; + numbers[id] = numbers[id] || _.size(numbers) + 1; + return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); + }, + /** + * Returns a description of the origins row of the pivot table + * + * @private + * @param {Object[]} columns for which origin cells must be generated + * @returns {Object[]} + */ + _getOriginsRow: function (columns) { + var self = this; + var sortedColumn = this.data.sortedColumn || {}; + var originRow = []; + + columns.forEach(function (column) { + var groupId = column.groupId; + var measure = column.measure; + var isSorted = sortedColumn.measure === measure && + _.isEqual(sortedColumn.groupId, groupId); + var isSortedByOrigin = isSorted && !sortedColumn.originIndexes[1]; + var isSortedByVariation = isSorted && sortedColumn.originIndexes[1]; + + self.data.origins.forEach(function (origin, originIndex) { + var originCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex], + title: origin, + width: 1, + }; + if (isSortedByOrigin && sortedColumn.originIndexes[0] === originIndex) { + originCell.order = sortedColumn.order; + } + originRow.push(originCell); + + if (originIndex > 0) { + var variationCell = { + groupId: groupId, + height: 1, + measure: measure, + originIndexes: [originIndex - 1, originIndex], + title: _t('Variation'), + width: 1, + }; + if (isSortedByVariation && sortedColumn.originIndexes[1] === originIndex) { + variationCell.order = sortedColumn.order; + } + originRow.push(variationCell); + } + + }); + }); + + return originRow; + }, + + /** + * Get the selection needed to display the group by dropdown + * @returns {Object[]} + * @private + */ + _getSelectionGroupBy: function (groupBys) { + let groupedFieldNames = groupBys.rowGroupBys + .concat(groupBys.colGroupBys) + .map(function (g) { + return g.split(':')[0]; + }); + + var fields = Object.keys(this.groupableFields) + .map((fieldName, index) => { + return { + name: fieldName, + field: this.groupableFields[fieldName], + active: groupedFieldNames.includes(fieldName) + } + }) + .sort((left, right) => left.field.string < right.field.string ? -1 : 1); + return fields; + }, + + /** + * Returns a description of the pivot table. + * + * @private + * @returns {Object} + */ + _getTable: function () { + var headers = this._getTableHeaders(); + return { + headers: headers, + rows: this._getTableRows(this.rowGroupTree, headers[headers.length - 1]), + }; + }, + /** + * Returns the list of header rows of the pivot table: the col group rows + * (depending on the col groupbys), the measures row and optionnaly the + * origins row (if there are more than one origins). + * + * @private + * @returns {Object[]} + */ + _getTableHeaders: function () { + var colGroupBys = this._getGroupBys().colGroupBys; + var height = colGroupBys.length + 1; + var measureCount = this.data.measures.length; + var originCount = this.data.origins.length; + var leafCounts = this._getLeafCounts(this.colGroupTree); + var headers = []; + var measureColumns = []; // used to generate the measure cells + + // 1) generate col group rows (total row + one row for each col groupby) + var colGroupRows = (new Array(height)).fill(0).map(function () { + return []; + }); + // blank top left cell + colGroupRows[0].push({ + height: height + 1 + (originCount > 1 ? 1 : 0), // + measures rows [+ origins row] + title: "", + width: 1, + }); + + // col groupby cells with group values + /** + * Recursive function that generates the header cells corresponding to + * the groups of a given tree. + * + * @param {Object} tree + */ + function generateTreeHeaders(tree, fields) { + var group = tree.root; + var rowIndex = group.values.length; + var row = colGroupRows[rowIndex]; + var groupId = [[], group.values]; + var isLeaf = !tree.directSubTrees.size; + var leafCount = leafCounts[JSON.stringify(tree.root.values)]; + var cell = { + groupId: groupId, + height: isLeaf ? (colGroupBys.length + 1 - rowIndex) : 1, + isLeaf: isLeaf, + label: rowIndex === 0 ? undefined : fields[colGroupBys[rowIndex - 1].split(':')[0]].string, + title: group.labels[group.labels.length - 1] || _t('Total'), + width: leafCount * measureCount * (2 * originCount - 1), + }; + row.push(cell); + if (isLeaf) { + measureColumns.push(cell); + } + + [...tree.directSubTrees.values()].forEach(function (subTree) { + generateTreeHeaders(subTree, fields); + }); + } + + generateTreeHeaders(this.colGroupTree, this.fields); + // blank top right cell for 'Total' group (if there is more that one leaf) + if (leafCounts[JSON.stringify(this.colGroupTree.root.values)] > 1) { + var groupId = [[], []]; + var totalTopRightCell = { + groupId: groupId, + height: height, + title: "", + width: measureCount * (2 * originCount - 1), + }; + colGroupRows[0].push(totalTopRightCell); + measureColumns.push(totalTopRightCell); + } + headers = headers.concat(colGroupRows); + + // 2) generate measures row + var measuresRow = this._getMeasuresRow(measureColumns); + headers.push(measuresRow); + + // 3) generate origins row if more than one origin + if (originCount > 1) { + headers.push(this._getOriginsRow(measuresRow)); + } + + return headers; + }, + /** + * Returns the list of body rows of the pivot table for a given tree. + * + * @private + * @param {Object} tree + * @param {Object[]} columns + * @returns {Object[]} + */ + _getTableRows: function (tree, columns) { + var self = this; + + var rows = []; + var group = tree.root; + var rowGroupId = [group.values, []]; + var title = group.labels[group.labels.length - 1] || _t('Total'); + var indent = group.labels.length; + var isLeaf = !tree.directSubTrees.size; + var rowGroupBys = this._getGroupBys().rowGroupBys; + + var subGroupMeasurements = columns.map(function (column) { + var colGroupId = column.groupId; + var groupIntersectionId = [rowGroupId[0], colGroupId[1]]; + var measure = column.measure; + var originIndexes = column.originIndexes || [0]; + + var value = self._getCellValue(groupIntersectionId, measure, originIndexes); + + var measurement = { + groupId: groupIntersectionId, + originIndexes: originIndexes, + measure: measure, + value: value, + isBold: !groupIntersectionId[0].length || !groupIntersectionId[1].length, + }; + return measurement; + }); + + rows.push({ + title: title, + label: indent === 0 ? undefined : this.fields[rowGroupBys[indent - 1].split(':')[0]].string, + groupId: rowGroupId, + indent: indent, + isLeaf: isLeaf, + subGroupMeasurements: subGroupMeasurements + }); + + var subTreeKeys = tree.sortedKeys || [...tree.directSubTrees.keys()]; + subTreeKeys.forEach(function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + rows = rows.concat(self._getTableRows(subTree, columns)); + }); + + return rows; + }, + /** + * returns the height of a given groupTree + * + * @private + * @param {Object} tree, a groupTree + * @returns {number} + */ + _getTreeHeight: function (tree) { + var subTreeHeights = [...tree.directSubTrees.values()].map(this._getTreeHeight.bind(this)); + return Math.max(0, Math.max.apply(null, subTreeHeights)) + 1; + }, + /** + * @private + * @returns {boolean} + */ + _hasData: function () { + return (this.counts[JSON.stringify([[], []])] || []).some(function (count) { + return count > 0; + }); + }, + /** + * @override + */ + _isEmpty() { + return !this._hasData(); + }, + /** + * Initilize/Reinitialize this.rowGroupTree, colGroupTree, measurements, + * counts and subdivide the group 'Total' as many times it is necessary. + * A first subdivision with no groupBy (divisors.slice(0, 1)) is made in + * order to see if there is data in the intersection of the group 'Total' + * and the various origins. In case there is none, nonsupplementary rpc + * will be done (see the code of subdivideGroup). + * Once the promise resolves, this.rowGroupTree, colGroupTree, + * measurements, counts are correctly set. + * + * @private + * @return {Promise} + */ + _loadData: function () { + var self = this; + + this.rowGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + this.colGroupTree = { root: { labels: [], values: [] }, directSubTrees: new Map() }; + this.measurements = {}; + this.counts = {}; + + var key = JSON.stringify([[], []]); + this.groupDomains = {}; + this.groupDomains[key] = this.data.domains.slice(0); + + + var group = { rowValues: [], colValues: [] }; + var groupBys = this._getGroupBys(); + var leftDivisors = sections(groupBys.rowGroupBys); + var rightDivisors = sections(groupBys.colGroupBys); + var divisors = cartesian(leftDivisors, rightDivisors); + + return this._subdivideGroup(group, divisors.slice(0, 1)).then(function () { + return self._subdivideGroup(group, divisors.slice(1)); + }); + }, + /** + * Extract the information in the read_group results (groupSubdivisions) + * and develop this.rowGroupTree, colGroupTree, measurements, counts, and + * groupDomains. + * If a column needs to be sorted, the rowGroupTree corresponding to the + * group is sorted. + * + * @private + * @param {Object} group + * @param {Object[]} groupSubdivisions + */ + _prepareData: function (group, groupSubdivisions) { + var self = this; + + var groupRowValues = group.rowValues; + var groupRowLabels = []; + var rowSubTree = this.rowGroupTree; + var root; + if (groupRowValues.length) { + // we should have labels information on hand! regretful! + rowSubTree = this._findGroup(this.rowGroupTree, groupRowValues); + root = rowSubTree.root; + groupRowLabels = root.labels; + } + + var groupColValues = group.colValues; + var groupColLabels = []; + if (groupColValues.length) { + root = this._findGroup(this.colGroupTree, groupColValues).root; + groupColLabels = root.labels; + } + + groupSubdivisions.forEach(function (groupSubdivision) { + groupSubdivision.subGroups.forEach(function (subGroup) { + + var rowValues = groupRowValues.concat(self._getGroupValues(subGroup, groupSubdivision.rowGroupBy)); + var rowLabels = groupRowLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.rowGroupBy)); + + var colValues = groupColValues.concat(self._getGroupValues(subGroup, groupSubdivision.colGroupBy)); + var colLabels = groupColLabels.concat(self._getGroupLabels(subGroup, groupSubdivision.colGroupBy)); + + if (!colValues.length && rowValues.length) { + self._addGroup(self.rowGroupTree, rowLabels, rowValues); + } + if (colValues.length && !rowValues.length) { + self._addGroup(self.colGroupTree, colLabels, colValues); + } + + var key = JSON.stringify([rowValues, colValues]); + var originIndex = groupSubdivision.group.originIndex; + + if (!(key in self.measurements)) { + self.measurements[key] = self.data.origins.map(function () { + return self._getMeasurements({}); + }); + } + self.measurements[key][originIndex] = self._getMeasurements(subGroup); + + if (!(key in self.counts)) { + self.counts[key] = self.data.origins.map(function () { + return 0; + }); + } + self.counts[key][originIndex] = subGroup.__count; + + if (!(key in self.groupDomains)) { + self.groupDomains[key] = self.data.origins.map(function () { + return Domain.FALSE_DOMAIN; + }); + } + // if __domain is not defined this means that we are in the + // case where + // groupSubdivision.rowGroupBy = groupSubdivision.rowGroupBy = [] + if (subGroup.__domain) { + self.groupDomains[key][originIndex] = subGroup.__domain; + } + }); + }); + + if (this.data.sortedColumn) { + this.sortRows(this.data.sortedColumn, rowSubTree); + } + }, + /** + * In the preview implementation of the pivot view (a.k.a. version 2), + * the virtual field used to display the number of records was named + * __count__, whereas __count is actually the one used in xml. So + * basically, activating a filter specifying __count as measures crashed. + * Unfortunately, as __count__ was used in the JS, all filters saved as + * favorite at that time were saved with __count__, and not __count. + * So in order the make them still work with the new implementation, we + * handle both __count__ and __count. + * + * This function replaces in the given array of measures occurences of + * '__count__' by '__count'. + * + * @private + * @param {Array[string] || undefined} measures + * @returns {Array[string] || undefined} + */ + _processMeasures: function (measures) { + if (measures) { + return _.map(measures, function (measure) { + return measure === '__count__' ? '__count' : measure; + }); + } + }, + /** + * 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(comparisonRange), this.data.domain.concat(range)]; + this.data.origins = [comparisonRangeDescription, rangeDescription]; + } else { + this.data.domains = [this.data.domain]; + this.data.origins = [""]; + } + }, + /** + * Make any group in tree a leaf if it was a leaf in oldTree. + * + * @private + * @param {Object} tree + * @param {Object} oldTree + */ + _pruneTree: function (tree, oldTree) { + if (!oldTree.directSubTrees.size) { + tree.directSubTrees.clear(); + delete tree.sortedKeys; + return; + } + var self = this; + [...tree.directSubTrees.keys()].forEach(function (subTreeKey) { + var subTree = tree.directSubTrees.get(subTreeKey); + if (!oldTree.directSubTrees.has(subTreeKey)) { + subTree.directSubTrees.clear(); + delete subTreeKey.sortedKeys; + } else { + var oldSubTree = oldTree.directSubTrees.get(subTreeKey); + self._pruneTree(subTree, oldSubTree); + } + }); + }, + /** + * Toggle the active state for a given measure, then reload the data + * if this turns out to be necessary. + * + * @param {string} fieldName + * @returns {Promise} + */ + _toggleMeasure: function (fieldName) { + var index = this.data.measures.indexOf(fieldName); + if (index !== -1) { + this.data.measures.splice(index, 1); + // in this case, we already have all data in memory, no need to + // actually reload a lesser amount of information + return Promise.resolve(); + } else { + this.data.measures.push(fieldName); + } + return this._loadData(); + }, + /** + * Extract from a groupBy value a label. + * + * @private + * @param {any} value + * @param {string} groupBy + * @returns {string} + */ + _sanitizeLabel: function (value, groupBy) { + var fieldName = groupBy.split(':')[0]; + if (value === false) { + return _t("Undefined"); + } + if (value instanceof Array) { + return this._getNumberedLabel(value, fieldName); + } + if (fieldName && this.fields[fieldName] && (this.fields[fieldName].type === 'selection')) { + var selected = _.where(this.fields[fieldName].selection, { 0: value })[0]; + return selected ? selected[1] : value; + } + return value; + }, + /** + * Extract from a groupBy value the raw value of that groupBy (discarding + * a label if any) + * + * @private + * @param {any} value + * @returns {any} + */ + _sanitizeValue: function (value) { + if (value instanceof Array) { + return value[0]; + } + return value; + }, + /** + * Get all partitions of a given group using the provided list of divisors + * and enrich the objects of this.rowGroupTree, colGroupTree, + * measurements, counts. + * + * @private + * @param {Object} group + * @param {Array[]} divisors + * @returns + */ + _subdivideGroup: function (group, divisors) { + var self = this; + + var key = JSON.stringify([group.rowValues, group.colValues]); + + var proms = this.data.origins.reduce( + function (acc, origin, originIndex) { + // if no information on group content is available, we fetch data. + // if group is known to be empty for the given origin, + // we don't need to fetch data fot that origin. + if (!self.counts[key] || self.counts[key][originIndex] > 0) { + var subGroup = { + rowValues: group.rowValues, + colValues: group.colValues, + originIndex: originIndex + }; + divisors.forEach(function (divisor) { + acc.push(self._getGroupSubdivision(subGroup, divisor[0], divisor[1])); + }); + } + return acc; + }, + [] + ); + return this._loadDataDropPrevious.add(Promise.all(proms)).then(function (groupSubdivisions) { + if (groupSubdivisions.length) { + self._prepareData(group, groupSubdivisions); + } + }); + }, + /** + * Sort recursively the subTrees of tree using sortFunction. + * In the end each node of the tree has its direct children sorted + * according to the criterion reprensented by sortFunction. + * + * @private + * @param {Function} sortFunction + * @param {Object} tree + */ + _sortTree: function (sortFunction, tree) { + var self = this; + tree.sortedKeys = _.sortBy([...tree.directSubTrees.keys()], sortFunction(tree)); + [...tree.directSubTrees.values()].forEach(function (subTree) { + self._sortTree(sortFunction, subTree); + }); + }, +}); + +return PivotModel; + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js new file mode 100644 index 00000000..dcba95bc --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -0,0 +1,202 @@ +odoo.define('web.PivotRenderer', function (require) { + "use strict"; + + const OwlAbstractRenderer = require('web.AbstractRendererOwl'); + const field_utils = require('web.field_utils'); + const patchMixin = require('web.patchMixin'); + + const { useExternalListener, useState, onMounted, onPatched } = owl.hooks; + + /** + * Here is a basic example of the structure of the Pivot Table: + * + * ┌─────────────────────────┬─────────────────────────────────────────────┬─────────────────┐ + * │ │ - web.PivotHeader │ │ + * │ ├──────────────────────┬──────────────────────┤ │ + * │ │ + web.PivotHeader │ + web.PivotHeader │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ │ web.PivotMeasure │ web.PivotMeasure │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ ─ web.PivotHeader │ │ │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ + web.PivotHeader │ │ │ │ + * ├─────────────────────────┼──────────────────────┼──────────────────────┼─────────────────┤ + * │ + web.PivotHeader │ │ │ │ + * └─────────────────────────┴──────────────────────┴──────────────────────┴─────────────────┘ + * + */ + + class PivotRenderer extends OwlAbstractRenderer { + /** + * @override + * @param {boolean} props.disableLinking Disallow opening records by clicking on a cell + * @param {Object} props.widgets Widgets defined in the arch + */ + constructor() { + super(...arguments); + this.sampleDataTargets = ['table']; + this.state = useState({ + activeNodeHeader: { + groupId: false, + isXAxis: false, + click: false + }, + }); + + onMounted(() => this._updateTooltip()); + + onPatched(() => this._updateTooltip()); + + useExternalListener(window, 'click', this._resetState); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * Get the formatted value of the cell + * + * @private + * @param {Object} cell + * @returns {string} Formatted value + */ + _getFormattedValue(cell) { + const type = this.props.widgets[cell.measure] || + (this.props.fields[cell.measure].type === 'many2one' ? 'integer' : this.props.fields[cell.measure].type); + const formatter = field_utils.format[type]; + return formatter(cell.value, this.props.fields[cell.measure]); + } + + /** + * Get the formatted variation of a cell + * + * @private + * @param {Object} cell + * @returns {string} Formatted variation + */ + _getFormattedVariation(cell) { + const value = cell.value; + return isNaN(value) ? '-' : field_utils.format.percentage(value, this.props.fields[cell.measure]); + } + + /** + * Retrieves the padding of a left header + * + * @private + * @param {Object} cell + * @returns {Number} Padding + */ + _getPadding(cell) { + return 5 + cell.indent * 30; + } + + /** + * Compute if a cell is active (with its groupId) + * + * @private + * @param {Array} groupId GroupId of a cell + * @param {Boolean} isXAxis true if the cell is on the x axis + * @returns {Boolean} true if the cell is active + */ + _isClicked(groupId, isXAxis) { + return _.isEqual(groupId, this.state.activeNodeHeader.groupId) && this.state.activeNodeHeader.isXAxis === isXAxis; + } + + /** + * Reset the state of the node. + * + * @private + */ + _resetState() { + // This check is used to avoid the destruction of the dropdown. + // The click on the header bubbles to window in order to hide + // all the other dropdowns (in this component or other components). + // So we need isHeaderClicked to cancel this behaviour. + if (this.isHeaderClicked) { + this.isHeaderClicked = false; + return; + } + this.state.activeNodeHeader = { + groupId: false, + isXAxis: false, + click: false + }; + } + + /** + * Configure the tooltips on the headers. + * + * @private + */ + _updateTooltip() { + $(this.el).find('.o_pivot_header_cell_opened, .o_pivot_header_cell_closed').tooltip(); + } + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + + /** + * Handles a click on a menu item in the dropdown to select a groupby. + * + * @private + * @param {Object} field + * @param {string} interval + */ + _onClickMenuGroupBy(field, interval) { + this.trigger('groupby_menu_selection', { field, interval }); + } + + + /** + * Handles a click on a header node + * + * @private + * @param {Object} cell + * @param {string} type col or row + */ + _onHeaderClick(cell, type) { + const groupValues = cell.groupId[type === 'col' ? 1 : 0]; + const groupByLength = type === 'col' ? this.props.colGroupBys.length : this.props.rowGroupBys.length; + if (cell.isLeaf && groupValues.length >= groupByLength) { + this.isHeaderClicked = true; + this.state.activeNodeHeader = { + groupId: cell.groupId, + isXAxis: type === 'col', + click: 'leftClick' + }; + } + this.trigger(cell.isLeaf ? 'closed_header_click' : 'opened_header_click', { cell, type }); + } + + /** + * Hover the column in which the mouse is. + * + * @private + * @param {MouseEvent} ev + */ + _onMouseEnter(ev) { + var index = [...ev.currentTarget.parentNode.children].indexOf(ev.currentTarget); + if (ev.currentTarget.tagName === 'TH') { + index += 1; + } + this.el.querySelectorAll('td:nth-child(' + (index + 1) + ')').forEach(elt => elt.classList.add('o_cell_hover')); + } + + /** + * Remove the hover on the columns. + * + * @private + */ + _onMouseLeave() { + this.el.querySelectorAll('.o_cell_hover').forEach(elt => elt.classList.remove('o_cell_hover')); + } + } + + PivotRenderer.template = 'web.PivotRenderer'; + + return patchMixin(PivotRenderer); + +}); diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js new file mode 100644 index 00000000..ea3ab9c7 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -0,0 +1,158 @@ +odoo.define('web.PivotView', function (require) { + "use strict"; + + /** + * The Pivot View is a view that represents data in a 'pivot grid' form. It + * aggregates data on 2 dimensions and displays the result, allows the user to + * 'zoom in' data. + */ + + const AbstractView = require('web.AbstractView'); + const config = require('web.config'); + const core = require('web.core'); + const PivotModel = require('web.PivotModel'); + const PivotController = require('web.PivotController'); + const PivotRenderer = require('web.PivotRenderer'); + const RendererWrapper = require('web.RendererWrapper'); + + const _t = core._t; + const _lt = core._lt; + + const searchUtils = require('web.searchUtils'); + const GROUPABLE_TYPES = searchUtils.GROUPABLE_TYPES; + + const PivotView = AbstractView.extend({ + display_name: _lt('Pivot'), + icon: 'fa-table', + config: Object.assign({}, AbstractView.prototype.config, { + Model: PivotModel, + Controller: PivotController, + Renderer: PivotRenderer, + }), + viewType: 'pivot', + searchMenuTypes: ['filter', 'groupBy', 'comparison', 'favorite'], + + /** + * @override + * @param {Object} params + * @param {Array} params.additionalMeasures + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + + const activeMeasures = []; // Store the defined active measures + const colGroupBys = []; // Store the defined group_by used on cols + const rowGroupBys = []; // Store the defined group_by used on rows + const measures = {}; // All the available measures + const groupableFields = {}; // The fields which can be used to group data + const widgets = {}; // Wigdets defined in the arch + const additionalMeasures = params.additionalMeasures || []; + + this.fields.__count = { string: _t("Count"), type: "integer" }; + + //Compute the measures and the groupableFields + Object.keys(this.fields).forEach(name => { + const field = this.fields[name]; + if (name !== 'id' && field.store === true) { + if (['integer', 'float', 'monetary'].includes(field.type) || additionalMeasures.includes(name)) { + measures[name] = field; + } + if (GROUPABLE_TYPES.includes(field.type)) { + groupableFields[name] = field; + } + } + }); + measures.__count = { string: _t("Count"), type: "integer" }; + + + this.arch.children.forEach(field => { + let name = field.attrs.name; + // Remove invisible fields from the measures if not in additionalMeasures + if (field.attrs.invisible && py.eval(field.attrs.invisible)) { + if (name in groupableFields) { + delete groupableFields[name]; + } + if (!additionalMeasures.includes(name)) { + delete measures[name]; + return; + } + } + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + if (field.attrs.widget) { + widgets[name] = field.attrs.widget; + } + // add active measures to the measure list. This is very rarely + // necessary, but it can be useful if one is working with a + // functional field non stored, but in a model with an overrided + // read_group method. In this case, the pivot view could work, and + // the measure should be allowed. However, be careful if you define + // a measure in your pivot view: non stored functional fields will + // probably not work (their aggregate will always be 0). + if (field.attrs.type === 'measure' && !(name in measures)) { + measures[name] = this.fields[name]; + } + if (field.attrs.string && name in measures) { + measures[name].string = field.attrs.string; + } + if (field.attrs.type === 'measure' || 'operator' in field.attrs) { + activeMeasures.push(name); + measures[name] = this.fields[name]; + } + if (field.attrs.type === 'col') { + colGroupBys.push(name); + } + if (field.attrs.type === 'row') { + rowGroupBys.push(name); + } + }); + if ((!activeMeasures.length) || this.arch.attrs.display_quantity) { + activeMeasures.splice(0, 0, '__count'); + } + + this.loadParams.measures = activeMeasures; + this.loadParams.colGroupBys = config.device.isMobile ? [] : colGroupBys; + this.loadParams.rowGroupBys = rowGroupBys; + this.loadParams.fields = this.fields; + this.loadParams.default_order = params.default_order || this.arch.attrs.default_order; + this.loadParams.groupableFields = groupableFields; + + const disableLinking = !!(this.arch.attrs.disable_linking && + JSON.stringify(this.arch.attrs.disable_linking)); + + this.rendererParams.widgets = widgets; + this.rendererParams.disableLinking = disableLinking; + + this.controllerParams.disableLinking = disableLinking; + this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled"); + this.controllerParams.measures = measures; + + // retrieve form and list view ids from the action to open those views + // when a data cell of the pivot view is clicked + this.controllerParams.views = [ + _findView(params.actionViews, 'list'), + _findView(params.actionViews, 'form'), + ]; + + function _findView(views, viewType) { + const view = views.find(view => { + return view.type === viewType; + }); + return [view ? view.viewID : false, viewType]; + } + }, + + /** + * + * @override + */ + getRenderer(parent, state) { + state = Object.assign(state || {}, this.rendererParams); + return new RendererWrapper(parent, this.config.Renderer, state); + }, + }); + + return PivotView; + +}); diff --git a/addons/web/static/src/js/views/qweb/qweb_view.js b/addons/web/static/src/js/views/qweb/qweb_view.js new file mode 100644 index 00000000..4e7f8024 --- /dev/null +++ b/addons/web/static/src/js/views/qweb/qweb_view.js @@ -0,0 +1,208 @@ +/** + * Client-side implementation of a qweb view. + */ +odoo.define('web.qweb', function (require) { +"use strict"; + +var core = require('web.core'); +var AbstractView = require('web.AbstractView'); +var AbstractModel = require('web.AbstractModel'); +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractController = require('web.AbstractController'); +var registry = require('web.view_registry'); + +var _lt = core._lt; + +/** + * model + */ +var Model = AbstractModel.extend({ + /** + * init + */ + init: function () { + this._super.apply(this, arguments); + this._state = { + viewId: false, + modelName: false, + body: '', + context: {}, + domain: [], + }; + }, + /** + * fetches the rendered qweb view + */ + _fetch: function () { + var state = this._state; + return this._rpc({ + model: state.modelName, + method: 'qweb_render_view', + kwargs: { + view_id: state.viewId, + domain: state.domain, + context: state.context + } + }).then(function (r) { + state.body = r; + return state.viewId; + }); + }, + /** + * get + */ + __get: function () { + return this._state; + }, + /** + * load + */ + __load: function (params) { + _.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context'])); + + return this._fetch(); + }, + /** + * reload + */ + __reload: function (_id, params) { + _.extend(this._state, _.pick(params, ['domain', 'context'])); + + return this._fetch(); + } +}); +/** + * renderer + */ +var Renderer = AbstractRenderer.extend({ + /** + * render + */ + _render: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.$el.html(self.state.body); + }); + } +}); +/** + * controller + */ +var Controller = AbstractController.extend({ + events: _.extend({}, AbstractController.prototype.events, { + 'click [type="toggle"]': '_onLazyToggle', + 'click [type="action"]' : '_onActionClicked', + }), + + init: function () { + this._super.apply(this, arguments); + }, + + /** + * @override + */ + renderButtons: function ($node) { + this.$buttons = $('<nav/>'); + if ($node) { + $node.append(this.$buttons); + } + }, + _update: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + // move control panel buttons from the view to the control panel + // area + var $cp_buttons = self.renderer.$('nav.o_qweb_cp_buttons'); + $cp_buttons.children().appendTo(self.$buttons.empty()); + $cp_buttons.remove(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Lazy toggle. Content is not remembered between unfolds. + */ + _onLazyToggle: function (e) { + // TODO: add support for view (possibly action as well?) + var $target = $(e.target); + var $t = $target.closest('[data-model]'); + if (!($target.hasClass('fa-caret-down') || $target.hasClass('fa-caret-right'))) { + $target = $t.find('.fa-caret-down, .fa-caret-right'); + } + + var data = $t.data(); + if (this._fold($t)) { + $target.removeClass('fa-caret-down').addClass('fa-caret-right'); + return; + } + + // NB: $.data() automatically parses json attributes, but does not + // automatically parse lone float literals in data-*, so a + // data-args (as a json object) is very convenient + var args = data.args || _.omit(data, 'model', 'method', 'id'); + + return this._rpc({ + model: data.model, + method: data.method, + args: data.id ? [data.id] : undefined, + kwargs: args // FIXME: context? + }).then(function (s) { + return $(s); + }).then(function ($newcontent) { + $t.data('children', $newcontent).after($newcontent); + $target.removeClass('fa-caret-right').addClass('fa-caret-down'); + }); + }, + /** + * Attempts to fold the parameter, returns whether that happened. + */ + _fold: function ($el) { + var $children = $el.data('children'); + if (!$children) { + return false; + } + + var self = this; + $children.each(function (_i, e) { + self._fold($(e)); + }).remove(); + $el.removeData('children'); + return true; + } +}); + +/** + * view + */ +var QWebView = AbstractView.extend({ + display_name: _lt('Freedom View'), + icon: 'fa-file-picture-o', + viewType: 'qweb', + // groupable? + enableTimeRangeMenu: true, + config: _.extend({}, AbstractView.prototype.config, { + Model: Model, + Renderer: Renderer, + Controller: Controller, + }), + + /** + * init method + */ + init: function (viewInfo, params) { + this._super.apply(this, arguments); + this.loadParams.viewId = viewInfo.view_id; + } +}); + +registry.add('qweb', QWebView); +return { + View: QWebView, + Controller: Controller, + Renderer: Renderer, + Model: Model +}; +}); diff --git a/addons/web/static/src/js/views/renderer_wrapper.js b/addons/web/static/src/js/views/renderer_wrapper.js new file mode 100644 index 00000000..d8fd3843 --- /dev/null +++ b/addons/web/static/src/js/views/renderer_wrapper.js @@ -0,0 +1,15 @@ +odoo.define('web.RendererWrapper', function (require) { + "use strict"; + + const { ComponentWrapper } = require('web.OwlCompatibility'); + + class RendererWrapper extends ComponentWrapper { + getLocalState() { } + setLocalState() { } + giveFocus() { } + resetLocalState() { } + } + + return RendererWrapper; + +}); diff --git a/addons/web/static/src/js/views/sample_server.js b/addons/web/static/src/js/views/sample_server.js new file mode 100644 index 00000000..8af27feb --- /dev/null +++ b/addons/web/static/src/js/views/sample_server.js @@ -0,0 +1,692 @@ +odoo.define('web.SampleServer', function (require) { + "use strict"; + + const session = require('web.session'); + const utils = require('web.utils'); + const Registry = require('web.Registry'); + + class UnimplementedRouteError extends Error {} + + /** + * Helper function returning the value from a list of sample strings + * corresponding to the given ID. + * @param {number} id + * @param {string[]} sampleTexts + * @returns {string} + */ + function getSampleFromId(id, sampleTexts) { + return sampleTexts[(id - 1) % sampleTexts.length]; + } + + /** + * Helper function returning a regular expression specifically matching + * a given 'term' in a fieldName. For example `fieldNameRegex('abc')`: + * will match: + * - "abc" + * - "field_abc__def" + * will not match: + * - "aabc" + * - "abcd_ef" + * @param {...string} term + * @returns {RegExp} + */ + function fieldNameRegex(...terms) { + return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`); + } + + const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message'); + const EMAIL_REGEX = fieldNameRegex('email'); + const PHONE_REGEX = fieldNameRegex('phone'); + const URL_REGEX = fieldNameRegex('url'); + + /** + * Sample server class + * + * Represents a static instance of the server used when a RPC call sends + * empty values/groups while the attribute 'sample' is set to true on the + * view. + * + * This server will generate fake data and send them in the adequate format + * according to the route/method used in the RPC. + */ + class SampleServer { + + /** + * @param {string} modelName + * @param {Object} fields + */ + constructor(modelName, fields) { + this.mainModel = modelName; + this.data = {}; + this.data[modelName] = { + fields, + records: [], + }; + // Generate relational fields' co models + for (const fieldName in fields) { + const field = fields[fieldName]; + if (['many2one', 'one2many', 'many2many'].includes(field.type)) { + this.data[field.relation] = this.data[field.relation] || { + fields: { + display_name: { type: 'char' }, + id: { type: 'integer' }, + color: { type: 'integer' }, + }, + records: [], + }; + } + } + // On some models, empty grouped Kanban or List view still contain + // real (empty) groups. In this case, we re-use the result of the + // web_read_group rpc to tweak sample data s.t. those real groups + // contain sample records. + this.existingGroups = null; + // Sample records generation is only done if necessary, so we delay + // it to the first "mockRPC" call. These flags allow us to know if + // the records have been generated or not. + this.populated = false; + this.existingGroupsPopulated = false; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * This is the main entry point of the SampleServer. Mocks a request to + * the server with sample data. + * @param {Object} params + * @returns {any} the result obtained with the sample data + * @throws {Error} If called on a route/method we do not handle + */ + mockRpc(params) { + if (!(params.model in this.data)) { + throw new Error(`SampleServer: unknown model ${params.model}`); + } + this._populateModels(); + switch (params.method || params.route) { + case '/web/dataset/search_read': + return this._mockSearchReadController(params); + case 'web_read_group': + return this._mockWebReadGroup(params); + case 'read_group': + return this._mockReadGroup(params); + case 'read_progress_bar': + return this._mockReadProgressBar(params); + case 'read': + return this._mockRead(params); + } + // this rpc can't be mocked by the SampleServer itself, so check if there is an handler + // in the mockRegistry: either specific for this model (with key 'model/method'), or + // global (with key 'method') + const method = params.method || params.route; + const mockFunction = SampleServer.mockRegistry.get(`${params.model}/${method}`) || + SampleServer.mockRegistry.get(method); + if (mockFunction) { + return mockFunction.call(this, params); + } + console.log(`SampleServer: unimplemented route "${params.method || params.route}"`); + throw new SampleServer.UnimplementedRouteError(); + } + + setExistingGroups(groups) { + this.existingGroups = groups; + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * @param {Object[]} measures, each measure has the form { fieldName, type } + * @param {Object[]} records + * @returns {Object} + */ + _aggregateFields(measures, records) { + const values = {}; + for (const { fieldName, type } of measures) { + if (['float', 'integer', 'monetary'].includes(type)) { + if (records.length) { + let value = 0; + for (const record of records) { + value += record[fieldName]; + } + values[fieldName] = this._sanitizeNumber(value); + } else { + values[fieldName] = null; + } + } + if (type === 'many2one') { + const ids = new Set(records.map(r => r[fieldName])); + values.fieldName = ids.size || null; + } + } + return values; + } + + /** + * @param {any} value + * @param {Object} options + * @param {string} [options.interval] + * @param {string} [options.relation] + * @param {string} [options.type] + * @returns {any} + */ + _formatValue(value, options) { + if (!value) { + return false; + } + const { type, interval, relation } = options; + if (['date', 'datetime'].includes(type)) { + const fmt = SampleServer.FORMATS[interval]; + return moment(value).format(fmt); + } else if (type === 'many2one') { + const rec = this.data[relation].records.find(({id}) => id === value); + return [value, rec.display_name]; + } else { + return value; + } + } + + /** + * Generates field values based on heuristics according to field types + * and names. + * + * @private + * @param {string} modelName + * @param {string} fieldName + * @param {number} id the record id + * @returns {any} the field value + */ + _generateFieldValue(modelName, fieldName, id) { + const field = this.data[modelName].fields[fieldName]; + switch (field.type) { + case "boolean": + return fieldName === 'active' ? true : this._getRandomBool(); + case "char": + case "text": + if (["display_name", "name"].includes(fieldName)) { + if (SampleServer.PEOPLE_MODELS.includes(modelName)) { + return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE); + } else if (modelName === 'res.country') { + return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES); + } + } + if (fieldName === 'display_name') { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (["name", "reference"].includes(fieldName)) { + return `REF${String(id).padStart(4, '0')}`; + } else if (DESCRIPTION_REGEX.test(fieldName)) { + return getSampleFromId(id, SampleServer.SAMPLE_TEXTS); + } else if (EMAIL_REGEX.test(fieldName)) { + const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE) + .replace(/ /, ".") + .toLowerCase(); + return `${emailName}@sample.demo`; + } else if (PHONE_REGEX.test(fieldName)) { + return `+1 555 754 ${String(id).padStart(4, '0')}`; + } else if (URL_REGEX.test(fieldName)) { + return `http://sample${id}.com`; + } + return false; + case "date": + case "datetime": { + const format = field.type === "date" ? + "YYYY-MM-DD" : + "YYYY-MM-DD HH:mm:ss"; + return this._getRandomDate(format); + } + case "float": + return this._getRandomFloat(SampleServer.MAX_FLOAT); + case "integer": { + let max = SampleServer.MAX_INTEGER; + if (fieldName.includes('color')) { + max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0; + } + return this._getRandomInt(max); + } + case "monetary": + return this._getRandomInt(SampleServer.MAX_MONETARY); + case "many2one": + if (field.relation === 'res.currency') { + return session.company_currency_id; + } + if (field.relation === 'ir.attachment') { + return false; + } + return this._getRandomSubRecordId(); + case "one2many": + case "many2many": { + const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()]; + return [...new Set(ids)]; + } + case "selection": { + // I hoped we wouldn't have to implement such special cases, but here it is. + // If this (mail) field is set, 'Warning' is displayed instead of the last + // activity, and we don't want to see a bunch of 'Warning's in a list. In the + // future, if we have to implement several special cases like that, we'll setup + // a proper hook to allow external modules to define extensions of this function. + // For now, as we have only one use case, I guess that doing it here is fine. + if (fieldName === 'activity_exception_decoration') { + return false; + } + if (field.selection.length > 0) { + return this._getRandomArrayEl(field.selection)[0]; + } + return false; + } + default: + return false; + } + } + + /** + * @private + * @param {any[]} array + * @returns {any} + */ + _getRandomArrayEl(array) { + return array[Math.floor(Math.random() * array.length)]; + } + + /** + * @private + * @returns {boolean} + */ + _getRandomBool() { + return Math.random() < 0.5; + } + + /** + * @private + * @param {string} format + * @returns {moment} + */ + _getRandomDate(format) { + const delta = Math.floor( + (Math.random() - Math.random()) * SampleServer.DATE_DELTA + ); + return new moment() + .add(delta, "hour") + .format(format); + } + + /** + * @private + * @param {number} max + * @returns {number} float in [O, max[ + */ + _getRandomFloat(max) { + return this._sanitizeNumber(Math.random() * max); + } + + /** + * @private + * @param {number} max + * @returns {number} int in [0, max[ + */ + _getRandomInt(max) { + return Math.floor(Math.random() * max); + } + + /** + * @private + * @returns {number} id in [1, SUB_RECORDSET_SIZE] + */ + _getRandomSubRecordId() { + return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1; + } + /** + * Mocks calls to the read method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Array[]} params.args (args[0] is the list of ids, args[1] is + * the list of fields) + * @returns {Object[]} + */ + _mockRead(params) { + const model = this.data[params.model]; + const ids = params.args[0]; + const fieldNames = params.args[1]; + const records = []; + for (const r of model.records) { + if (!ids.includes(r.id)) { + continue; + } + const record = { id: r.id }; + for (const fieldName of fieldNames) { + const field = model.fields[fieldName]; + if (!field) { + record[fieldName] = false; // unknown field + } else if (field.type === 'many2one') { + const relModel = this.data[field.relation]; + const relRecord = relModel.records.find( + relR => r[fieldName] === relR.id + ); + record[fieldName] = relRecord ? + [relRecord.id, relRecord.display_name] : + false; + } else { + record[fieldName] = r[fieldName]; + } + } + records.push(record); + } + return records; + } + + /** + * Mocks calls to the read_group method. + * + * @param {Object} params + * @param {string} params.model + * @param {string[]} [params.fields] defaults to the list of all fields + * @param {string[]} params.groupBy + * @param {boolean} [params.lazy=true] + * @returns {Object[]} Object with keys groups and length + */ + _mockReadGroup(params) { + const lazy = 'lazy' in params ? params.lazy : true; + const model = params.model; + const fields = this.data[model].fields; + const records = this.data[model].records; + + const normalizedGroupBys = []; + let groupBy = []; + if (params.groupBy.length) { + groupBy = lazy ? [params.groupBy[0]] : params.groupBy; + } + for (const groupBySpec of groupBy) { + let [fieldName, interval] = groupBySpec.split(':'); + interval = interval || 'month'; + const { type, relation } = fields[fieldName]; + if (type) { + const gb = { fieldName, type, interval, relation, alias: groupBySpec }; + normalizedGroupBys.push(gb); + } + } + const groups = utils.groupBy(records, (record) => { + const vals = {}; + for (const gb of normalizedGroupBys) { + const { fieldName, type } = gb; + let value; + if (['date', 'datetime'].includes(type)) { + value = this._formatValue(record[fieldName], gb); + } else { + value = record[fieldName]; + } + vals[fieldName] = value; + } + return JSON.stringify(vals); + }); + const measures = []; + for (const measureSpec of (params.fields || Object.keys(fields))) { + const [fieldName, aggregateFunction] = measureSpec.split(':'); + const { type } = fields[fieldName]; + if (!params.groupBy.includes(fieldName) && type && + (type !== 'many2one' || aggregateFunction !== 'count_distinct')) { + measures.push({ fieldName, type }); + } + } + + let result = []; + for (const id in groups) { + const records = groups[id]; + const group = { __domain: [] }; + let countKey = `__count`; + if (normalizedGroupBys.length && lazy) { + countKey = `${normalizedGroupBys[0].fieldName}_count`; + } + group[countKey] = records.length; + const firstElem = records[0]; + for (const gb of normalizedGroupBys) { + const { alias, fieldName } = gb; + group[alias] = this._formatValue(firstElem[fieldName], gb); + } + Object.assign(group, this._aggregateFields(measures, records)); + result.push(group); + } + if (normalizedGroupBys.length > 0) { + const { alias, interval, type } = normalizedGroupBys[0]; + result = utils.sortBy(result, (group) => { + const val = group[alias]; + if (['date', 'datetime'].includes(type)) { + return moment(val, SampleServer.FORMATS[interval]); + } + return val; + }); + } + return result; + } + + /** + * Mocks calls to the read_progress_bar method. + * @private + * @param {Object} params + * @param {string} params.model + * @param {Object} params.kwargs + * @return {Object} + */ + _mockReadProgressBar(params) { + const groupBy = params.kwargs.group_by; + const progress_bar = params.kwargs.progress_bar; + const groupByField = this.data[params.model].fields[groupBy]; + const data = {}; + for (const record of this.data[params.model].records) { + let groupByValue = record[groupBy]; + if (groupByField.type === "many2one") { + const relatedRecords = this.data[groupByField.relation].records; + const relatedRecord = relatedRecords.find(r => r.id === groupByValue); + groupByValue = relatedRecord.display_name; + } + if (!(groupByValue in data)) { + data[groupByValue] = {}; + for (const key in progress_bar.colors) { + data[groupByValue][key] = 0; + } + } + const fieldValue = record[progress_bar.field]; + if (fieldValue in data[groupByValue]) { + data[groupByValue][fieldValue]++; + } + } + return data; + } + + /** + * Mocks calls to the /web/dataset/search_read route to return sample + * records. + * @private + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @returns {{ records: Object[], length: number }} + */ + _mockSearchReadController(params) { + const model = this.data[params.model]; + const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT); + const records = this._mockRead({ + model: params.model, + args: [rawRecords.map(r => r.id), params.fields], + }); + return { records, length: records.length }; + } + + /** + * Mocks calls to the web_read_group method to return groups populated + * with sample records. Only handles the case where the real call to + * web_read_group returned groups, but none of these groups contain + * records. In this case, we keep the real groups, and populate them + * with sample records. + * @private + * @param {Object} params + * @param {Object} [result] the result of a real call to web_read_group + * @returns {{ groups: Object[], length: number }} + */ + _mockWebReadGroup(params) { + let groups; + if (this.existingGroups) { + this._tweakExistingGroups(params); + groups = this.existingGroups; + } else { + groups = this._mockReadGroup(params); + } + return { + groups, + length: groups.length, + }; + } + + /** + * Updates the sample data such that the existing groups (in database) + * also exists in the sample, and such that there are sample records in + * those groups. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.groupBy + */ + _populateExistingGroups(params) { + if (!this.existingGroupsPopulated) { + const groups = this.existingGroups; + this.groupsInfo = groups; + const groupBy = params.groupBy[0]; + const values = groups.map(g => g[groupBy]); + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === 'many2one'; + if (groupedByM2O) { // re-populate co model with relevant records + this.data[groupByField.relation].records = values.map(v => { + return { id: v[0], display_name: v[1] }; + }); + } + for (const r of this.data[params.model].records) { + const value = getSampleFromId(r.id, values); + r[groupBy] = groupedByM2O ? value[0] : value; + } + this.existingGroupsPopulated = true; + } + } + + /** + * Generates sample records for the models in this.data. Records will be + * generated once, and subsequent calls to this function will be skipped. + * @private + */ + _populateModels() { + if (!this.populated) { + for (const modelName in this.data) { + const model = this.data[modelName]; + const fieldNames = Object.keys(model.fields).filter(f => f !== 'id'); + const size = modelName === this.mainModel ? + SampleServer.MAIN_RECORDSET_SIZE : + SampleServer.SUB_RECORDSET_SIZE; + for (let id = 1; id <= size; id++) { + const record = { id }; + for (const fieldName of fieldNames) { + record[fieldName] = this._generateFieldValue(modelName, fieldName, id); + } + model.records.push(record); + } + } + this.populated = true; + } + } + + /** + * Rounds the given number value according to the configured precision. + * @private + * @param {number} value + * @returns {number} + */ + _sanitizeNumber(value) { + return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION)); + } + + /** + * A real (web_)read_group call has been done, and it has returned groups, + * but they are all empty. This function updates the sample data such + * that those group values exist and those groups contain sample records. + * @private + * @param {Object[]} groups empty groups returned by the server + * @param {Object} params + * @param {string} params.model + * @param {string[]} params.fields + * @param {string[]} params.groupBy + * @returns {Object[]} groups with count and aggregate values updated + * + * TODO: rename + */ + _tweakExistingGroups(params) { + const groups = this.existingGroups; + this._populateExistingGroups(params); + + // update count and aggregates for each group + const groupBy = params.groupBy[0].split(':')[0]; + const groupByField = this.data[params.model].fields[groupBy]; + const groupedByM2O = groupByField.type === 'many2one'; + const records = this.data[params.model].records; + for (const g of groups) { + const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy]; + const recordsInGroup = records.filter(r => r[groupBy] === groupValue); + g[`${groupBy}_count`] = recordsInGroup.length; + for (const field of params.fields) { + const fieldType = this.data[params.model].fields[field].type; + if (['integer, float', 'monetary'].includes(fieldType)) { + g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0); + } + } + g.__data = { + records: this._mockRead({ + model: params.model, + args: [recordsInGroup.map(r => r.id), params.fields], + }), + length: recordsInGroup.length, + }; + } + } + } + + SampleServer.FORMATS = { + day: 'YYYY-MM-DD', + week: '[W]ww YYYY', + month: 'MMMM YYYY', + quarter: '[Q]Q YYYY', + year: 'Y', + }; + SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: 'DD MMM YYYY' }); + + SampleServer.MAIN_RECORDSET_SIZE = 16; + SampleServer.SUB_RECORDSET_SIZE = 5; + SampleServer.SEARCH_READ_LIMIT = 10; + + SampleServer.MAX_FLOAT = 100; + SampleServer.MAX_INTEGER = 50; + SampleServer.MAX_COLOR_INT = 7; + SampleServer.MAX_MONETARY = 100000; + SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days + SampleServer.FLOAT_PRECISION = 2; + + SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"]; + SampleServer.SAMPLE_PEOPLE = [ + "John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot", + ]; + SampleServer.SAMPLE_TEXTS = [ + "Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa", + ]; + SampleServer.PEOPLE_MODELS = [ + 'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact' + ]; + + SampleServer.UnimplementedRouteError = UnimplementedRouteError; + + // mockRegistry allows to register mock version of methods or routes, + // for all models: + // SampleServer.mockRegistry.add('some_route', () => "abcd"); + // for a specific model (e.g. 'res.partner'): + // SampleServer.mockRegistry.add('res.partner/some_method', () => 23); + SampleServer.mockRegistry = new Registry(); + + return SampleServer; +}); diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js new file mode 100644 index 00000000..71537847 --- /dev/null +++ b/addons/web/static/src/js/views/search_panel.js @@ -0,0 +1,214 @@ +odoo.define("web/static/src/js/views/search_panel.js", function (require) { + "use strict"; + + const { Model, useModel } = require("web/static/src/js/model.js"); + const patchMixin = require("web.patchMixin"); + + const { Component, hooks } = owl; + const { useState, useSubEnv } = hooks; + + /** + * Search panel + * + * Represent an extension of the search interface located on the left side of + * the view. It is divided in sections defined in a "<searchpanel>" node located + * inside of a "<search>" arch. Each section is represented by a list of different + * values (categories or ungrouped filters) or groups of values (grouped filters). + * Its state is directly affected by its model (@see SearchPanelModelExtension). + * @extends Component + */ + class SearchPanel extends Component { + constructor() { + super(...arguments); + + useSubEnv({ searchModel: this.props.searchModel }); + + this.state = useState({ + active: {}, + expanded: {}, + }); + this.model = useModel("searchModel"); + this.scrollTop = 0; + this.hasImportedState = false; + + this.importState(this.props.importedState); + } + + async willStart() { + this._expandDefaultValue(); + this._updateActiveValues(); + } + + mounted() { + this._updateGroupHeadersChecked(); + if (this.hasImportedState) { + this.el.scroll({ top: this.scrollTop }); + } + } + + async willUpdateProps() { + this._updateActiveValues(); + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + exportState() { + const exported = { + expanded: this.state.expanded, + scrollTop: this.el.scrollTop, + }; + return JSON.stringify(exported); + } + + importState(stringifiedState) { + this.hasImportedState = Boolean(stringifiedState); + if (this.hasImportedState) { + const state = JSON.parse(stringifiedState); + this.state.expanded = state.expanded; + this.scrollTop = state.scrollTop; + } + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Expands category values holding the default value of a category. + * @private + */ + _expandDefaultValue() { + if (this.hasImportedState) { + return; + } + const categories = this.model.get("sections", s => s.type === "category"); + for (const category of categories) { + this.state.expanded[category.id] = {}; + if (category.activeValueId) { + const ancestorIds = this._getAncestorValueIds(category, category.activeValueId); + for (const ancestorId of ancestorIds) { + this.state.expanded[category.id][ancestorId] = true; + } + } + } + } + + /** + * @private + * @param {Object} category + * @param {number} categoryValueId + * @returns {number[]} list of ids of the ancestors of the given value in + * the given category. + */ + _getAncestorValueIds(category, categoryValueId) { + const { parentId } = category.values.get(categoryValueId); + return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : []; + } + + /** + * Prevent unnecessary calls to the model by ensuring a different category + * is clicked. + * @private + * @param {Object} category + * @param {Object} value + */ + async _toggleCategory(category, value) { + if (value.childrenIds.length) { + const categoryState = this.state.expanded[category.id]; + if (categoryState[value.id] && category.activeValueId === value.id) { + delete categoryState[value.id]; + } else { + categoryState[value.id] = true; + } + } + if (category.activeValueId !== value.id) { + this.state.active[category.id] = value.id; + this.model.dispatch("toggleCategoryValue", category.id, value.id); + } + } + + /** + * @private + * @param {number} filterId + * @param {{ values: Map<Object> }} group + */ + _toggleFilterGroup(filterId, { values }) { + const valueIds = []; + const checked = [...values.values()].every( + (value) => this.state.active[filterId][value.id] + ); + values.forEach(({ id }) => { + valueIds.push(id); + this.state.active[filterId][id] = !checked; + }); + this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked); + } + + /** + * @private + * @param {number} filterId + * @param {Object} [group] + * @param {number} valueId + * @param {MouseEvent} ev + */ + _toggleFilterValue(filterId, valueId, { currentTarget }) { + this.state.active[filterId][valueId] = currentTarget.checked; + this._updateGroupHeadersChecked(); + this.model.dispatch("toggleFilterValues", filterId, [valueId]); + } + + _updateActiveValues() { + for (const section of this.model.get("sections")) { + if (section.type === "category") { + this.state.active[section.id] = section.activeValueId; + } else { + this.state.active[section.id] = {}; + if (section.groups) { + for (const group of section.groups.values()) { + for (const value of group.values.values()) { + this.state.active[section.id][value.id] = value.checked; + } + } + } + if (section && section.values) { + for (const value of section.values.values()) { + this.state.active[section.id][value.id] = value.checked; + } + } + } + } + } + + /** + * Updates the "checked" or "indeterminate" state of each of the group + * headers according to the state of their values. + * @private + */ + _updateGroupHeadersChecked() { + const groups = this.el.querySelectorAll(":scope .o_search_panel_filter_group"); + for (const group of groups) { + const header = group.querySelector(":scope .o_search_panel_group_header input"); + const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")]; + header.checked = false; + header.indeterminate = false; + if (vals.every((v) => v.checked)) { + header.checked = true; + } else if (vals.some((v) => v.checked)) { + header.indeterminate = true; + } + } + } + } + SearchPanel.modelExtension = "SearchPanel"; + + SearchPanel.props = { + className: { type: String, optional: 1 }, + importedState: { type: String, optional: 1 }, + searchModel: Model, + }; + SearchPanel.template = "web.SearchPanel"; + + return patchMixin(SearchPanel); +}); diff --git a/addons/web/static/src/js/views/search_panel_model_extension.js b/addons/web/static/src/js/views/search_panel_model_extension.js new file mode 100644 index 00000000..48466f8d --- /dev/null +++ b/addons/web/static/src/js/views/search_panel_model_extension.js @@ -0,0 +1,789 @@ +odoo.define("web/static/src/js/views/search_panel_model_extension.js", function (require) { + "use strict"; + + const ActionModel = require("web/static/src/js/views/action_model.js"); + const { sortBy } = require("web.utils"); + const Domain = require("web.Domain"); + const pyUtils = require("web.py_utils"); + + // DefaultViewTypes is the list of view types for which the searchpanel is + // present by default (if not explicitly stated in the "view_types" attribute + // in the arch). + const DEFAULT_VIEW_TYPES = ["kanban", "tree"]; + const DEFAULT_LIMIT = 200; + let nextSectionId = 1; + + /** + * @param {Filter} filter + * @returns {boolean} + */ + function hasDomain(filter) { + return filter.domain !== "[]"; + } + + /** + * @param {Section} section + * @returns {boolean} + */ + function hasValues({ errorMsg, groups, type, values }) { + if (errorMsg) { + return true; + } else if (groups) { + return [...groups.values()].some((g) => g.values.size); + } else if (type === "category") { + return values && values.size > 1; // false item ignored + } else { + return values && values.size > 0; + } + } + + /** + * Returns a serialised array of the given map with its values being the + * shallow copies of the original values. + * @param {Map<any, Object>} map + * @return {Array[]} + */ + function serialiseMap(map) { + return [...map].map(([key, val]) => [key, Object.assign({}, val)]); + } + + /** + * @typedef Section + * @prop {string} color + * @prop {string} description + * @prop {boolean} enableCounters + * @prop {boolean} expand + * @prop {string} fieldName + * @prop {string} icon + * @prop {number} id + * @prop {number} index + * @prop {number} limit + * @prop {string} type + */ + + /** + * @typedef {Section} Category + * @prop {boolean} hierarchize + */ + + /** + * @typedef {Section} Filter + * @prop {string} domain + * @prop {string} groupBy + */ + + /** + * @function sectionPredicate + * @param {Section} section + * @returns {boolean} + */ + + /** + * @property {{ sections: Map<number, Section> }} state + * @extends ActionModel.Extension + */ + class SearchPanelModelExtension extends ActionModel.Extension { + constructor() { + super(...arguments); + + this.categoriesToLoad = []; + this.defaultValues = {}; + this.filtersToLoad = []; + this.initialStateImport = false; + this.searchDomain = []; + for (const key in this.config.context) { + const match = /^searchpanel_default_(.*)$/.exec(key); + if (match) { + this.defaultValues[match[1]] = this.config.context[key]; + } + } + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * @override + */ + async callLoad(params) { + const searchDomain = this._getExternalDomain(); + params.searchDomainChanged = ( + JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain) + ); + if (!this.shouldLoad && !this.initialStateImport) { + const isFetchable = (section) => section.enableCounters || + (params.searchDomainChanged && !section.expand); + this.categoriesToLoad = this.categories.filter(isFetchable); + this.filtersToLoad = this.filters.filter(isFetchable); + this.shouldLoad = params.searchDomainChanged || + Boolean(this.categoriesToLoad.length + this.filtersToLoad.length); + } + this.searchDomain = searchDomain; + this.initialStateImport = false; + await super.callLoad(params); + } + + /** + * @override + */ + exportState() { + const state = Object.assign({}, super.exportState()); + state.sections = serialiseMap(state.sections); + for (const [id, section] of state.sections) { + section.values = serialiseMap(section.values); + if (section.groups) { + section.groups = serialiseMap(section.groups); + for (const [id, group] of section.groups) { + group.values = serialiseMap(group.values); + } + } + } + return state; + } + + /** + * @override + * @returns {any} + */ + get(property, ...args) { + switch (property) { + case "domain": return this.getDomain(); + case "sections": return this.getSections(...args); + } + } + + /** + * @override + */ + importState(importedState) { + this.initialStateImport = Boolean(importedState && !this.state.sections); + super.importState(...arguments); + if (importedState) { + this.state.sections = new Map(this.state.sections); + for (const section of this.state.sections.values()) { + section.values = new Map(section.values); + if (section.groups) { + section.groups = new Map(section.groups); + for (const group of section.groups.values()) { + group.values = new Map(group.values); + } + } + } + } + } + + /** + * @override + */ + async isReady() { + await this.sectionsPromise; + } + + /** + * @override + */ + async load(params) { + this.sectionsPromise = this._fetchSections(params.isInitialLoad); + if (this._shouldWaitForData(params)) { + await this.sectionsPromise; + } + } + + /** + * @override + */ + prepareState() { + Object.assign(this.state, { sections: new Map() }); + this._createSectionsFromArch(); + } + + //--------------------------------------------------------------------- + // Actions / Getters + //--------------------------------------------------------------------- + + /** + * Returns the concatenation of the category domain ad the filter + * domain. + * @returns {Array[]} + */ + getDomain() { + return Domain.prototype.normalizeArray([ + ...this._getCategoryDomain(), + ...this._getFilterDomain(), + ]); + } + + /** + * Returns a sorted list of a copy of all sections. This list can be + * filtered by a given predicate. + * @param {sectionPredicate} [predicate] used to determine + * which subsets of sections is wanted + * @returns {Section[]} + */ + getSections(predicate) { + let sections = [...this.state.sections.values()].map((section) => + Object.assign({}, section, { empty: !hasValues(section) }) + ); + if (predicate) { + sections = sections.filter(predicate); + } + return sections.sort((s1, s2) => s1.index - s2.index); + } + + /** + * Sets the active value id of a given category. + * @param {number} sectionId + * @param {number} valueId + */ + toggleCategoryValue(sectionId, valueId) { + const category = this.state.sections.get(sectionId); + category.activeValueId = valueId; + } + + /** + * Toggles a the filter value of a given section. The value will be set + * to "forceTo" if provided, else it will be its own opposed value. + * @param {number} sectionId + * @param {number[]} valueIds + * @param {boolean} [forceTo=null] + */ + toggleFilterValues(sectionId, valueIds, forceTo = null) { + const section = this.state.sections.get(sectionId); + for (const valueId of valueIds) { + const value = section.values.get(valueId); + value.checked = forceTo === null ? !value.checked : forceTo; + } + } + + //--------------------------------------------------------------------- + // Internal getters + //--------------------------------------------------------------------- + + /** + * Shorthand access to sections of type "category". + * @returns {Category[]} + */ + get categories() { + return [...this.state.sections.values()].filter(s => s.type === "category"); + } + + /** + * Shorthand access to sections of type "filter". + * @returns {Filter[]} + */ + get filters() { + return [...this.state.sections.values()].filter(s => s.type === "filter"); + } + + //--------------------------------------------------------------------- + // Private + //--------------------------------------------------------------------- + + /** + * Sets active values for each filter (coming from context). This needs + * to be done only once at initialization. + * @private + */ + _applyDefaultFilterValues() { + for (const { fieldName, values } of this.filters) { + const defaultValues = this.defaultValues[fieldName] || []; + for (const valueId of defaultValues) { + const value = values.get(valueId); + if (value) { + value.checked = true; + } + } + } + } + + /** + * @private + * @param {string} sectionId + * @param {Object} result + */ + _createCategoryTree(sectionId, result) { + const category = this.state.sections.get(sectionId); + + let { error_msg, parent_field: parentField, values, } = result; + if (error_msg) { + category.errorMsg = error_msg; + values = []; + } + if (category.hierarchize) { + category.parentField = parentField; + } + for (const value of values) { + category.values.set( + value.id, + Object.assign({}, value, { + childrenIds: [], + parentId: value[parentField] || false, + }) + ); + } + for (const value of values) { + const { parentId } = category.values.get(value.id); + if (parentId && category.values.has(parentId)) { + category.values.get(parentId).childrenIds.push(value.id); + } + } + // collect rootIds + category.rootIds = [false]; + for (const value of values) { + const { parentId } = category.values.get(value.id); + if (!parentId) { + category.rootIds.push(value.id); + } + } + // Set active value from context + const valueIds = [false, ...values.map((val) => val.id)]; + this._ensureCategoryValue(category, valueIds); + } + + /** + * @private + * @param {string} sectionId + * @param {Object} result + */ + _createFilterTree(sectionId, result) { + const filter = this.state.sections.get(sectionId); + + let { error_msg, values, } = result; + if (error_msg) { + filter.errorMsg = error_msg; + values = []; + } + + // restore checked property + values.forEach((value) => { + const oldValue = filter.values.get(value.id); + value.checked = oldValue ? oldValue.checked : false; + }); + + filter.values = new Map(); + const groupIds = []; + if (filter.groupBy) { + const groups = new Map(); + for (const value of values) { + const groupId = value.group_id; + if (!groups.has(groupId)) { + if (groupId) { + groupIds.push(groupId); + } + groups.set(groupId, { + id: groupId, + name: value.group_name, + values: new Map(), + tooltip: value.group_tooltip, + sequence: value.group_sequence, + hex_color: value.group_hex_color, + }); + // restore former checked state + const oldGroup = + filter.groups && filter.groups.get(groupId); + groups.get(groupId).state = + (oldGroup && oldGroup.state) || false; + } + groups.get(groupId).values.set(value.id, value); + } + filter.groups = groups; + filter.sortedGroupIds = sortBy( + groupIds, + (id) => groups.get(id).sequence || groups.get(id).name + ); + for (const group of filter.groups.values()) { + for (const [valueId, value] of group.values) { + filter.values.set(valueId, value); + } + } + } else { + for (const value of values) { + filter.values.set(value.id, value); + } + } + } + + /** + * Adds a section in this.state.sections for each visible field found + * in the search panel arch. + * @private + */ + _createSectionsFromArch() { + let hasCategoryWithCounters = false; + let hasFilterWithDomain = false; + this.config.archNodes.forEach(({ attrs, tag }, index) => { + if (tag !== "field" || attrs.invisible === "1") { + return; + } + const type = attrs.select === "multi" ? "filter" : "category"; + const section = { + color: attrs.color, + description: + attrs.string || this.config.fields[attrs.name].string, + enableCounters: !!pyUtils.py_eval( + attrs.enable_counters || "0" + ), + expand: !!pyUtils.py_eval(attrs.expand || "0"), + fieldName: attrs.name, + icon: attrs.icon, + id: nextSectionId++, + index, + limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)), + type, + values: new Map(), + }; + if (type === "category") { + section.activeValueId = this.defaultValues[attrs.name]; + section.icon = section.icon || "fa-folder"; + section.hierarchize = !!pyUtils.py_eval( + attrs.hierarchize || "1" + ); + section.values.set(false, { + childrenIds: [], + display_name: this.env._t("All"), + id: false, + bold: true, + parentId: false, + }); + hasCategoryWithCounters = hasCategoryWithCounters || section.enableCounters; + } else { + section.domain = attrs.domain || "[]"; + section.groupBy = attrs.groupby; + section.icon = section.icon || "fa-filter"; + hasFilterWithDomain = hasFilterWithDomain || section.domain !== "[]"; + } + this.state.sections.set(section.id, section); + }); + /** + * Category counters are automatically disabled if a filter domain is found + * to avoid inconsistencies with the counters. The underlying problem could + * actually be solved by reworking the search panel and the way the + * counters are computed, though this is not the current priority + * considering the time it would take, hence this quick "fix". + */ + if (hasCategoryWithCounters && hasFilterWithDomain) { + // If incompatibilities are found -> disables all category counters + for (const category of this.categories) { + category.enableCounters = false; + } + // ... and triggers a warning + console.warn( + "Warning: categories with counters are incompatible with filters having a domain attribute.", + "All category counters have been disabled to avoid inconsistencies.", + ); + } + } + + /** + * Ensures that the active value of a category is one of its own + * existing values. + * @private + * @param {Category} category + * @param {number[]} valueIds + */ + _ensureCategoryValue(category, valueIds) { + if (!valueIds.includes(category.activeValueId)) { + category.activeValueId = valueIds[0]; + } + } + + /** + * Fetches values for each category at startup. At reload a category is + * only fetched if needed. + * @private + * @param {Category[]} categories + * @returns {Promise} resolved when all categories have been fetched + */ + async _fetchCategories(categories) { + const filterDomain = this._getFilterDomain(); + await Promise.all(categories.map(async (category) => { + const result = await this.env.services.rpc({ + method: "search_panel_select_range", + model: this.config.modelName, + args: [category.fieldName], + kwargs: { + category_domain: this._getCategoryDomain(category.id), + enable_counters: category.enableCounters, + expand: category.expand, + filter_domain: filterDomain, + hierarchize: category.hierarchize, + limit: category.limit, + search_domain: this.searchDomain, + }, + }); + this._createCategoryTree(category.id, result); + })); + } + + /** + * Fetches values for each filter. This is done at startup and at each + * reload if needed. + * @private + * @param {Filter[]} filters + * @returns {Promise} resolved when all filters have been fetched + */ + async _fetchFilters(filters) { + const evalContext = {}; + for (const category of this.categories) { + evalContext[category.fieldName] = category.activeValueId; + } + const categoryDomain = this._getCategoryDomain(); + await Promise.all(filters.map(async (filter) => { + const result = await this.env.services.rpc({ + method: "search_panel_select_multi_range", + model: this.config.modelName, + args: [filter.fieldName], + kwargs: { + category_domain: categoryDomain, + comodel_domain: Domain.prototype.stringToArray( + filter.domain, + evalContext + ), + enable_counters: filter.enableCounters, + filter_domain: this._getFilterDomain(filter.id), + expand: filter.expand, + group_by: filter.groupBy || false, + group_domain: this._getGroupDomain(filter), + limit: filter.limit, + search_domain: this.searchDomain, + }, + }); + this._createFilterTree(filter.id, result); + })); + } + + /** + * @private + * @param {boolean} isInitialLoad + * @returns {Promise} + */ + async _fetchSections(isInitialLoad) { + await this._fetchCategories( + isInitialLoad ? this.categories : this.categoriesToLoad + ); + await this._fetchFilters( + isInitialLoad ? this.filters : this.filtersToLoad + ); + if (isInitialLoad) { + this._applyDefaultFilterValues(); + } + } + + /** + * Computes and returns the domain based on the current active + * categories. If "excludedCategoryId" is provided, the category with + * that id is not taken into account in the domain computation. + * @private + * @param {string} [excludedCategoryId] + * @returns {Array[]} + */ + _getCategoryDomain(excludedCategoryId) { + const domain = []; + for (const category of this.categories) { + if ( + category.id === excludedCategoryId || + !category.activeValueId + ) { + continue; + } + const field = this.config.fields[category.fieldName]; + const operator = + field.type === "many2one" && category.parentField ? "child_of" : "="; + domain.push([ + category.fieldName, + operator, + category.activeValueId, + ]); + } + return domain; + } + + /** + * Returns the domain retrieved from the other model extensions. + * @private + * @returns {Array[]} + */ + _getExternalDomain() { + const domains = this.config.get("domain"); + const domain = domains.reduce((acc, dom) => [...acc, ...dom], []); + return Domain.prototype.normalizeArray(domain); + } + + /** + * Computes and returns the domain based on the current checked + * filters. The values of a single filter are combined using a simple + * rule: checked values within a same group are combined with an "OR" + * operator (this is expressed as single condition using a list) and + * groups are combined with an "AND" operator (expressed by + * concatenation of conditions). + * If a filter has no group, its checked values are implicitely + * considered as forming a group (and grouped using an "OR"). + * If excludedFilterId is provided, the filter with that id is not + * taken into account in the domain computation. + * @private + * @param {string} [excludedFilterId] + * @returns {Array[]} + */ + _getFilterDomain(excludedFilterId) { + const domain = []; + + function addCondition(fieldName, valueMap) { + const ids = []; + for (const [valueId, value] of valueMap) { + if (value.checked) { + ids.push(valueId); + } + } + if (ids.length) { + domain.push([fieldName, "in", ids]); + } + } + + for (const filter of this.filters) { + if (filter.id === excludedFilterId) { + continue; + } + const { fieldName, groups, values } = filter; + if (groups) { + for (const group of groups.values()) { + addCondition(fieldName, group.values); + } + } else { + addCondition(fieldName, values); + } + } + return domain; + } + + /** + * Returns a domain or an object of domains used to complement + * the filter domains to accurately describe the constrains on + * records when computing record counts associated to the filter + * values (if a groupBy is provided). The idea is that the checked + * values within a group should not impact the counts for the other + * values in the same group. + * @private + * @param {string} filter + * @returns {Object<string, Array[]> | Array[] | null} + */ + _getGroupDomain(filter) { + const { fieldName, groups, enableCounters } = filter; + const { type: fieldType } = this.config.fields[fieldName]; + + if (!enableCounters || !groups) { + return { + many2one: [], + many2many: {}, + }[fieldType]; + } + let groupDomain = null; + if (fieldType === "many2one") { + for (const group of groups.values()) { + const valueIds = []; + let active = false; + for (const [valueId, value] of group.values) { + const { checked } = value; + valueIds.push(valueId); + if (checked) { + active = true; + } + } + if (active) { + if (groupDomain) { + groupDomain = [[0, "=", 1]]; + break; + } else { + groupDomain = [[fieldName, "in", valueIds]]; + } + } + } + } else if (fieldType === "many2many") { + const checkedValueIds = new Map(); + groups.forEach(({ values }, groupId) => { + values.forEach(({ checked }, valueId) => { + if (checked) { + if (!checkedValueIds.has(groupId)) { + checkedValueIds.set(groupId, []); + } + checkedValueIds.get(groupId).push(valueId); + } + }); + }); + groupDomain = {}; + for (const [gId, ids] of checkedValueIds.entries()) { + for (const groupId of groups.keys()) { + if (gId !== groupId) { + const key = JSON.stringify(groupId); + if (!groupDomain[key]) { + groupDomain[key] = []; + } + groupDomain[key].push([fieldName, "in", ids]); + } + } + } + } + return groupDomain; + } + + /** + * Returns whether the query informations should be considered as ready + * before or after having (re-)fetched the sections data. + * @private + * @param {Object} params + * @param {boolean} params.isInitialLoad + * @param {boolean} params.searchDomainChanged + * @returns {boolean} + */ + _shouldWaitForData({ isInitialLoad, searchDomainChanged }) { + if (isInitialLoad && Object.keys(this.defaultValues).length) { + // Default values need to be checked on initial load + return true; + } + if (this.categories.length && this.filters.some(hasDomain)) { + // Selected category value might affect the filter values + return true; + } + if (!this.searchDomain.length) { + // No search domain -> no need to check for expand + return false; + } + return [...this.state.sections.values()].some( + (section) => !section.expand && searchDomainChanged + ); + } + + //--------------------------------------------------------------------- + // Static + //--------------------------------------------------------------------- + + /** + * @override + * @returns {{ attrs: Object, children: Object[] } | null} + */ + static extractArchInfo(archs, viewType) { + const { children } = archs.search; + const spNode = children.find(c => c.tag === "searchpanel"); + const isObject = (obj) => typeof obj === "object" && obj !== null; + if (spNode) { + const actualType = viewType === "list" ? "tree" : viewType; + const { view_types } = spNode.attrs; + const viewTypes = view_types ? + view_types.split(",") : + DEFAULT_VIEW_TYPES; + if (viewTypes.includes(actualType)) { + return { + attrs: spNode.attrs, + children: spNode.children.filter(isObject), + }; + } + } + return null; + } + } + SearchPanelModelExtension.layer = 1; + + ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30); + + return SearchPanelModelExtension; +}); diff --git a/addons/web/static/src/js/views/select_create_controllers_registry.js b/addons/web/static/src/js/views/select_create_controllers_registry.js new file mode 100644 index 00000000..2cbc0fd0 --- /dev/null +++ b/addons/web/static/src/js/views/select_create_controllers_registry.js @@ -0,0 +1,60 @@ +odoo.define('web.select_create_controllers_registry', function (require) { +"use strict"; + +return {}; + +}); + +odoo.define('web._select_create_controllers_registry', function (require) { +"use strict"; + +var KanbanController = require('web.KanbanController'); +var ListController = require('web.ListController'); +var select_create_controllers_registry = require('web.select_create_controllers_registry'); + +var SelectCreateKanbanController = KanbanController.extend({ + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Override to select the clicked record instead of opening it + * + * @override + * @private + */ + _onOpenRecord: function (ev) { + var selectedRecord = this.model.get(ev.data.id); + this.trigger_up('select_record', { + id: selectedRecord.res_id, + display_name: selectedRecord.data.display_name, + }); + }, +}); + +var SelectCreateListController = ListController.extend({ + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Override to select the clicked record instead of opening it + * + * @override + * @private + */ + _onOpenRecord: function (ev) { + var selectedRecord = this.model.get(ev.data.id); + this.trigger_up('select_record', { + id: selectedRecord.res_id, + display_name: selectedRecord.data.display_name, + }); + }, +}); + +_.extend(select_create_controllers_registry, { + SelectCreateListController: SelectCreateListController, + SelectCreateKanbanController: SelectCreateKanbanController, +}); + +}); diff --git a/addons/web/static/src/js/views/signature_dialog.js b/addons/web/static/src/js/views/signature_dialog.js new file mode 100644 index 00000000..12bb18f4 --- /dev/null +++ b/addons/web/static/src/js/views/signature_dialog.js @@ -0,0 +1,121 @@ +odoo.define('web.signature_dialog', function (require) { +"use strict"; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var NameAndSignature = require('web.name_and_signature').NameAndSignature; + +var _t = core._t; + +// The goal of this dialog is to ask the user a signature request. +// It uses @see SignNameAndSignature for the name and signature fields. +var SignatureDialog = Dialog.extend({ + template: 'web.signature_dialog', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/web/static/src/xml/name_and_signature.xml'] + ), + custom_events: { + 'signature_changed': '_onChangeSignature', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {Object} options + * @param {string} [options.title='Adopt Your Signature'] - modal title + * @param {string} [options.size='medium'] - modal size + * @param {Object} [options.nameAndSignatureOptions={}] - options for + * @see NameAndSignature.init() + */ + init: function (parent, options) { + var self = this; + options = options || {}; + + options.title = options.title || _t("Adopt Your Signature"); + options.size = options.size || 'medium'; + options.technical = false; + + if (!options.buttons) { + options.buttons = []; + options.buttons.push({text: _t("Adopt and Sign"), classes: "btn-primary", disabled: true, click: function (e) { + self._onConfirm(); + }}); + options.buttons.push({text: _t("Cancel"), close: true}); + } + + this._super(parent, options); + + this.nameAndSignature = new NameAndSignature(this, options.nameAndSignatureOptions); + }, + /** + * Start the nameAndSignature widget and wait for it. + * + * @override + */ + willStart: function () { + return Promise.all([ + this.nameAndSignature.appendTo($('<div>')), + this._super.apply(this, arguments) + ]); + }, + /** + * Initialize the name and signature widget when the modal is opened. + * + * @override + */ + start: function () { + var self = this; + this.$primaryButton = this.$footer.find('.btn-primary'); + + this.opened().then(function () { + self.$('.o_web_sign_name_and_signature').replaceWith(self.nameAndSignature.$el); + // initialize the signature area + self.nameAndSignature.resetSignature(); + }); + + return this._super.apply(this, arguments); + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * Returns whether the drawing area is currently empty. + * + * @see NameAndSignature.isSignatureEmpty() + * @returns {boolean} Whether the drawing area is currently empty. + */ + isSignatureEmpty: function () { + return this.nameAndSignature.isSignatureEmpty(); + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * Toggles the submit button depending on the signature state. + * + * @private + */ + _onChangeSignature: function () { + var isEmpty = this.nameAndSignature.isSignatureEmpty(); + this.$primaryButton.prop('disabled', isEmpty); + }, + /** + * Upload the signature image when confirm. + * + * @private + */ + _onConfirm: function (fct) { + this.trigger_up('upload_signature', { + name: this.nameAndSignature.getName(), + signatureImage: this.nameAndSignature.getSignatureImage(), + }); + }, +}); + +return SignatureDialog; + +}); diff --git a/addons/web/static/src/js/views/standalone_field_manager_mixin.js b/addons/web/static/src/js/views/standalone_field_manager_mixin.js new file mode 100644 index 00000000..501ecf7c --- /dev/null +++ b/addons/web/static/src/js/views/standalone_field_manager_mixin.js @@ -0,0 +1,64 @@ +odoo.define('web.StandaloneFieldManagerMixin', function (require) { +"use strict"; + + +var FieldManagerMixin = require('web.FieldManagerMixin'); + +/** + * The StandaloneFieldManagerMixin is a mixin, designed to be used by a widget + * that instanciates its own field widgets. + * + * @mixin + * @name StandaloneFieldManagerMixin + * @mixes FieldManagerMixin + * @property {Function} _confirmChange + * @property {Function} _registerWidget + */ +var StandaloneFieldManagerMixin = _.extend({}, FieldManagerMixin, { + + /** + * @override + */ + init: function () { + FieldManagerMixin.init.apply(this, arguments); + + // registeredWidgets is a dict of all field widgets used by the widget + this.registeredWidgets = {}; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * This method will be called whenever a field value has changed (and has + * been confirmed by the model). + * + * @private + * @param {string} id basicModel Id for the changed record + * @param {string[]} fields the fields (names) that have been changed + * @param {OdooEvent} event the event that triggered the change + * @returns {Promise} + */ + _confirmChange: function (id, fields, event) { + var result = FieldManagerMixin._confirmChange.apply(this, arguments); + var record = this.model.get(id); + _.each(this.registeredWidgets[id], function (widget, fieldName) { + if (_.contains(fields, fieldName)) { + widget.reset(record, event); + } + }); + return result; + }, + + _registerWidget: function (datapointID, fieldName, widget) { + if (!this.registeredWidgets[datapointID]) { + this.registeredWidgets[datapointID] = {}; + } + this.registeredWidgets[datapointID][fieldName] = widget; + }, +}); + +return StandaloneFieldManagerMixin; + +}); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js new file mode 100644 index 00000000..21004ed9 --- /dev/null +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -0,0 +1,484 @@ +odoo.define('web.view_dialogs', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var dom = require('web.dom'); +var view_registry = require('web.view_registry'); +var select_create_controllers_registry = require('web.select_create_controllers_registry'); + +var _t = core._t; + +/** + * Class with everything which is common between FormViewDialog and + * SelectCreateDialog. + */ +var ViewDialog = Dialog.extend({ + custom_events: _.extend({}, Dialog.prototype.custom_events, { + push_state: '_onPushState', + }), + /** + * @constructor + * @param {Widget} parent + * @param {options} [options] + * @param {string} [options.dialogClass=o_act_window] + * @param {string} [options.res_model] the model of the record(s) to open + * @param {any[]} [options.domain] + * @param {Object} [options.context] + */ + init: function (parent, options) { + options = options || {}; + options.fullscreen = config.device.isMobile; + options.dialogClass = options.dialogClass || '' + ' o_act_window'; + + this._super(parent, $.extend(true, {}, options)); + + this.res_model = options.res_model || null; + this.domain = options.domain || []; + this.context = options.context || {}; + this.options = _.extend(this.options || {}, options || {}); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * We stop all push_state events from bubbling up. It would be weird to + * change the url because a dialog opened. + * + * @param {OdooEvent} event + */ + _onPushState: function (event) { + event.stopPropagation(); + }, +}); + +/** + * Create and edit dialog (displays a form view record and leave once saved) + */ +var FormViewDialog = ViewDialog.extend({ + /** + * @param {Widget} parent + * @param {Object} [options] + * @param {string} [options.parentID] the id of the parent record. It is + * useful for situations such as a one2many opened in a form view dialog. + * In that case, we want to be able to properly evaluate domains with the + * 'parent' key. + * @param {integer} [options.res_id] the id of the record to open + * @param {Object} [options.form_view_options] dict of options to pass to + * the Form View @todo: make it work + * @param {Object} [options.fields_view] optional form fields_view + * @param {boolean} [options.readonly=false] only applicable when not in + * creation mode + * @param {boolean} [options.deletable=false] whether or not the record can + * be deleted + * @param {boolean} [options.disable_multiple_selection=false] set to true + * to remove the possibility to create several records in a row + * @param {function} [options.on_saved] callback executed after saving a + * record. It will be called with the record data, and a boolean which + * indicates if something was changed + * @param {function} [options.on_remove] callback executed when the user + * clicks on the 'Remove' button + * @param {BasicModel} [options.model] if given, it will be used instead of + * a new form view model + * @param {string} [options.recordID] if given, the model has to be given as + * well, and in that case, it will be used without loading anything. + * @param {boolean} [options.shouldSaveLocally] if true, the view dialog + * will save locally instead of actually saving (useful for one2manys) + * @param {function} [options._createContext] function to get context for name field + * useful for many2many_tags widget where we want to removed default_name field + * context. + */ + init: function (parent, options) { + var self = this; + options = options || {}; + + this.res_id = options.res_id || null; + this.on_saved = options.on_saved || (function () {}); + this.on_remove = options.on_remove || (function () {}); + this.context = options.context; + this._createContext = options._createContext; + this.model = options.model; + this.parentID = options.parentID; + this.recordID = options.recordID; + this.shouldSaveLocally = options.shouldSaveLocally; + this.readonly = options.readonly; + this.deletable = options.deletable; + this.disable_multiple_selection = options.disable_multiple_selection; + var oBtnRemove = 'o_btn_remove'; + + var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection; + var readonly = _.isNumber(options.res_id) && options.readonly; + + if (!options.buttons) { + options.buttons = [{ + text: options.close_text || (readonly ? _t("Close") : _t("Discard")), + classes: "btn-secondary o_form_button_cancel", + close: true, + click: function () { + if (!readonly) { + self.form_view.model.discardChanges(self.form_view.handle, { + rollback: self.shouldSaveLocally, + }); + } + }, + }]; + + if (!readonly) { + options.buttons.unshift({ + text: options.save_text || (multi_select ? _t("Save & Close") : _t("Save")), + classes: "btn-primary", + click: function () { + self._save().then(self.close.bind(self)); + } + }); + + if (multi_select) { + options.buttons.splice(1, 0, { + text: _t("Save & New"), + classes: "btn-primary", + click: function () { + self._save() + .then(function () { + // reset default name field from context when Save & New is clicked, pass additional + // context so that when getContext is called additional context resets it + var additionalContext = self._createContext && self._createContext(false) || {}; + self.form_view.createRecord(self.parentID, additionalContext); + }) + .then(function () { + if (!self.deletable) { + return; + } + self.deletable = false; + self.buttons = self.buttons.filter(function (button) { + return button.classes.split(' ').indexOf(oBtnRemove) < 0; + }); + self.set_buttons(self.buttons); + self.set_title(_t("Create ") + _.str.strRight(self.title, _t("Open: "))); + }); + }, + }); + } + + var multi = options.disable_multiple_selection; + if (!multi && this.deletable) { + this._setRemoveButtonOption(options, oBtnRemove); + } + } + } + this._super(parent, options); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the form view dialog. It is necessarily asynchronous, but this + * method returns immediately. + * + * @returns {FormViewDialog} this instance + */ + open: function () { + var self = this; + var _super = this._super.bind(this); + var FormView = view_registry.get('form'); + var fields_view_def; + if (this.options.fields_view) { + fields_view_def = Promise.resolve(this.options.fields_view); + } else { + fields_view_def = this.loadFieldView(this.res_model, this.context, this.options.view_id, 'form'); + } + + fields_view_def.then(function (viewInfo) { + var refinedContext = _.pick(self.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); + var formview = new FormView(viewInfo, { + modelName: self.res_model, + context: refinedContext, + ids: self.res_id ? [self.res_id] : [], + currentId: self.res_id || undefined, + index: 0, + mode: self.res_id && self.options.readonly ? 'readonly' : 'edit', + footerToButtons: true, + default_buttons: false, + withControlPanel: false, + model: self.model, + parentID: self.parentID, + recordID: self.recordID, + isFromFormViewDialog: true, + }); + return formview.getController(self); + }).then(function (formView) { + self.form_view = formView; + var fragment = document.createDocumentFragment(); + if (self.recordID && self.shouldSaveLocally) { + self.model.save(self.recordID, {savePoint: true}); + } + return self.form_view.appendTo(fragment) + .then(function () { + self.opened().then(function () { + var $buttons = $('<div>'); + self.form_view.renderButtons($buttons); + if ($buttons.children().length) { + self.$footer.empty().append($buttons.contents()); + } + dom.append(self.$el, fragment, { + callbacks: [{widget: self.form_view}], + in_DOM: true, + }); + self.form_view.updateButtons(); + }); + return _super(); + }); + }); + + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _focusOnClose: function() { + var isFocusSet = false; + this.trigger_up('form_dialog_discarded', { + callback: function (isFocused) { + isFocusSet = isFocused; + }, + }); + return isFocusSet; + }, + + /** + * @private + */ + _remove: function () { + return Promise.resolve(this.on_remove()); + }, + + /** + * @private + * @returns {Promise} + */ + _save: function () { + var self = this; + return this.form_view.saveRecord(this.form_view.handle, { + stayInEdit: true, + reload: false, + savePoint: this.shouldSaveLocally, + viewType: 'form', + }).then(function (changedFields) { + // record might have been changed by the save (e.g. if this was a new record, it has an + // id now), so don't re-use the copy obtained before the save + var record = self.form_view.model.get(self.form_view.handle); + return self.on_saved(record, !!changedFields.length); + }); + }, + + /** + * Set the "remove" button into the options' buttons list + * + * @private + * @param {Object} options The options object to modify + * @param {string} btnClasses The classes for the remove button + */ + _setRemoveButtonOption(options, btnClasses) { + const self = this; + options.buttons.push({ + text: _t("Remove"), + classes: 'btn-secondary ' + btnClasses, + click: function() { + self._remove().then(self.close.bind(self)); + } + }); + }, +}); + +/** + * Search dialog (displays a list of records and permits to create a new one by switching to a form view) + */ +var SelectCreateDialog = ViewDialog.extend({ + custom_events: _.extend({}, ViewDialog.prototype.custom_events, { + select_record: function (event) { + if (!this.options.readonly) { + this.on_selected([event.data]); + this.close(); + } + }, + selection_changed: function (event) { + event.stopPropagation(); + this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length); + }, + }), + + /** + * options: + * - initial_ids + * - initial_view: form or search (default search) + * - list_view_options: dict of options to pass to the List View + * - on_selected: optional callback to execute when records are selected + * - disable_multiple_selection: true to allow create/select multiple records + * - dynamicFilters: filters to add to the searchview + */ + init: function () { + this._super.apply(this, arguments); + _.defaults(this.options, { initial_view: 'search' }); + this.on_selected = this.options.on_selected || (function () {}); + this.on_closed = this.options.on_closed || (function () {}); + this.initialIDs = this.options.initial_ids; + this.viewType = 'list'; + }, + + open: function () { + if (this.options.initial_view !== "search") { + return this.create_edit_record(); + } + var self = this; + var _super = this._super.bind(this); + var viewRefID = this.viewType === 'kanban' ? + (this.options.kanban_view_ref && JSON.parse(this.options.kanban_view_ref) || false) : false; + return this.loadViews(this.res_model, this.context, [[viewRefID, this.viewType], [false, 'search']], {load_filters: true}) + .then(this.setup.bind(this)) + .then(function (fragment) { + self.opened().then(function () { + dom.append(self.$el, fragment, { + callbacks: [{widget: self.viewController}], + in_DOM: true, + }); + self.set_buttons(self.__buttons); + }); + return _super(); + }); + }, + + setup: function (fieldsViews) { + var self = this; + var fragment = document.createDocumentFragment(); + + var domain = this.domain; + if (this.initialIDs) { + domain = domain.concat([['id', 'in', this.initialIDs]]); + } + var ViewClass = view_registry.get(this.viewType); + var viewOptions = {}; + var selectCreateController; + if (this.viewType === 'list') { // add listview specific options + _.extend(viewOptions, { + hasSelectors: !this.options.disable_multiple_selection, + readonly: true, + + }, this.options.list_view_options); + selectCreateController = select_create_controllers_registry.SelectCreateListController; + } + if (this.viewType === 'kanban') { + _.extend(viewOptions, { + noDefaultGroupby: true, + selectionMode: this.options.selectionMode || false, + }); + selectCreateController = select_create_controllers_registry.SelectCreateKanbanController; + } + var view = new ViewClass(fieldsViews[this.viewType], _.extend(viewOptions, { + action: { + controlPanelFieldsView: fieldsViews.search, + help: _.str.sprintf("<p>%s</p>", _t("No records found!")), + }, + action_buttons: false, + dynamicFilters: this.options.dynamicFilters, + context: this.context, + domain: domain, + modelName: this.res_model, + withBreadcrumbs: false, + withSearchPanel: false, + })); + view.setController(selectCreateController); + return view.getController(this).then(function (controller) { + self.viewController = controller; + // render the footer buttons + self._prepareButtons(); + return self.viewController.appendTo(fragment); + }).then(function () { + return fragment; + }); + }, + close: function () { + this._super.apply(this, arguments); + this.on_closed(); + }, + create_edit_record: function () { + var self = this; + var dialog = new FormViewDialog(this, _.extend({}, this.options, { + on_saved: function (record) { + var values = [{ + id: record.res_id, + display_name: record.data.display_name || record.data.name, + }]; + self.on_selected(values); + }, + })).open(); + dialog.on('closed', this, this.close); + return dialog; + }, + /** + * @override + */ + _focusOnClose: function() { + var isFocusSet = false; + this.trigger_up('form_dialog_discarded', { + callback: function (isFocused) { + isFocusSet = isFocused; + }, + }); + return isFocusSet; + }, + /** + * prepare buttons for dialog footer based on options + * + * @private + */ + _prepareButtons: function () { + this.__buttons = [{ + text: _t("Cancel"), + classes: 'btn-secondary o_form_button_cancel', + close: true, + }]; + if (!this.options.no_create) { + this.__buttons.unshift({ + text: _t("Create"), + classes: 'btn-primary', + click: this.create_edit_record.bind(this) + }); + } + if (!this.options.disable_multiple_selection) { + this.__buttons.unshift({ + text: _t("Select"), + classes: 'btn-primary o_select_button', + disabled: true, + close: true, + click: function () { + var records = this.viewController.getSelectedRecords(); + var values = _.map(records, function (record) { + return { + id: record.res_id, + display_name: record.data.display_name, + }; + }); + this.on_selected(values); + }, + }); + } + }, +}); + +return { + FormViewDialog: FormViewDialog, + SelectCreateDialog: SelectCreateDialog, +}; + +}); diff --git a/addons/web/static/src/js/views/view_registry.js b/addons/web/static/src/js/views/view_registry.js new file mode 100644 index 00000000..f936787b --- /dev/null +++ b/addons/web/static/src/js/views/view_registry.js @@ -0,0 +1,44 @@ +odoo.define('web.view_registry', function (require) { +"use strict"; + +/** + * This module defines the view_registry. Web views are added to the registry + * in the 'web._view_registry' module to avoid cyclic dependencies. + * Views defined in other addons should be added in this registry as well, + * ideally in another module than the one defining the view, in order to + * separate the declarative part of a module (the view definition) from its + * 'side-effects' part. + */ + +var Registry = require('web.Registry'); + +return new Registry(); + +}); + +odoo.define('web._view_registry', function (require) { +"use strict"; + +/** + * The purpose of this module is to add the web views in the view_registry. + * This can't be done directly in the module defining the view_registry as it + * would produce cyclic dependencies. + */ + +var FormView = require('web.FormView'); +var GraphView = require('web.GraphView'); +var KanbanView = require('web.KanbanView'); +var ListView = require('web.ListView'); +var PivotView = require('web.PivotView'); +var CalendarView = require('web.CalendarView'); +var view_registry = require('web.view_registry'); + +view_registry + .add('form', FormView) + .add('list', ListView) + .add('kanban', KanbanView) + .add('graph', GraphView) + .add('pivot', PivotView) + .add('calendar', CalendarView); + +}); diff --git a/addons/web/static/src/js/views/view_utils.js b/addons/web/static/src/js/views/view_utils.js new file mode 100644 index 00000000..82cefe5b --- /dev/null +++ b/addons/web/static/src/js/views/view_utils.js @@ -0,0 +1,92 @@ +odoo.define('web.viewUtils', function (require) { +"use strict"; + +var dom = require('web.dom'); +var utils = require('web.utils'); + +var viewUtils = { + /** + * Returns the value of a group dataPoint, i.e. the value of the groupBy + * field for the records in that group. + * + * @param {Object} group dataPoint of type list, corresponding to a group + * @param {string} groupByField the name of the groupBy field + * @returns {string | integer | false} + */ + getGroupValue: function (group, groupByField) { + var groupedByField = group.fields[groupByField]; + switch (groupedByField.type) { + case 'many2one': + return group.res_id || false; + case 'selection': + var descriptor = _.find(groupedByField.selection, function (option) { + return option[1] === group.value; + }); + return descriptor && descriptor[0]; + case 'char': + case 'boolean': + return group.value; + default: + return false; // other field types are not handled + } + }, + /** + * States whether or not the quick create feature is available for the given + * datapoint, depending on its groupBy field. + * + * @param {Object} list dataPoint of type list + * @returns {Boolean} true iff the kanban quick create feature is available + */ + isQuickCreateEnabled: function (list) { + var groupByField = list.groupedBy[0] && list.groupedBy[0].split(':')[0]; + if (!groupByField) { + return false; + } + var availableTypes = ['char', 'boolean', 'many2one', 'selection']; + if (!_.contains(availableTypes, list.fields[groupByField].type)) { + return false; + } + return true; + }, + /** + * @param {string} arch view arch + * @returns {Object} parsed arch + */ + parseArch: function (arch) { + var doc = $.parseXML(arch).documentElement; + var stripWhitespaces = doc.nodeName.toLowerCase() !== 'kanban'; + return utils.xml_to_json(doc, stripWhitespaces); + }, + /** + * Renders a button according to a given arch node element. + * + * @param {Object} node + * @param {Object} [options] + * @param {string} [options.extraClass] + * @param {boolean} [options.textAsTitle=false] + * @returns {jQuery} + */ + renderButtonFromNode: function (node, options) { + var btnOptions = { + attrs: _.omit(node.attrs, 'icon', 'string', 'type', 'attrs', 'modifiers', 'options', 'effect'), + icon: node.attrs.icon, + }; + if (options && options.extraClass) { + var classes = btnOptions.attrs.class ? btnOptions.attrs.class.split(' ') : []; + btnOptions.attrs.class = _.uniq(classes.concat(options.extraClass.split(' '))).join(' '); + } + var str = (node.attrs.string || '').replace(/_/g, ''); + if (str) { + if (options && options.textAsTitle) { + btnOptions.attrs.title = str; + } else { + btnOptions.text = str; + } + } + return dom.renderButton(btnOptions); + }, +}; + +return viewUtils; + +}); |
