diff options
| author | Géry Debongnie <ged@odoo.com> | 2017-03-05 10:05:56 +0100 |
|---|---|---|
| committer | Aaron Bohy <aab@odoo.com> | 2017-04-11 19:44:38 +0200 |
| commit | 905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch) | |
| tree | 265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/views | |
| parent | 54d4c73af4ad05c7640d28c1892247c9538b8017 (diff) | |
[REF] web, *: redesign all JS views
This commit introduce a full redesign of all JS views. We started
basically from scratch. The goal was to unify all the various views
under a common framework, to make them testable, to make then usable in
different conditions (in studio, or in the frontend), and to make our
lives easier.
Some important points are:
- we introduced new coding guidelines (camelCase, 80 chars width, ...)
- we have a brand new testing framework (still QUnit based)
- kanban view moved to the web addon
- calendar view (formerly web_calender) moved to web as well
- the tree view was removed
- all new code should be documented
We hope that this code is the start of a new era for the Odoo web
client, we want to have a high quality codebase, well documented, well
tested, well designed.
Work done by the framework team: mostly aab, ged, chm, dmo, qsm
Diffstat (limited to 'addons/web/static/src/js/views')
54 files changed, 12118 insertions, 13593 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 00000000000..b90b1448ce2 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_controller.js @@ -0,0 +1,498 @@ +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 Widget = require('web.Widget'); + + +var AbstractController = Widget.extend({ + custom_events: { + open_record: '_onOpenRecord', + }, + + /** + * @constructor + * @param {Widget} parent + * @param {AbstractModel} model + * @param {AbstractRenderer} renderer + * @param {object} params + * @param {string} params.modelName + * @param {any} [params.handle] a handle that will be given to the model (some id) + * @param {any} params.initialState the initialState + * @param {string} [params.noContentHelp] + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.model = model; + this.renderer = renderer; + this.modelName = params.modelName; + this.handle = params.handle; + this.activeActions = params.activeActions; + this.initialState = params.initialState; + this.noContentHelp = params.noContentHelp; + }, + /** + * Simply renders and updates the url. + * + * @returns {Deferred} + */ + start: function () { + return $.when( + this._super.apply(this, arguments), + this.renderer.appendTo(this.$el) + ).then(this._update.bind(this, this.initialState)); + }, + destroy: function () { + if (this.$buttons) { + this.$buttons.off(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns any special keys that may be useful when reloading the view to + * get the same effect. This is necessary for saving the current view in + * the favorites. For example, a graph view might want to add a key to + * save the current graph type. + * + * @returns {Object} + */ + getContext: function () { + return {}; + }, + /** + * @see setScrollTop + * @returns {number} + */ + getScrollTop: function () { + return this.scrollTop; + }, + /** + * Returns a title that may be displayed in the breadcrumb area. For + * example, the name of the record. + * + * Note: this seems wrong right now, it should not be implemented, we have + * no guarantee that there is a display_name variable in a controller. + * + * @returns {string} + */ + getTitle: function () { + return this.display_name; + }, + /** + * 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] This object will simply be given to the update + * @returns {Deferred} + */ + reload: function (params) { + return this.update(params || {}); + }, + /** + * Most likely called by the view manager, this method is responsible for + * adding buttons in the control panel (buttons such as save/discard/...) + * + * Note that there is no guarantee that this method will be called. The + * controller is supposed to work even without a view manager, for example + * in the frontend (odoo frontend = public website) + * + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { + }, + /** + * For views that require a pager, this method will be called to allow the + * controller to instantiate and render a pager. Note that in theory, the + * controller can actually render whatever he wants in the pager zone. If + * your view does not want a pager, just let this method empty. + * + * @param {Query Node} $node + */ + renderPager: function ($node) { + }, + /** + * Same as renderPager, but for the 'sidebar' zone (the zone with the menu + * dropdown in the control panel next to the buttons) + * + * @param {Query Node} $node + */ + renderSidebar: function ($node) { + }, + /** + * Not sure about this one, it probably needs to be reworked, maybe merged + * in get/set local state methods. + * + * @see getScrollTop + * @param {number} scrollTop + */ + setScrollTop: function (scrollTop) { + this.scrollTop = scrollTop; + }, + /** + * 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 {Deferred} + */ + update: function (params, options) { + var self = this; + var shouldReload = (options && 'reload' in options) ? options.reload : true; + var def = shouldReload ? this.model.reload(this.handle, params) : $.when(); + return def.then(function (handle) { + self.handle = handle || self.handle; // update handle if we reloaded + var state = self.model.get(self.handle); + var localState = self.renderer.getLocalState(); + return self.renderer.updateState(state, params).then(function () { + self.renderer.setLocalState(localState); + self._update(state); + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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. + * + * @param {Object} [state] information that will be pushed to the outside + * world + */ + _pushState: function (state) { + this.trigger_up('push_state', state || {}); + }, + /** + * Hide or show the nocontent helper. For this, it also remove the renderer + * from the dom, and reattach it when necessary. + * + * This method is a helper for controllers that want to display a help + * message when no content is available. It is suggested to override + * _update to call this method. + * + * @param {boolean} hasNoContent + */ + _toggleNoContentHelper: function (hasNoContent) { + if (hasNoContent) { + this.renderer.$el.detach(); + var $msg = $('<div>') + .addClass('oe_view_nocontent') + .html(this.noContentHelp); + this.$el.html($msg); + } else { + if (!document.contains(this.renderer.el)) { + this.$('div.oe_view_nocontent').remove(); + this.$el.append(this.renderer.$el); + } + } + }, + /** + * 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. + * + * @param {Object} state the state given by the model + * @returns {Deferred} + */ + _update: function (state) { + this._pushState(); + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * 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? or view manager + * + * @param {OdooEvent} event + * @param {number} event.data.id The local model ID for the record to be + * opened + * @param {string} [event.data.mode='readonly'] + */ + _onOpenRecord: function (event) { + var record = this.model.get(event.data.id, {raw: true}); + this.trigger_up('switch_view', { + view_type: 'form', + res_id: record.res_id, + mode: event.data.mode || 'readonly', + }); + }, + +}); + +return AbstractController; + +}); + + // do_show: function () { + // this._super.apply(this, arguments); + // core.bus.trigger('view_shown', this); + // }, + // do_push_state: function (state) { + // if (this.getParent() && this.getParent().do_push_state) { + // this.getParent().do_push_state(state); + // } + // }, + // do_load_state: function (state, warm) { + // }, + /** + * Switches to a specific view type + */ + // do_switch_view: function () { + // this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments))); + // }, + // hasNoContent: function (state) { + // return !state.count && this.no_content_help; + // }, + // displayNoContentHelp: function () { + // var $msg = $('<div>') + // .addClass('oe_view_nocontent') + // .html(this.no_content_help); + // this.$el.append($msg); + // return; + // }, + // open_record: function (id, options) { + // // TODO: move this to view manager at some point + // var state = this.model.get(this.db_id); + // var record = this.model.get(id); + // var res_ids; + // if (state.groupedBy.length) { + // res_ids = _.pluck(_.flatten(_.pluck(state.data, 'data')), 'res_id'); + // } else { + // res_ids = _.pluck(state.data, 'res_id'); + // } + // options = _.extend({}, options, { + // dataset: { + // res_ids: res_ids, + // current_id: record.res_id, + // }, + // }); + // this.trigger_up('switch_view', { + // view_type: 'form', + // options: options, + // }); + // }, + // open_dialog: function (event) { + // var data = event.data; + // data.flags = _.defaults(data.flags || {}, { + // mode: 'edit', + // footer_to_buttons: true, + // action_buttons: false, + // headless: true + // }); + // this.do_action({ + // type: 'ir.actions.act_window', + // res_model: data.model || this.model, + // res_id: data.id, + // view_id: data.view_id, + // view_mode: data.type || 'form', + // view_type: data.type || 'form', + // views: [[false, data.type || 'form']], + // target: 'new', + // context: data.context, + // flags: data.flags, + // }, { + // on_load: function (action) { + // action.viewManager.$('.o_form_statusbar').remove(); + // data.on_load && data.on_load(action.viewManager); + // }, + // }); + // }, + // _onDeletedRecords: function () { + // this.update_state(this.db_id); + // }, + // update_state: function (db_id) { + // this.db_id = db_id; + // var state = this.model.get(db_id); + // if (state.type === 'record') { + // this.set({ title : state.data.id ? state.data.display_name : _t("New") }); + // } + // if (this.pager && this.config.hasPager) { + // this.update_pager(); + // } + // if (this.$buttons) { + // this.update_buttons(); + // } + // this.$('.oe_view_nocontent').remove(); + // if (this.hasNoContent(state)) { + // if (this.renderer) { + // this.renderer.do_hide(); + // } + // this.displayNoContentHelp(); + // return; + // } + // if (this.renderer) { + // this.update_renderer(); + // this.renderer.do_show(); + // } + // var params = state.res_id ? {id: state.res_id} : {}; + // this.do_push_state(params); + // core.bus.trigger('view_shown'); + // }, + // reload: function (event) { + // var db_id = event && event.data && event.data.db_id; + // if (db_id) { + // // reload the relational field given its db_id + // this.model.reload(db_id).then(this._confirmSave.bind(this, db_id)); + // } else { + // // no db_id given, so reload the main record + // return this.model.reload(this.db_id).then(this.update_state.bind(this)); + // } + // }, + // update_renderer: function () { + // var state = this.model.get(this.db_id); + // return this.renderer.update(state); + // }, + // renderPager: function ($node, options) { + // var data = this.model.get(this.db_id, {raw: true}); + // this.pager = new Pager(this, data.count, data.offset + 1, this.page_size, options); + + // this.pager.on('pager_changed', this, function (new_state) { + // var self = this; + // var data = this.model.get(this.db_id); + // this.pager.disable(); + // var limit_changed = (this.page_size !== new_state.limit); + // this.page_size = new_state.limit; + // this.model + // .setLimit(data.id, new_state.limit) + // .setOffset(data.id, new_state.current_min - 1) + // .reload(data.id) + // .then(function (state) { + // self.update_state(state); + // // Reset the scroll position to the top on page changed only + // if (!limit_changed) { + // self.trigger_up('scrollTo', {offset: 0}); + // } + // }) + // .then(this.pager.enable.bind(this.pager)); + // }); + // this.pager.appendTo($node = $node || this.options.$pager); + // this.update_pager(); // to force proper visibility + // }, + // update_buttons: function () { + // }, + // update_pager: function () { + // var data = this.model.get(this.db_id); + // this.pager.updateState({ + // current_min: data.offset + 1, + // size: data.count, + // }); + // var is_pager_visible = (data.type === 'record') || (!!data.count && (data.groupedBy && !data.groupedBy.length)); + // this.pager.do_toggle(is_pager_visible); + // }, + // do_search: function (domain, context, group_by) { + // var load = this.db_id ? this._reload_data : this._load_data; + // return load.call(this, domain, context, group_by).then(this.update_state.bind(this)); + // }, + // _load_data: function (domain, context, group_by) { + // return this.model + // .load({ + // type: 'record', + // model: this.modelName, + // domain: domain, + // groupedBy: group_by, + // context: context, + // }); + // }, + // _reload_data: function (domain, context, group_by) { + // return this.model + // .setDomain(this.db_id, domain) + // .setContext(this.db_id, context) + // .setGroupBy(this.db_id, group_by) + // .reload(this.db_id); + // }, + // sidebar_eval_context: function () { + // return $.when({}); + // }, + // /** + // * Return whether the user can perform a given action (e.g. 'create', 'edit') in this view. + // * An action is disabled by setting the corresponding attribute in the view's main element, + // * like: <form string="" create="false" edit="false" delete="false"> + // */ + // is_action_enabled: function (action) { + // var attrs = this.fields_view.arch.attrs; + // return (action in attrs) ? JSON.parse(attrs[action]) : true; + // }, + // config: { + // openGroupByDefault: false, + // page_size: 40, + // hasPager: true, // TODO: change this into false + // js_libs: [], // the list of lazy-loaded js dependencies + // css_libs: [], // the list of lazy-loaded css dependencies + // }, + // defaults: { + // action: {}, + // }, + // used by views that need a searchview. + // searchable: true, + // used by views that need a searchview but don't want it to be displayed. + // searchview_hidden: false, + + // events: { + // 'click a[type=action]': function (ev) { + // ev.preventDefault(); + // var action_data = $(ev.target).attr('name'); + // this.do_action(action_data); + // } + // }, + + // custom_events: _.extend({}, FieldManagerMixin.custom_events, { + // open_record: function (event) { + // this.open_record(event.data.id); + // }, + // reload: 'reload', + // open_dialog: 'open_dialog', + // close_dialog: function () { + // this.do_action({type: 'ir.actions.act_window_close'}); + // }, + // // TODO: add open_action, ... + // }), + 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 00000000000..78aa595a398 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_model.js @@ -0,0 +1,69 @@ +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. + */ + +var Class = require('web.Class'); +var mixins = require('web.mixins'); + +var AbstractModel = Class.extend(mixins.EventDispatcherMixin, mixins.ServicesMixin, { + /** + * @param {Widget} parent + */ + init: function (parent) { + mixins.EventDispatcherMixin.init.call(this); + this.setParent(parent); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method should return the complete state necessary for the renderer + * to display the currently viewed data. + * + * @returns {*} + */ + get: function () { + }, + /** + * The load method is called once in a model, when we load the data for the + * first time. The method returns (a deferred that resolves to) some kind + * of token/handle. The handle can then be used with the get method to + * access a representation of the data. + * + * @param {Object} params + * @param {string} params.modelName the name of the model + * @returns {Deferred} The deferred resolves to some kind of handle + */ + load: function (params) { + return $.when(); + }, + /** + * 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. + * + * @param {Object} params + * @returns {Deferred} + */ + reload: function (params) { + return $.when(); + }, +}); + +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 00000000000..c47f6f7e3f3 --- /dev/null +++ b/addons/web/static/src/js/views/abstract_renderer.js @@ -0,0 +1,119 @@ +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 Widget = require('web.Widget'); + +return Widget.extend({ + /** + * @constructor + * @param {Widget} parent + * @param {any} state + * @param {Object} params + */ + init: function (parent, state, params) { + this._super(parent); + this.state = state; + this.arch = params.arch; + }, + /** + * The rendering can be asynchronous (but it is not encouraged). The start + * method simply makes sure that we render the view. + * + * @returns {Deferred} + */ + start: function () { + this.$el.addClass(this.arch.attrs.class); + return $.when(this._render(), this._super()); + }, + + //-------------------------------------------------------------------------- + // 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 () { + }, + /** + * 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) { + }, + /** + * update the state of the view. It always retrigger a full rerender. + * + * @param {any} state + * @param {Object} params + * @returns {Deferred} + */ + updateState: function (state, params) { + this.state = state; + return this._render(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render the view + * + * @abstract + * @private + * @returns {Deferred} + */ + _render: function () { + return $.when(); + }, +}); + +}); + + // // this method is used to get local state unknown by renderer parent, but + // // necessary to restore the renderer to a full state after an update. For + // // example, the currently opened tabs in a form view, or the current scrolling + // // position + // getLocalState: function () { + // // to be implemented by actual renderer + // }, + // // this method is used after an update (and may also be used after a new renderer + // // is instantiated). For example, opening a new record in a form view is + // // done by instantiating a new renderer. But the curently opened page should + // // be maintained if possible. + // setLocalState: function (local_state) { + // // to be implemented by actual renderer + // }, + + // var local_state = this.getLocalState(); + // .then(this.setLocalState.bind(this, local_state)); + // // todo: add and use getWidget method + // getWidget: function (node) { + // // read field type from node + // // or get widget name from attrs + // }, + // }, 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 00000000000..c95e4f084fe --- /dev/null +++ b/addons/web/static/src/js/views/abstract_view.js @@ -0,0 +1,181 @@ +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 createController method on the view instance. This returns a + * controller (with a model and a renderer as sub widgets) + * 3. append the controller somewhere + * + * Note that once a controller has been instantiated, the view class is no + * longer useful (unless you want to create another controller), and will be + * in most case discarded. + */ + +var Class = require('web.Class'); +var ajax = require('web.ajax'); +var AbstractModel = require('web.AbstractModel'); +var AbstractRenderer = require('web.AbstractRenderer'); +var AbstractController = require('web.AbstractController'); + +var AbstractView = Class.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, + + // determine if a search view should be displayed in the control panel and + // allowed to interact with the view. Currently, the only not searchable + // views are the form view and the diagram view. + searchable: true, + + config: { + Model: AbstractModel, + Renderer: AbstractRenderer, + Controller: AbstractController, + js_libs: [], + css_libs: [], + }, + + /** + * 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 {any} arch + * @param {any} fields + * @param {Object} params + * @param {string} params.modelName The actual model name + * @param {Object} params.context + * @param {number} [params.count] + * @param {string[]} params.domain + * @param {string[]} params.groupBy + * @param {number} [params.currentId] + * @param {number[]} [params.ids] + * @param {string} [params.action.help] + */ + init: function (arch, fields, params) { + this.rendererParams = { + arch: arch + }; + + this.controllerParams = { + modelName: params.modelName, + activeActions: { + edit: arch.attrs.edit ? JSON.parse(arch.attrs.edit) : true, + create: arch.attrs.create ? JSON.parse(arch.attrs.create) : true, + delete: arch.attrs.delete ? JSON.parse(arch.attrs.delete) : true, + }, + noContentHelp: params.action && params.action.help, + }; + + this.loadParams = { + context: params.context, + count: params.count || ((this.controllerParams.ids !== undefined) && + this.controllerParams.ids.length) || 0, + domain: params.domain, + groupedBy: params.groupBy, + modelName: params.modelName, + res_id: params.currentId, + res_ids: params.ids, + }; + if (params.modelName) { + this.loadParams.modelName = params.modelName; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Main method of the view class. + * + * @param {Widget} parent The parent of the resulting Controller (most + * likely a view manager) + * @returns {Deferred} The deferred resolves to a controller + */ + createController: function (parent) { + var self = this; + var model = this.model || this.createModel(parent); + return $.when(model.load(this.loadParams), this._loadLibs()).then(function () { + var state = model.get(arguments[0]); + var renderer = self.createRenderer(parent, state); + var Controller = self.config.Controller; + var controllerParams = _.extend({ + initialState: state, + }, self.controllerParams); + var controller = new Controller(parent, model, renderer, controllerParams); + renderer.setParent(controller); + + if (!self.model) { + // if we have a model, it already has a parent. Otherwise, we + // set the controller, so the rpcs from the model actually work + model.setParent(controller); + } + return controller; + }); + }, + createModel: function (parent) { + var Model = this.config.Model; + return new Model(parent); + }, + createRenderer: function (parent, state) { + var Renderer = this.config.Renderer; + return new Renderer(parent, state, this.rendererParams); + }, + /** + * this is usedful to customize the actual class to use before calling + * createView. + * + * @param {Controller} Controller + */ + setController: function (Controller) { + this.config.Controller = Controller; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Makes sure that the js_libs and css_libs are properly loaded. Note that + * the ajax loadJS and loadCSS methods don't do anything if the given file + * is already loaded. + * + * @private + * @returns {Deferred} + */ + _loadLibs: function () { + var defs = []; + _.each(this.config.js_libs, function (url) { + defs.push(ajax.loadJS(url)); + }); + _.each(this.config.css_libs, function (url) { + defs.push(ajax.loadCSS(url)); + }); + return $.when.apply($, defs); + }, +}); + +return AbstractView; + +}); 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 00000000000..24b5c7fa3d0 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -0,0 +1,178 @@ +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 FieldManagerMixin = require('web.FieldManagerMixin'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var Pager = require('web.Pager'); + +var _t = core._t; + +return AbstractController.extend(FieldManagerMixin, { + custom_events: _.extend({}, AbstractController.prototype.custom_events, FieldManagerMixin.custom_events, { + reload: '_onReload', + }), + /** + * @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.handle = params.initialState.id; + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + return this._super.apply(this, arguments) + .then(this._updateEnv.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + renderPager: function ($node, options) { + var data = this.model.get(this.handle, {raw: true}); + this.pager = new Pager(this, data.count, data.offset + 1, data.limit, options); + + this.pager.on('pager_changed', this, function (newState) { + var self = this; + this.pager.disable(); + var limitChanged = (data.limit !== newState.limit); + this.reload({limit: newState.limit, offset: newState.current_min - 1}) + .then(function () { + // Reset the scroll position to the top on page changed only + if (!limitChanged) { + self.trigger_up('scrollTo', {offset: 0}); + } + }) + .then(this.pager.enable.bind(this.pager)); + }); + this.pager.appendTo($node); + this._updatePager(); // to force proper visibility + }, + /** + * @override + * @returns {Deferred} + */ + update: function (params, options) { + var self = this; + return this._super(params, options).then(function () { + self._updateEnv(); + self._updatePager(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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, self.handle) + .then(self._onDeletedRecords.bind(self, ids)); + } + if (this.confirmOnDelete) { + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: doIt, + }); + } else { + doIt(); + } + }, + /** + * 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({}); + }, + /** + * Helper method, to get the current environment variables from the model + * and notifies the component chain (by bubbling an event up) + */ + _updateEnv: function () { + var env = this.model.get(this.handle, {env: true}); + this.trigger_up('env_updated', env); + }, + /** + * Helper method, to make sure the information displayed by the pager is up + * to date. + */ + _updatePager: function () { + if (this.pager) { + var data = this.model.get(this.handle, {raw: true}); + this.pager.updateState({ + current_min: data.offset + 1, + size: data.count, + }); + var isRecord = data.type === 'record'; + var hasData = !!data.count; + var isGrouped = data.groupedBy ? !!data.groupedBy.length : false; + var isNew = this.model.isNew(this.handle); + var isPagerVisible = isRecord ? !isNew : (hasData && !isGrouped); + + this.pager.do_toggle(isPagerVisible); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * 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} event + * @param {Object} event.data + * @param {string} [event.data.db_id] handle of the data to reload and + * re-render (reload the whole form by default) + * @param {string[]} [event.data.fieldNames] list of the record's fields to + * reload + */ + _onReload: function (event) { + var data = event && event.data || {}; + var handle = data.db_id; + if (handle) { + // reload the relational field given its db_id + this.model.reload(handle).then(this._confirmSave.bind(this, handle)); + } else { + // no db_id given, so reload the main record + this.reload({fieldNames: data.fieldNames}); + } + }, + +}); + +}); 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 00000000000..5baf65f1c30 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -0,0 +1,1996 @@ +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: + * { + * _changes: { + * data: { fieldName: field_values | [commands] }, + * relational_data: { field_names: datapoint } + * }, + * aggregate_values: { fieldName: number} | undefined + * count: number, default=0, + * context: {string:value}, + * data: {string:value} | [{string:value}], + * field_names: [string], + * fields: {string:field description}, + * grouped_by: [strings] | undefined, + * id: number, + * is_group: boolean, + * is_list: boolean, + * is_open: boolean, + * is_record: boolean, + * limit: number | undefined (default=80 for lists) + * model: string, + * offset: number, default=0, + * openGroupByDefault: boolean, + * ordered_by: [order] | undefined, + * res_id: [number]|undefined, + * res_ids: [number] | undefined, + * relational_data: { + * field_names: datapoints + * }, + * static: boolean, * like previous DatasetStatic + * } + * + * Notes: + * - id: is totally unrelated to res_id. id is a web client local concept + * - res_id: if set to a number, 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 data = require('web.data'); // TODO: remove dependency to data.js +var pyeval = require('web.pyeval'); +var session = require('web.session'); + +var x2ManyCommands = { + // (0, _, {values}) + CREATE: 0, + create: function (values) { + return [x2ManyCommands.CREATE, false, values]; + }, + // (1, id, {values}) + UPDATE: 1, + update: function (id, values) { + 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 Model = AbstractModel.extend({ + /** + * @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 dictionary is used to hold all many2one informations for widget + // selection. + this.many2ones = {}; + + // this dictionary is used to hold all many2many informations for widget + // many2many_checkboxes. + this.many2manys = {}; + + this.localData = Object.create(null); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + + /** + * Add a default record to a list object. This method actually make a new + * record with the makeDefaultRecord method, then add it to the list object. + * + * @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 {Deferred<string>} resolves to the id of the new created record + */ + addDefaultRecord: function (listID, options) { + var list = this.localData[listID]; + var context = this._getContext(list); + + var position = (options && options.position) || 'top'; + var params = { + context: context, + fieldAttrs: list.fieldAttrs, + fieldNames: list.fieldNames, + fields: list.fields, + }; + return this.makeDefaultRecord(list.model, params).then(function(id) { + list.count++; + if (position === 'top') { + list.data.unshift(id); + } else { + list.data.push(id); + } + return id; + }); + }, + /** + * Delete a list of records, then, if a parent_id is given, reload the + * parent. + * + * @todo we should remove the deleted records from the localData + * @todo is it really necessary to reload the data? it seems artificial, and + * the caller should be able to do that himself + * @todo why can't we infer modelName? + * + * @param {string[]} recordIds list of local resources ids. They should all + * be of type 'record', and of the same model + * @param {string} modelName + * @param {string} parent_id + * @returns {Deferred} + */ + deleteRecords: function (recordIds, modelName, parent_id) { + var self = this; + var records = _.map(recordIds, function (id) { return self.localData[id]; }); + return this.performModelRPC(modelName, 'unlink', [_.pluck(records, 'res_id')], { + context: session.user_context, // todo: combine with view context + }).then(function () { + _.each(records, function (record) { + record.res_ids.splice(record.offset, 1)[0]; + record.res_id = record.res_ids[record.offset]; + record.count--; + }); + if (parent_id) { + return self.reload(parent_id); + } + }); + }, + /** + * Discard all changes in a local resource. Basically, it removes + * everything that was stored in a _changes key. + * + * @param {string} id local resource id + */ + discardChanges: function (id) { + var element = this.localData[id]; + this._visitChildren(element, function (elem) { + elem._changes = null; + }); + }, + /** + * Duplicate a record (by calling the 'copy' route) + * + * @param {string} recordID id for a local resource + * @returns {Deferred -> string} resolves to the id of duplicate record + */ + duplicateRecord: function (recordID) { + var self = this; + var record = this.localData[recordID]; + return this.performModelRPC(record.model, 'copy', [record.data.id], { + context: this._getContext(record), + }).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({ + fieldAttrs: record.fieldAttrs, + fieldNames: record.fieldNames, + fields: record.fields, + modelName: record.model, + res_id: res_id, + res_ids: record.res_ids.slice(0), + }); + }); + }, + /** + * 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 record = this.localData[id]; + + if (options.env) { + var env = { + ids: record.res_ids ? record.res_ids.slice(0) : [], + }; + if (record.type === 'record') { + env.currentId = this.isNew(record.id) ? undefined : record.res_id; + } + return env; + } + + // do not copy fields: this has a really big performance cost, for views + // with many records and lots of fields (for ex, kanban view contacts) + var fields = record.fields; + delete record.fields; + var element = $.extend(true, {}, record); + record.fields = fields; + element.fields = fields; + + var field, relDataPoint; + if (element.type === 'record') { + // apply changes + _.extend(element.data, element._changes); + + for (var fieldName in element.data) { + field = element.fields[fieldName]; + + // get relational datapoint + if (field.type === 'many2one') { + if (options.raw) { + relDataPoint = this.localData[element.data[fieldName]]; + element.data[fieldName] = relDataPoint ? relDataPoint.res_id : false; + } else { + element.data[fieldName] = this.get(element.data[fieldName]) || false; + } + } + if (field.type === 'one2many' || field.type === 'many2many') { + if (options.raw) { + relDataPoint = this.localData[element.data[fieldName]]; + var ids = _.map(relDataPoint.data, function (id) { + return self.localData[id].res_id; + }); + element.data[fieldName] = ids; + } else { + element.data[fieldName] = this.get(element.data[fieldName]) || []; + } + } + + // weird way to get out many2ones for widget=selection + if (field.__fetch_selection) { + field.__selection_information = self.many2ones[field.relation]; + } + + // weird way to get out many2manys for widget=many2many_checkboxes + if (field.__fetch_many2manys) { + field.__many2many_information = self.many2manys[field.relation]; + } + } + + // this is not strictly necessary, but it hides some implementation + // details, and can easily be removed if needed. + delete element.orderedBy; + delete element.aggregateValues; + delete element.groupedBy; + } + if (element.type === 'list') { + // apply changes if any + if (element._changes) { + element.data = element._changes; + element.count = element._changes.length; + element.res_ids = _.map(element._changes, function (elemID) { + return self.localData[elemID].res_id; + }); + } + // get relational datapoint + element.data = _.map(element.data, function (elemID) { + return self.get(elemID); + }); + } + + delete element._cache; + delete element._changes; + delete element.static; + delete element.parentID; + delete element.rawContext; + return element; + }, + /** + * return true if a record is dirty. A record is considered dirty if it has + * some unsaved changes. A list is considered dirty if its _changes key is + * set to an array of its new datapoints (possibly empty) + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isDirty: function (id) { + var isDirty = false; + var record = this.localData[id]; + this._visitChildren(record, function (r) { + if (r.type === "record" ? !_.isEmpty(r._changes) : r._changes) { + isDirty = true; + } + }); + return isDirty; + }, + /** + * Check if a record is new, meaning if it is in the process of being + * created and no actual record exists in db. + * + * @param {string} id id for a local resource + * @returns {boolean} + */ + isNew: function (id) { + return typeof this.localData[id].res_id !== 'number'; + }, + /** + * 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 + * + * @param {any} params + * @param {Object} [params.fieldAttrs={}] contains the attrs of each field + * @param {Array} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @param {string} [params.type] 'record' or 'list' + * @returns {Deferred -> string} resolves to a local id, or handle + */ + load: function (params) { + params.type = params.type || (params.res_id !== undefined ? 'record' : 'list'); + + if (params.type === 'record' && params.res_id === undefined) { + return this.makeDefaultRecord(params.modelName, params); + } + var dataPoint = this._makeDataPoint(params); + return this._load(dataPoint).then(function () { + return dataPoint.id; + }); + }, + /** + * 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. + * + * @param {any} params + * @param {string} modelName model name + * @param {Object} params.context the context for the new record + * @param {Object} [params.fieldAttrs={}] contains the attrs of each field + * @param {Array} params.fieldNames the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @param {Object} params.context the context for the new record + * @returns {Deferred -> string} resolves to the id for the created resource + */ + makeDefaultRecord: function (modelName, params) { + var self = this; + var fields_key = _.without(params.fieldNames, '__last_update'); + + return this.performModelRPC(modelName, 'default_get', [fields_key], { + context: params.context, + }).then(function (result) { + // fill default values for missing fields + for (var i = 0; i < params.fieldNames.length; i++) { + var fieldName = params.fieldNames[i]; + if (!(fieldName in result)) { + var field = params.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + result[fieldName] = []; + } else { + result[fieldName] = null; + } + } + } + + var data = {}; + var record = self._makeDataPoint({ + modelName: modelName, + data: data, + fields: params.fields, + fieldNames: params.fieldNames, + fieldAttrs: params.fieldAttrs, + context: params.context, + res_ids: params.res_ids, + }); + + var defs = []; // FIXME: remove defs? + _.each(params.fieldNames, function (name) { + var field = params.fields[name]; + data[name] = null; + record._changes = record._changes || {}; + if (field.type === 'many2one' && result[name]) { + var rec = self._makeDataPoint({ + context: record.context, + data: {id: result[name]}, + modelName: field.relation, + }); + record._changes[name] = rec.id; + } else if (field.type === 'one2many' || field.type === 'many2many') { + var x2manyList = self._makeDataPoint({ + context: record.context, + fieldAttrs: field.fieldAttrs, + fields: field.relatedFields, + limit: field.limit, + modelName: field.relation, + res_ids: [], + static: true, + type: 'list', + }); + record._changes[name] = x2manyList.id; + var many2ones = {}; + _.each(result[name], function (command) { + if (command[0] === 0) { + // CREATE + var r = self._makeDataPoint({ + modelName: x2manyList.model, + context: x2manyList.context, + fields: field.relatedFields, + fieldAttrs: field.fieldAttrs, + }); + x2manyList._changes.push(r.id); + r._changes = command[2]; + + // this is necessary so the fields are initialized + for (var fieldName in command[2]) { + r.data[fieldName] = null; + } + + for (var name in r._changes) { + if (r.fields[name].type === 'many2one') { + var rec = self._makeDataPoint({ + context: r.context, + modelName: r.fields[name].relation, + data: {id: r._changes[name]} + }); + r._changes[name] = rec.id; + many2ones[name] = true; + } + } + } + if (command[0] === 6) { + // REPLACE_WITH + x2manyList._changes = []; + } + }); + + // fetch many2ones display_name + _.each(_.keys(many2ones), function (name) { + defs.push(self._fetchNameGets(x2manyList, name)); + }); + } else { + record._changes[name] = result[name]; + } + }); + return $.when.apply($, defs) + .then(function () { + var shouldApplyOnchange = false; + var field; + for (var field_name in record.data) { + field = record.fields[field_name]; + if (field.onChange) { + shouldApplyOnchange = true; + } + } + if (shouldApplyOnchange) { + return self._applyOnChange(record, fields_key); + } else { + return $.when(); + } + }) + .then(function () { + return self._fetchRelationalData(record); + }) + .then(function () { + return self._postprocess(record); + }) + .then(function () { + return record.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} [attrs] various field attrs that we want to set + * @returns {string} the local id for the created resource + */ + makeRecord: function (model, fields, attrs) { + var self = this; + var record_fields = {}; + _.each(fields, function (field) { + record_fields[field.name] = _.pick(field, 'type', 'relation', 'domain'); + }); + var record = this._makeDataPoint({ + modelName: model, + fields: record_fields, + fieldAttrs: attrs, + }); + _.each(fields, function (field) { + if ('value' in field) { + if (field.type === 'many2one') { + var id = field.value[0]; + var display_name = field.value[1]; + var relatedRecord = self._makeDataPoint({ + modelName: field.relation, + data: { + id: id, + display_name: display_name, + } + }); + record.data[field.name] = relatedRecord.id; + } else { + record.data[field.name] = field.value; + } + } + }); + return record.id; + }, + /** + * This method just does a name_create call to the server. + * + * @todo Maybe this method should also create a local record... + * + * @param {string} model the name of the model + * @param {string} name the desired name of the created record + * @param {Object} context the context to be passed to the rpc + * @returns {Deferred} + */ + nameCreate: function (model, name, context) { + return this.performModelRPC(model, 'name_create', [name], { + context: _.extend({}, session.user_context, context), + }); + }, + /** + * Do a name_search. I think that it should be done directly by the widget + * @todo remove this + * @deprecated + * + * @param {any} model + * @param {any} name + * @param {any} domain + * @param {any} operator + * @param {any} limit + * @returns + */ + nameSearch: function (model, name, domain, operator, limit) { + // fixme: correctly handle context + return this.performModelRPC(model, 'name_search', [], { + name: name || '', + args: domain || [], + operator: operator || 'ilike', + limit: limit || 0, + }); + }, + /** + * 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 + * @returns {string[]} list of changed fields + */ + notifyChanges: function (record_id, changes) { + var self = this; + var record = this.localData[record_id]; + var onChangeFields = []; // the fields that have changed and that have an on_change + var field; + var defs = []; + record._changes = record._changes || {}; + + // apply changes to local data + for (var fieldName in changes) { + field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + defs.push(this._applyX2ManyChange(record, fieldName, changes[fieldName])); + } else if (field.type === 'many2one') { + defs.push(this._applyMany2OneChange(record, fieldName, changes[fieldName])); + } else { + record._changes[fieldName] = changes[fieldName]; + } + if (field.onChange) { + onChangeFields.push(fieldName); + } + } + + return $.when.apply($, defs).then(function () { + var onchangeDef; + if (onChangeFields.length) { + onchangeDef = self._applyOnChange(record, onChangeFields).then(function (result) { + return _.keys(changes).concat(Object.keys(result && result.value || {})); + }); + } else { + onchangeDef = $.Deferred().resolve(_.keys(changes)); + } + return onchangeDef.then(function (fieldNames) { + _.each(fieldNames, function (name) { + if (record._changes && record._changes[name] === record.data[name]) { + delete record._changes[name]; + } + }); + return fieldNames; + }); + }); + }, + /** + * Reload all data for a given resource + * + * @param {string} id local id for a resource + * @param {Object} [options] + * @returns {Deferred -> string} resolves to the id of the resource + */ + reload: function (id, options) { + options = options || {}; + var element = this.localData[id]; + + if (element.type === 'record') { + if ('currentId' in options && !options.currentId) { + var params = { + context: element.context, + fieldAttrs: element.fieldAttrs, + fieldNames: element.fieldNames, + fields: element.fields, + }; + return this.makeDefaultRecord(element.model, params); + } + this.discardChanges(id); + } + + if (options.context !== undefined) { + element.context = options.context; + } + 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.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'); + return this._load(element, loadOptions).then(function (result) { + return result.id; + }); + }, + /** + * 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} record_id local resource + * @param {Object} [options] + * @param {boolean} [options.reload=true] if true, data will be reloaded + * @returns {Deferred} + */ + save: function (record_id, options) { + var self = this; + options = options || {}; + var shouldReload = 'reload' in options ? options.reload : true; + return this.mutex.exec(function () { + var record = self.localData[record_id]; + var method = self.isNew(record_id) ? 'create' : 'write'; + var changes = _.extend({}, record._changes); + var commands = self._generateX2ManyCommands(record); + for (var fieldName in commands) { + if (commands[fieldName] === null) { + delete changes[fieldName]; + } else { + changes[fieldName] = commands[fieldName]; + } + } + + // replace local ids by actual ids in relational changes + for (fieldName in changes) { + var field = record.fields[fieldName]; + if (field.type === 'many2one') { + if (changes[fieldName]) { + var res_id = self.localData[changes[fieldName]].res_id; + changes[fieldName] = res_id; + } else { + changes[fieldName] = false; + } + } + } + + if (method === 'create') { + _.each(record.fieldNames, function (name) { + changes[name] = changes[name] || record.data[name]; + if (changes[name] === null) { + delete changes[name]; + } + }); + } + // make sure we don't write an undefined id + delete changes.id; + + // in the case of a write, only perform the RPC if there are changes to save + if (method === 'create' || Object.keys(changes).length) { + var args = method === 'write' ? [[record.data.id], changes] : [changes]; + return self.performModelRPC(record.model, method, args, { + context: session.user_context, // todo: combine with view context + }).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++; + } + // erase changes as they have been applied + record._changes = {}; + + return shouldReload ? self._fetchRecord(record) : false; + }); + } else { + return $.when(record_id); + } + }); + }, + /** + * For list resources, this changes the orderedBy key, then performs the + * sort directly, in javascript. This is used for sorting static datasets, + * such as a one2many in a form view. For dynamic datasets, such as a list + * view, this method will be called, but then the sort will be ignored since + * we will reload data. + * + * @todo don't sort in js when we reload data anyway + * + * @param {string} list_id id for the list resource + * @param {string} fieldName valid field name + * @returns {this} so we can chain call this method + */ + setSort: function (list_id, fieldName) { + var list = this.localData[list_id]; + if (list.type === 'record') { + return; + } + list.offset = 0; + if (list.orderedBy.length === 0) { + list.orderedBy.push({name: fieldName, asc: true}); + } else if (list.orderedBy[0].name === fieldName){ + 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); + } + this._sortList(list); + return this; + }, + /** + * 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 {Deferred -> string} resolves to the parent id + */ + toggleActive: function(recordIDs, value, parentID) { + var self = this; + var parent = this.localData[parentID]; + var resIDs = _.map(recordIDs, function(recordID) { + return self.localData[recordID].res_id; + }); + return this + .performModelRPC(parent.model, 'write', [resIDs, { active: value }]) + .then(this.reload.bind(this, parentID)); + + }, + /** + * Toggle (open/close) a group in a grouped list, then fetches relevant + * data + * + * @param {string} groupId + * @returns {Deferred -> string} resolves to the group id + */ + toggleGroup: function (groupId) { + var group = this.localData[groupId]; + if (group.isOpen) { + group.isOpen = false; + group.data = []; + group.offset = 0; + return $.when(groupId); + } + if (!group.isOpen) { + group.isOpen = true; + var def; + if (group.count > 0) { + def = this._fetchUngroupedList(group); + } + return $.when(def).then(function () { + return groupId; + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Apply a many2one onchange. 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. + * + * @param {Object} record + * @param {string} fieldName + * @param {Object} [data] + * @returns {Deferred} + */ + _applyMany2OneChange: function (record, fieldName, data) { + var self = this; + if (!data) { + record._changes[fieldName] = false; + return $.when(); + } + var rel_data = _.pick(data, 'id', 'display_name'); + var def; + if (!('display_name' in rel_data)) { + var field = record.fields[fieldName]; + def = this.performModelRPC(field.relation, 'name_get', [data.id], { + context: record.context, + }).then(function (result) { + rel_data.display_name = result[0][1]; + }); + } + return $.when(def).then(function () { + var rec = self._makeDataPoint({ + context: record.context, + data: rel_data, + fields: {}, + modelName: record.fields[fieldName].relation, + }); + record._changes[fieldName] = rec.id; + }); + }, + /** + * This method is quite important: it is supposed to perform the /onchange + * rpc and apply the result. + * + * @param {Object} record + * @param {string[]} fields changed fields + * @returns + */ + _applyOnChange: function (record, fields) { + var self = this; + var onchange_spec = this._buildOnchangeSpecs(record); + var idList = record.data.id ? [record.data.id] : []; + var context = data.build_context(record, session.user_context).eval(); + + var rawData = this.get(record.id, {raw: true}).data; + var commands = this._generateX2ManyCommands(record); + var currentData = _.pick(_.extend(rawData, commands), record.fieldNames); + if (fields.length === 1) { + fields = fields[0]; + } + return this.mutex.exec(function () { + var args = [idList, currentData, fields, onchange_spec, context]; + return self.performModelRPC(record.model, 'onchange', args, {}).then(function (result) { + var defs = []; + _.each(result.value, function (val, name) { + var field = record.fields[name]; + if (!field) { return; } // ignore changes of unknown fields + + if (field.type === 'many2one' ) { + // 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. + // FIXME: shouldn't we erase the value in that case? + 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}; + var rec = self._makeDataPoint({ + context: context, + data: data, + modelName: field.relation, + }); + record._changes[name] = rec.id; + } + } else if (field.type === 'one2many') { + record._changes = record._changes || {}; + var listId = record._changes[name] || record.data[name]; + var list = self.localData[listId]; + var rec; + _.each(val, function (command) { + if (command[0] === 0 || command[0] === 1) { + // CREATE or UPDATE + var params = { + context: context, + fields: list.fields, + fieldAttrs: list.fieldAttrs, + modelName: list.model, + fieldNames: _.keys(command[2]), + }; + if (command[0] === 1) { + params.res_id = command[1]; + } + rec = self._makeDataPoint(params); + var data = command[2]; + _.each(rec.fieldNames, function (name) { + var field = rec.fields[name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: data[name][0], + display_name: data[name][1], + }, + }); + data[name] = r.id; + } + }); + rec._changes = data; + for (var f in rec._changes) { + rec.data[f] = null; + } + list._changes.push(rec.id); + } + if (command[0] === 5) { + // DELETE ALL + list._changes = []; + } + }); + } else { + record._changes[name] = val; + } + }); + return $.when.apply($, defs).then(function () { + return result; + }); + }); + }); + }, + /** + * 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'} + * @returns {Deferred} + */ + _applyX2ManyChange: function (record, fieldName, command) { + var self = this; + var list = this.localData[record._changes[fieldName] || record.data[fieldName]]; + var field = record.fields[fieldName]; + var rec; + var defs = []; + list._changes = list._changes || list.data; + + 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); + newRecord.fields = list.fields; + list._changes.push(newRecord.id); + break; + case 'ADD_M2M': + // 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]; + var list_records = {}; + _.each(data, function (d) { + rec = self._makeDataPoint({ + context: record.context, + modelName: field.relation, + fields: field.relatedFields, + fieldAttrs: field.fieldAttrs, + res_id: d.id, + }); + list_records[d.id] = rec; + list._changes.push(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 def = this + .performModelRPC(list.model, 'read', [_.pluck(data, 'id'), list.fieldNames], {}) + .then(function (records) { + _.each(records, function (record) { + list_records[record.id].data = record; + }); + }); + defs.push(def); + break; + case 'CREATE': + defs.push(this.addDefaultRecord(list.id)); + break; + case 'UPDATE': + var changedRecord = this.localData[command.id]; + changedRecord._changes = changedRecord._changes || {}; + _.extend(changedRecord._changes, _.pick(command.data, list.fieldNames)); + break; + case 'REMOVE': + list._changes = _.without(list._changes, command.id); + break; + 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 + var currentIds = _.map(list._changes, function (localId) { + return self.localData[localId].res_id; + }); + var newIds = _.difference(command.ids, currentIds); + var removedIds = _.difference(currentIds, command.ids); + var addDef, removedDef; + if (newIds.length) { + var values = _.map(newIds, function (id) { + return {id: id}; + }); + + addDef = this._applyX2ManyChange(record, fieldName, { + operation: 'ADD_M2M', + ids: values + }); + } + if (removedIds.length) { + removedDef = this._applyX2ManyChange(record, fieldName, { + operation: 'REMOVE', + ids: removedIds + }); + } + return $.when(addDef, removedDef); + } + + return $.when.apply($, defs); + }, + /** + * 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"} + * + * @see _applyOnChange + * + * @param {Object} record resource object of type 'record' + * @returns {Object} an onchange spec + */ + _buildOnchangeSpecs: function (record) { + // TODO: replace this function by some generic tree function in utils + var specs = {}; + _.each(record.fieldNames, function (name) { + var field = record.fields[name]; + specs[name] = (field.onChange) || ""; + _.each(field.views, function (view) { + _.each(view.fields, function (field, subname) { + specs[name + '.' + subname] = (field.onChange) || ""; + }); + }); + }); + return specs; + }, + /** + * Fetch all name_gets for the many2ones in a group + * + * @param {Object} group a valid resource object + * @returns {Deferred} + */ + _fetchMany2OneGroup: function (group) { + var ids = _.uniq(_.pluck(group, 'res_id')); + + return this.performModelRPC(group[0].model, 'name_get', [ids], { + context: group[0].context, + }).then(function (name_gets) { + _.each(group, function (record) { + var nameGet = _.find(name_gets, function (n) { return n[0] === record.res_id;}); + record.data.display_name = nameGet[1]; + }); + }); + }, + _fetchNameGets: function (list, fieldName) { + var self = this; + var model; + var records = []; + var ids = _.map(list._changes || list.data, function (localId) { + var record = self.localData[localId]; + var data = record._changes || record.data; + var many2oneId = data[fieldName]; + var many2oneRecord = self.localData[many2oneId]; + records.push(many2oneRecord); + model = many2oneRecord.model; + return many2oneRecord.res_id; + }); + return this.performModelRPC(model, 'name_get', [ids], { + context: list.context, + }).then(function (name_gets) { + for (var i = 0; i < name_gets.length; i++) { + records[i].data.display_name = name_gets[i][1]; + } + }); + }, + /** + * For a given resource of type 'record', fetch all data. + * + * @param {Object} record local resource + * @param {string[]} [fieldNames] the list of fields to fetch. If not given, + * fetch all the fields in record.fieldNames (+ display_name) + * @returns {Deferred -> Object} resolves to the record + */ + _fetchRecord: function (record, fieldNames) { + var self = this; + fieldNames = fieldNames || _.uniq(record.fieldNames.concat(['display_name'])); + var oldData = record.data; + return this.performModelRPC(record.model, 'read', [record.res_id, fieldNames], { + // context: { 'bin_size': true }, // FIXME: when editing a subrecord in the partner form view, it tries to write the bin_size on the image field + }).then(function (result) { + result = result[0]; + record.data = _.extend({}, record.data, result); + return self._postprocess(record); + }).then(function (record) { + // process many2one: split [id, nameget] and create corresponding record + _.each(fieldNames, function (name) { + var field = record.fields[name]; + var val = record.data[name]; + if (field.type === 'many2one') { + if (val !== false) { + // the many2one value is of the form [id, display_name] + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + display_name: val[1], + id: val[0], + }, + }); + record.data[name] = r.id; + } else { + // no value for the many2one + record.data[name] = false; + } + } + }); + }).then(function () { + return self._fetchX2Manys(record, oldData, fieldNames).then(function () { + return record; + }); + }); + }, + /** + * This method is incorrectly named. It should be named something like + * _fetchMany2OneData. + * + * For a given record, this method fetches all many2ones information, + * batching the requests if possible (for example, if 3 many2ones are in + * relation on the same model, then we can probably fetch them in one rpc) + * + * This method is currently only called by makeDefaultRecord, it should be + * called by the onchange methods at some point. + * + * @todo fix bug: returns a list of deferred, not a deferred + * + * @param {Object} record a valid resource object + * @returns {Deferred} + */ + _fetchRelationalData: function (record) { + var self = this; + var toBeFetched = []; + + // find all many2one related records to be fetched + _.each(record.fieldNames, function (name) { + var field = record.fields[name]; + if (field.type === 'many2one') { + var localId = (record._changes && record._changes[name]) || record.data[name]; + var relatedRecord = self.localData[localId]; + if (!relatedRecord) { + return; + } + toBeFetched.push(relatedRecord); + } + }); + + // group them by model and context. Using the context as key is + // necessary to make sure the correct context is used for the rpc; + var groups = _.groupBy(toBeFetched, function (rec) { + return [rec.model, JSON.stringify(rec.context)].join(); + }); + + return $.when.apply($, _.map(groups, this._fetchMany2OneGroup.bind(this))); + }, + /** + * Fetch all data in a ungrouped list + * + * @param {Object} list a valid resource object + * @returns {Deferred -> Object} resolves to the fecthed list + */ + _fetchUngroupedList: function (list) { + var self = this; + var def; + if (list.static) { + def = this._readUngroupedList(list); + } else { + def = this._searchReadUngroupedList(list); + } + return def.then(function () { + return self._fetchX2ManysBatched(list); + }).then(function () { + return list; + }); + }, + /** + * 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} oldData the data fetched previously + * @param {string[]} [fieldNames] list of fields to fetch + * @returns {Deferred} + */ + _fetchX2Manys: function (record, oldData, fieldNames) { + var self = this; + var defs = []; + fieldNames = fieldNames || record.fieldNames; + _.each(fieldNames, function (fieldName) { + var field = record.fields[fieldName]; + if (field.type === 'one2many' || field.type === 'many2many') { + var list; + + // a previously loaded list exists + if (oldData[fieldName]) { + list = self.localData[oldData[fieldName]]; + list.res_ids = record.data[fieldName]; + list.count = list.res_ids.length; + list.offset = list.offset < list.count ? list.offset : 0; + } else { + // need to create a list from scratch + var attrs = record.fieldAttrs[fieldName]; + var rawContext = attrs && attrs.context; + var ids = record.data[fieldName] || []; + list = self._makeDataPoint({ + count: ids.length, + fieldAttrs: field.fieldAttrs, + fields: field.relatedFields, + limit: field.limit, + modelName: field.relation, + res_ids: ids, + static: true, + type: 'list', + parentID: record.id, + rawContext: rawContext, + }); + } + record.data[fieldName] = list.id; + if (!field.__no_fetch) { + defs.push(self._readUngroupedList(list)); + } + } + }); + return $.when.apply($, defs); + }, + /** + * batch requests for 1 x2m in list + * + * @see _fetchX2ManysBatched + * @param {Object} list + * @param {string} fieldName + * @returns {Deferred} + */ + _fetchX2ManyBatched: function (list, fieldName) { + var self = this; + var field = list.fields[fieldName]; + + // step 1: collect ids + var ids = []; + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + ids = _.unique(ids.concat(record.data[fieldName] || [])); + var m2mList = self._makeDataPoint({ + fieldAttrs: field.fieldAttrs, + fields: field.relatedFields, + modelName: field.relation, + res_ids: record.data[fieldName], + static: true, + type: 'list', + }); + record.data[fieldName] = m2mList.id; + }); + + if (!ids.length || field.__no_fetch) { + return $.when(); + } + + // step 2: fetch data from server + return this.performModelRPC(field.relation, 'read', [ids, _.keys(field.relatedFields)], { + context: {}, // FIXME + }).then(function (results) { + // step 3: assign values to correct datapoints + var dataPoints = _.map(results, function (result) { + return self._makeDataPoint({ + modelName: field.relation, + data: result, + fields: field.relatedFields, + fieldAttrs: field.fieldAttrs, + }); + }); + + _.each(list.data, function (dataPoint) { + var record = self.localData[dataPoint]; + var m2mList = self.localData[record.data[fieldName]]; + + m2mList.data = []; + _.each(m2mList.res_ids, function (res_id) { + var dataPoint = _.find(dataPoints, function (d) { + return d.res_id === res_id; + }); + m2mList.data.push(dataPoint.id); + m2mList.count++; + }); + }); + }); + }, + /** + * batch request for x2ms for datapoint of type list + * + * @param {Object} list + * @returns {Deferred} + */ + _fetchX2ManysBatched: function (list) { + var defs = []; + for (var i = 0; i < list.fieldNames.length; i++) { + var field = list.fields[list.fieldNames[i]]; + if (field.type === 'many2many' || field.type === 'one2many') { + defs.push(this._fetchX2ManyBatched(list, list.fieldNames[i])); + } + } + return $.when.apply($, defs); + }, + /** + * Read all x2many fields and generate the commands for the server to create + * or write them... + * + * @param {Object} record + * @returns {Object} a map from some field names to commands + */ + _generateX2ManyCommands: function (record) { + var self = this; + var commands = {}; + var type, list, relData, relIds, removedIds, addedIds, keptIds, relRecord, i; + + var data = _.extend({}, record.data, record._changes); + + for (var fieldName in record.fields) { + type = record.fields[fieldName].type; + + if (type === 'many2many' || type === 'one2many') { + // skip if this field is empty + if (!data[fieldName]) { + commands[fieldName] = null; + continue; + } + list = this.localData[data[fieldName]]; + if (!list._changes) { + commands[fieldName] = null; + // skip if this field hasn't changed + continue; + } + relData = _.map(list._changes, function (localId) { + return self.localData[localId]; + }); + relIds = _.pluck(relData, 'res_id'); + commands[fieldName] = []; + + if (type === 'many2many') { + // 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 + // an complete override of the actual value of the m2m) + commands[fieldName].push(x2ManyCommands.replace_with(relIds)); + } else if (type === 'one2many') { + removedIds = _.difference(list.res_ids, relIds); + addedIds = _.difference(relIds, list.res_ids); + keptIds = _.intersection(list.res_ids, relIds); + for (i = 0; i < keptIds.length; i++) { + commands[fieldName].push(x2ManyCommands.link_to(keptIds[i])); + } + for (i = 0; i < removedIds.length; i++) { + commands[fieldName].push(x2ManyCommands.delete(removedIds[i])); + } + for (i = 0; i < addedIds.length; i++) { + relRecord = _.findWhere(relData, {res_id: addedIds[i]}); + relRecord = this.get(relRecord.id, {raw: true}); + commands[fieldName].push(x2ManyCommands.create(_.omit(relRecord.data, 'id'))); + } + // FIXME: update of records temporarily disabled as it doesn't work if we updated + // the record in a form view, as the _changes contains all the fields of the + // form view instead of the one that have changed + // _.each(relData, function (relRecord) { + // var new_values = relRecord._changes; + // if (!_.isEmpty(new_values)) { + // commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, new_values)); + // } + // }); + } + } + } + + 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} [options.field] if given, the context of the field will + * also be added (and evaluated) to the result + * @returns {Object} the evaluated context + */ + _getContext: function (element, options) { + var context = new data.CompoundContext(session.user_context, element.context); + if (options && options.field) { + var attrs = element.fieldAttrs[options.field]; + if (attrs && attrs.context) { + context.add(attrs.context); + } + } + if (element.rawContext) { + context.add(element.rawContext); + var evalContext = this.get(element.parentID, {raw: true}).data; + context.set_eval_context(evalContext); + } + return context.eval(); + }, + /** + * 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 + * @returns {Deferred} + */ + _load: function (dataPoint, options) { + if (dataPoint.type === 'record') { + return this._fetchRecord(dataPoint, options && options.fieldNames); + } + if (dataPoint.type === 'list' && dataPoint.groupedBy.length) { + return this._readGroup(dataPoint); + } + if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) { + return this._fetchUngroupedList(dataPoint); + } + }, + /** + * Turns a bag of properties into a valid local resource. Also, register + * the resource in the localData object. + * + * @param {Object} params + * @param {Object} [params.fieldAttrs={}] contains the attrs of each field + * @param {Array} [params.fieldNames] the name of fields to load, the list + * of all fields by default + * @param {Object} params.fields contains the description of each field + * @returns {Object} the resource created + */ + _makeDataPoint: function (params) { + var type = params.type || ('domain' in params && 'list') || 'record'; + + var res_id; + var value; + if (type === 'record') { + res_id = params.res_id || (params.data && params.data.id) || _.uniqueId('virtual_'); + } else { + var value_is_array = params.value instanceof Array; + res_id = value_is_array ? params.value[0] : undefined; + value = value_is_array ? params.value[1] : params.value; + } + params.res_id = res_id; + + var dataPoint = { + context: params.context || {}, + domain: params.domain || [], + groupedBy: params.groupedBy || [], + id: _.uniqueId(params.modelName + '_'), + model: params.modelName, + res_id: res_id, + res_ids: params.res_ids || [], + }; + + this.localData[dataPoint.id] = dataPoint; + + var fields = _.extend({ + display_name: {type: 'char'}, + id: {type: 'integer'}, + }, params.fields); + + _.extend(dataPoint, { + _cache: type === 'list' ? {} : undefined, + _changes: null, + aggregateValues: params.aggregateValues || {}, + count: params.count || dataPoint.res_ids.length, + data: params.data || (type === 'record' ? {} : []), + fieldNames: params.fieldNames || Object.keys(fields), + fields: fields, + fieldAttrs: params.fieldAttrs || {}, + isOpen: params.isOpen, + limit: type === 'record' ? 1 : params.limit, + offset: params.offset || (type === 'record' ? _.indexOf(dataPoint.res_ids, res_id) : 0), + openGroupByDefault: params.openGroupByDefault, + orderedBy: params.orderedBy || [], + parentID: params.parentID, + rawContext: params.rawContext, + static: params.static || false, + type: type, // 'record' | 'list' + value: value, + }); + + return dataPoint; + }, + /** + * 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 {any} record + * @returns {Deferred -> Object} resolves to the finished resource + */ + _postprocess: function (record) { + var self = this; + var defs = []; + _.each(record.fieldNames, function (name) { + var field = record.fields[name]; + var attrs = record.fieldAttrs[name]; + if (field.__fetch_status && !field.__status_information) { + var field_values = _.mapObject(record.data, function (val, key) { + var fieldType = record.fields[key].type; + if (fieldType === 'many2one') { + return val instanceof Array && val[0]; + } + return val; + }); + var _domain = new data.CompoundDomain(field.domain || {}).set_eval_context(field_values); + var domain = pyeval.eval('domain', _domain); + var fold_field = pyeval.py_eval(attrs.options || '{}').fold_field; + var fetch_status_information = self.performRPC('/web/dataset/search_read', { + model: field.relation, + fields: ['id'].concat(fold_field ? [fold_field] : []), + domain: domain, + }).then(function (result) { + var ids = _.pluck(result.records, 'id'); + return self.performModelRPC(field.relation, 'name_get', [ids], { + context: self._getContext(record, {field: name}), + }).then(function (name_gets) { + _.each(result.records, function (record) { + var name_get = _.find(name_gets, function (n) { + return n[0] === record.id; + }); + record.display_name = name_get[1]; + }); + field.__status_information = result.records; + }); + }); + defs.push(fetch_status_information); + } + if (field.__always_reload) { + if (record.data[name] instanceof Array) { + defs.push(self.performModelRPC(field.relation, 'name_get', [record.data[name][0]], { + context: self._getContext(record, {field: name}), + }).then(function (result) { + record.data[name] = result[0]; + })); + } + } + if (field.__fetch_selection && !self.many2ones[field.relation]) { + var fetchSelection = self + .performModelRPC(field.relation, 'name_search', [], {}) + .then(function (result) { + self.many2ones[field.relation] = result; + }); + defs.push(fetchSelection); + } + if (field.__fetch_many2manys) { + var fetchMany2Manys = self + .performModelRPC(field.relation, 'name_search', [], {}) + .then(function (result) { + self.many2manys[field.relation] = result; + }); + defs.push(fetchMany2Manys); + } + }); + + return $.when.apply($, defs).then(function () { + return record; + }); + }, + /** + * 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 + * @returns {Deferred<Object>} resolves to the fetched group object + */ + _readGroup: function (list) { + var self = this; + var fields = _.uniq(list.fieldNames.concat(list.groupedBy)); + return this.performModelRPC(list.model, 'read_group', [], { + fields: fields, + context: list.context, + groupby: list.groupedBy, + domain: list.domain, + orderby: false, + lazy: true, + }).then(function (groups) { + var rawGroupBy = list.groupedBy[0].split(':')[0]; + var previousGroups = _.map(list.data, function (groupID) { + return self.localData[groupID]; + }); + list.data = []; + list.count = groups.length; + var defs = []; + + _.each(groups, function (group) { + var aggregateValues = {}; + _.each(group, function (value, key) { + if (_.contains(fields, key) && key !== list.groupedBy[0]) { + aggregateValues[key] = value; + } + }); + var newGroup = self._makeDataPoint({ + modelName: list.model, + count: group[rawGroupBy + '_count'], + domain: group.__domain, + context: list.context, + fields: list.fields, + fieldNames: list.fieldNames, + fieldAttrs: list.fieldAttrs, + value: group[rawGroupBy], + aggregateValues: aggregateValues, + groupedBy: list.groupedBy.slice(1), + orderedBy: list.orderedBy, + limit: list.limit, + openGroupByDefault: list.openGroupByDefault, + type: 'list', // nested groupedBys not handled yet + }); + list.data.push(newGroup.id); + var old_group = _.find(previousGroups, function (g) { + return g.res_id === newGroup.res_id && g.value === newGroup.value; + }); + if (old_group) { + newGroup.isOpen = old_group.isOpen; + } else if (!newGroup.openGroupByDefault) { + newGroup.isOpen = false; + } else { + newGroup.isOpen = '__fold' in group ? !group.__fold : true; + } + if (newGroup.isOpen && newGroup.count > 0) { + defs.push(self._fetchUngroupedList(newGroup)); + } + }); + return $.when.apply($, defs).then(function () { + // generate the res_ids of the main list, being the concatenation + // of the fetched res_ids in each group + list.res_ids = _.flatten(_.map(arguments, function (group) { + return group ? group.res_ids : []; + })); + 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 {Deferred -> Object} resolves to the fetched list object + */ + _readUngroupedList: function (list) { + var self = this; + var def; + var ids = []; + var missingIds = []; + var upper_bound = list.limit ? Math.min(list.offset + list.limit, list.count) : list.count; + for (var i = list.offset; i < upper_bound; i++) { + var id = list.res_ids[i]; + ids.push(id); + if (!list._cache[id]) { + missingIds.push(id); + } + } + if (missingIds.length) { + def = this.performModelRPC(list.model, 'read', [missingIds, list.fieldNames], { + context: {}, // FIXME + }); + } else { + def = $.when(); + } + return def.then(function (records) { + list.data = []; + _.each(ids, function (id) { + var dataPoint; + if (id in list._cache) { + dataPoint = self.localData[list._cache[id]]; + } else { + dataPoint = self._makeDataPoint({ + modelName: list.model, + data: _.findWhere(records, {id: id}), + fields: list.fields, + fieldNames: list.fieldNames, + fieldAttrs: list.fieldAttrs, + }); + + // add many2one records + _.each(dataPoint.fieldNames, function (name) { + var field = dataPoint.fields[name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: dataPoint.data[name][0], + display_name: dataPoint.data[name][1], + } + }); + + dataPoint.data[name] = r.id; + } + }); + list._cache[id] = dataPoint.id; + } + list.data.push(dataPoint.id); + }); + self._sortList(list); + return list; + }); + }, + /** + * 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 {Deferred} + */ + _searchReadUngroupedList: function (list) { + var self = this; + return this.performRPC('/web/dataset/search_read', { + model: list.model, + fields: list.fieldNames, + domain: list.domain || [], + offset: list.offset, + limit: list.limit, + sort: this._serializeSort(list.orderedBy), + }).then(function (result) { + list.count = result.length; + var data = _.map(result.records, function (record) { + var dataPoint = self._makeDataPoint({ + data: record, + fields: list.fields, + fieldNames: list.fieldNames, + fieldAttrs: list.fieldAttrs, + modelName: list.model, + }); + + // add many2one records + _.each(dataPoint.fieldNames, function (name) { + var field = dataPoint.fields[name]; + if (field.type === 'many2one') { + var r = self._makeDataPoint({ + modelName: field.relation, + data: { + id: dataPoint.data[name][0], + display_name: dataPoint.data[name][1], + } + }); + + dataPoint.data[name] = r.id; + } + }); + return dataPoint.id; + }); + list.data = data; + list.res_ids = _.pluck(result.records, 'id'); + return list; + }); + }, + /** + * Helper method, generates a string to describe a ordered by sequence for + * SQL. + * + * For example, [{name: 'foo', asc: true}, {name: 'bar', asc: false}] will + * be converted into 'foo ASC, bar DESC' + * + * @param {Object[]} orderBy list of objects {name:..., asc: ...} + * @returns {string} + */ + _serializeSort: function (orderBy) { + return _.map(orderBy, function (order) { + return order.asc ? order.name + ' ASC' : order.name + ' DESC'; + }).join(', '); + }, + /** + * 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. Its intended use is for + * static datasets, such as a one2many in a form view. + * + * @param {Object} list + */ + _sortList: function (list) { + var self = this; + if (list.orderedBy.length) { + // sort records according to ordered_by[0] + var order = list.orderedBy[0]; + list.data.sort(function (r1, r2) { + var data1 = self.localData[r1].data; + var data2 = self.localData[r2].data; + if (data1[order.name] < data2[order.name]) { + return order.asc ? -1 : 1; + } + if (data1[order.name] > data2[order.name]) { + return order.asc ? 1 : -1; + } + return 0; + }); + } + }, + /** + * 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. + * + * @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 (_.contains(['one2many', 'many2one', 'many2many'], field.type)) { + var relationalElement = this.localData[element.data[fieldName]]; + + // relationalElement could be empty in the case of a many2one + if (relationalElement) { + self._visitChildren(relationalElement, fn); + } + } + } + } + if (element.type === 'list') { + _.each(element.data, function (elemId) { + var elem = self.localData[elemId]; + self._visitChildren(elem, fn); + }); + } + }, +}); + +return Model; + +}); + 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 00000000000..22ec34f714b --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_renderer.js @@ -0,0 +1,115 @@ +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. + * + * When a concrete view extends a BasicRenderer, it should add each field + * widgets in the this.widget list. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); + +var qweb = core.qweb; + +return AbstractRenderer.extend({ + /** + * @override + */ + init: function (paret, state, params) { + this._super.apply(this, arguments); + this.widgets = []; + this.activeActions = params.activeActions; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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({ + delay: { show: 1000, hide: 0 }, + title: function () { + return qweb.render('WidgetLabel.tooltip', { + debug: config.debug, + widget: widget, + }); + } + }); + }, + /** + * Helper method, used to get an object with the 'raw' field values. It is + * important when we want to evaluate if a record matches a given domain. + * + * A basic model record is a tree structure, but we sometimes need 'pure' + * data, so for example, [1,4] for the value of a one2many, not + * [{Object}, {Object}]. + * + * @param {Object} record + * @returns {Object} + */ + _getFieldValues: function (record) { + return _.mapObject(record.data, function (value, name) { + var field = record.fields[name]; + if (field.type === 'many2one') { + return value.data ? value.data.id || false : false; + } + if (field.type === 'one2many' || field.type === 'many2many') { + if (value) { + return _.pluck(value.data, 'res_id'); + } else { + return []; + } + } + if (field.type === 'boolean') { + // we want an explicit false value, not null if it is unset + return value || false; + } + return value; + }); + }, + /** + * Render the view + * + * @override + * @returns {Deferred} + */ + _render: function () { + var oldWidgets = this.widgets; + this.widgets = []; + return this._renderView().then(_.invoke.bind(_, oldWidgets, 'destroy')); + }, + /** + * Actual rendering. Supposed to be overridden by concrete renderers. + * The basic responsabilities of _renderView are: + * - use the xml arch of the view to render a jQuery representation + * - instantiate a widget from the widgets_registry for each field in the + * arch + * + * Note that the 'state' field should contains all necessary information + * for the rendering. The field widgets should be as synchronous as + * possible. + * + * @abstract + * @returns {Deferred} + */ + _renderView: function () { + return $.when(); + }, +}); + +}); 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 00000000000..1601cf4c350 --- /dev/null +++ b/addons/web/static/src/js/views/basic/basic_view.js @@ -0,0 +1,171 @@ +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 pyeval = require('web.pyeval'); + +/** + * Visit a tree of objects, where each children are in an attribute 'children'. + * For each children, we call the callback function given in arguments. + * + * @todo move this somewhere in web.utils + * + * @param {Object} tree an object describing a tree structure + * @param {function} f a callback + */ +function traverse(tree, f) { + if (f(tree)) { + _.each(tree.children, function (c) { traverse(c, f); }); + } +} + +var BasicView = AbstractView.extend({ + config: _.extend({}, AbstractView.prototype.config, { + Model: BasicModel, + Controller: BasicController, + }), + /** + * 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} arch + * @param {Object} fields + * @param {Object} params + */ + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var result = this._processFieldsView(arch, fields); + var fieldAttrs = result.fieldAttrs; + var view_fields = result.view_fields; + var fieldNames = _.keys(view_fields); + fields = _.defaults(view_fields, fields); + var self = this; + // 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 + for (var field_name in fields) { + var field = fields[field_name]; + _.each(field.views, function (inner_fields_view) { + result = self._processFieldsView(inner_fields_view.arch, inner_fields_view.fields); + inner_fields_view.fieldAttrs = result.fieldAttrs; + inner_fields_view.fields = result.view_fields; + }); + if (field.type === 'one2many' || field.type === 'many2many') { + if (field.views && field.views.tree) { + field.relatedFields = field.views.tree.fields; + field.fieldAttrs = field.views.tree.fieldAttrs; + field.limit = 80; + } else if (field.views && field.views.kanban) { + field.relatedFields = field.views.kanban.fields; + field.fieldAttrs = field.views.kanban.fieldAttrs; + field.limit = 40; + } else { + field.relatedFields = { + color: {type: 'int'}, + display_name: {type: 'char'}, + id: {type: 'integer'}, + }; + field.fieldAttrs = {}; + } + } + } + + this.controllerParams.confirmOnDelete = true; + this.controllerParams.archiveEnabled = 'active' in fields; + this.controllerParams.hasButtons = + 'action_buttons' in params ? params.action_buttons : true; + + this.loadParams.fieldAttrs = fieldAttrs; + this.loadParams.fieldNames = fieldNames; + this.loadParams.fields = fields; + this.loadParams.limit = parseInt(arch.attrs.limit, 10) || params.limit; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Visit all nodes in the arch field and process each fields + * + * @param {Object} arch + * @param {Object} fields + * @returns {Object} An object with 2 keys: view_fields and fieldAttrs + */ + _processFieldsView: function (arch, fields) { + var self = this; + var view_fields = Object.create(null); + var fieldAttrs = Object.create(null); + traverse(arch, function (node) { + if (typeof node === 'string') { + return false; + } + if (node.tag === 'field') { + var attrs = node.attrs || {}; + var field = _.extend({}, fields[node.attrs.name]); + self._processField(field, node, attrs); + view_fields[node.attrs.name] = field; + fieldAttrs[node.attrs.name] = attrs; + return false; + } + return node.tag !== 'arch'; + }); + return { + view_fields: view_fields, + fieldAttrs: fieldAttrs, + }; + }, + /** + * Process a field node, in particular, put a flag on the field to give + * special directives to the BasicModel. + * + * @param {Object} field + * @param {Object} node + * @param {Object} attrs + */ + _processField: function (field, node, attrs) { + if (attrs.options) { + var options = pyeval.py_eval(attrs.options || '{}'); + if (options.always_reload) { + field.__always_reload = true; + } + } + if (field.type === 'many2one') { + if (node.attrs.widget === 'statusbar' || node.attrs.widget === 'radio') { + field.__fetch_status = true; + } else if (node.attrs.widget === 'selection') { + field.__fetch_selection = true; + } + } + if (node.attrs.widget === 'many2many_checkboxes') { + field.__fetch_many2manys = true; + } + if (node.attrs.on_change) { + field.onChange = "1"; + } + }, +}); + +return BasicView; + +}); 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 00000000000..dbb574176f9 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_controller.js @@ -0,0 +1,267 @@ +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 QuickCreate = require('web.CalendarQuickCreate'); +var dialogs = require('web.view_dialogs'); +var Dialog = require('web.Dialog'); +var core = require('web.core'); + +var _t = core._t; +var QWeb = core.qweb; + +var CalendarController = AbstractController.extend({ + defaults: _.extend({}, AbstractController.prototype.defaults, { + confirm_on_delete: true, + }), + custom_events: _.extend({}, AbstractController.prototype.custom_events, { + openCreate: '_onOpenCreate', + openEvent: '_onOpenEvent', + dropRecord: '_onDropRecord', + updateRecord: '_onUpdateRecord', + changeDate: '_onChangeDate', + changeFilter: '_onChangeFilter', + toggleFullWidth: '_onToggleFullWidth', + }), + /** + * @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.quick_add_pop = params.quick_add_pop; + this.disable_quick_create = params.disable_quick_create; + this.confirm_on_delete = params.confirm_on_delete; + this.formViewId = params.formViewId; + this.readonlyFormViewId = params.readonlyFormViewId; + this.mapping = params.mapping; + this.context = params.context; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + + /** + * Render the buttons according to the CalendarView.buttons template and + * add listeners on it. Set this.$buttons with the produced jQuery element + * + * @param {jQueryElement} [$node] a jQuery node where the rendered buttons + * 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) { + var self = this; + this.$buttons = $(QWeb.render("CalendarView.buttons", {'widget': this})); + this.$buttons.on('click', 'button.o_calendar_button_new', function () { + self.trigger_up('switch_view', {view_type: 'form'}); + }); + + _.each(['prev', 'today', 'next'], function (action) { + self.$buttons.on('click', '.o_calendar_button_' + action, function () { + self.model[action](); + self.reload(); + }); + }); + _.each(['day', 'week', 'month'], function (scale) { + self.$buttons.on('click', '.o_calendar_button_' + scale, function () { + self.model.setScale(scale); + self.reload(); + }); + }); + + 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 + //-------------------------------------------------------------------------- + + /** + * @param {Object} record + * @param {integer} record.id + */ + _updateRecord: function (record) { + // Cannot modify actual name yet + var data = _.omit(this.model.calendarEventToRecord(record), 'name'); + this.performModelRPC(this.model, 'write', [record.id, data]) + .then(this.reload.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {OdooEvent} event + */ + _onChangeDate: function (event) { + var modelData = this.model.get(); + if (modelData.target_date.toString() === event.data.date) { + 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 { + this.model.setScale('week'); + } + this.model.setDate(event.data.date, true); + this.reload(); + }, + /** + * @param {OdooEvent} event + */ + _onChangeFilter: function (event) { + if (this.model.changeFilter(event.data) && !event.data.no_reload) { + this.reload(); + } + }, + /** + * @param {OdooEvent} event + */ + _onDropRecord: function (event) { + this._updateRecord(event.data); + }, + /** + * @param {OdooEvent} event + */ + _onOpenCreate: function (event) { + var self = this; + if (this.model.get().scale === "month") { + event.data.allDay = true; + } + var data = this.model.calendarEventToRecord(event.data); + + var context = _.extend({}, this.context); + context.default_name = data.name; + context['default_' + this.mapping.date_start] = data.start; + if (this.mapping.date_stop) { + context['default_' + this.mapping.date_stop] = data.stop; + } + if (this.mapping.date_delay) { + context['default_' + this.mapping.date_delay] = data.duration; + } + if (this.mapping.all_day) { + context['default_' + this.mapping.all_day] = true; + } + + var options = _.extend({}, this.options, {context: context}); + + if(!options.disable_quick_create && !event.data.disable_quick_create) { + if (this.quick != null) { + return this.quick.close(); + } + var model = this.modelName; + var quick = new QuickCreate(this, model, true, options, data, event.data); + quick.on('added', this, this.reload.bind(this)); + quick.open(); + quick.focus(); + return; + } + + new dialogs.FormViewDialog(self, { + res_model: this.modelName, + context: context, + title: _t("Create"), + disable_multiple_selection: true, + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }).open(); + }, + /** + * @param {OdooEvent} event + */ + _onOpenEvent: function (event) { + var self = this; + var id = event.data._id; + id = id && parseInt(id).toString() === id ? parseInt(id) : id; + var open_dialog = function (readonly) { + var options = { + res_model: self.modelName, + res_id: id || null, + context: event.context || self.context, + readonly: readonly, + title: _t("Open: ") + event.data.title, + on_saved: function () { + if (event.data.on_save) { + event.data.on_save(); + } + self.reload(); + }, + }; + if (readonly) { + if (self.readonlyFormViewId) { + options.view_id = parseInt(self.readonlyFormViewId); + } + options.buttons = [ + { + text: _t("Edit"), + classes: 'btn-primary', + close: true, + click: function () { open_dialog(false); } + }, + { + text: _t("Delete"), + click: function () { + Dialog.confirm(this, _t("Are you sure you want to delete this record ?"), { + confirm_callback: function () { + self.performModelRPC(self.modelName, 'unlink', [id]) + .then(function () { + self.dialog.destroy(); + self.reload(); + }); + } + }); + }, + }, + {text: _t("Close"), close: true} + ]; + } else if (self.formViewId) { + options.view_id = parseInt(self.formViewId); + } + self.dialog = new dialogs.FormViewDialog(self, options).open(); + }; + open_dialog(true); + }, + /** + * Called when we want to open or close the sidebar. + */ + _onToggleFullWidth: function () { + this.model.toggleFullWidth(); + this.reload(); + }, + /** + * @param {OdooEvent} event + */ + _onUpdateRecord: function (event) { + this._updateRecord(event.data); + }, +}); + +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 00000000000..16ba9999ed0 --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_model.js @@ -0,0 +1,620 @@ +odoo.define('web.CalendarModel', function (require) { +"use strict"; + +var AbstractModel = require('web.AbstractModel'); +var field_utils = require('web.field_utils'); +var session = require('web.session'); +var time = require('web.time'); +var core = require('web.core'); +var data = require('web.data'); + +var _t = core._t; + +var scales = [ + 'day', + 'week', + 'month' +]; + +return AbstractModel.extend({ + init: function () { + this._super.apply(this, arguments); + this.end_date = null; + }, + + //-------------------------------------------------------------------------- + // 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 = moment(new Date(event.start)); + var end = moment(new Date(event.end)); + + //Bug when we move an all_day event from week or day view, we don't have + // a date.end or duration... + if (end === null || end.diff(start) < 0) { + end = start.clone().add(1, 'h'); + } + + if (event.allDay) { + if (this.scale === 'month') { + if (event.r_start) { + start.hours(event.r_start.hours()) + .minutes(event.r_start.minutes()) + .seconds(event.r_start.seconds()) + .utc(); + end.add(-1, 'days') + .hours(event.r_end.hours()) + .minutes(event.r_end.minutes()) + .seconds(event.r_end.seconds()) + .utc(); + } else { + start.utc(); + end.utc(); + } + } else if (this.mapping.all_day) { + start.utc().startOf('day'); + end.utc().startOf('day'); + } else { + start.utc().hours(7); + end.utc().hours(19); + } + } else { + var offset = start.utcOffset(); + start.utc().add(-offset, 'minutes'); + end.utc().add(-offset, 'minutes'); + } + + if (this.mapping.all_day) { + if (event.record) { + data[this.mapping.all_day] = + (this.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] = time.datetime_to_str(start.toDate()); + if (this.mapping.date_delay) { + data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600; + } + + if (this.mapping.date_stop) { + data[this.mapping.date_stop] = time.datetime_to_str(end.toDate()); + } + + 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; + }, + /** + * @todo I think this is dead code + * + * @param {any} ids + * @param {any} model + * @returns + */ + deleteRecords: function (ids, model) { + return this.performModelRPC(model, 'unlink', [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 {Deferred} + */ + load: function (params) { + var self = this; + this.modelName = params.modelName; + this.fields = params.fields; + this.fieldNames = params.fieldNames; + this.mapping = params.mapping; + this.mode = params.mode; // one of month, week or day + this.scales = params.scales; // one of month, week or day + + // Check whether the date field is editable (i.e. if the events can be + // dragged and dropped) + this.editable = params.editable; + + // 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.field_color = params.field_color; + if (!this.preload_def) { + var def; + if (this.field_color) { + var fields = this.field_color.split('.'); + if (fields.length > 1) { + def = new $.Deferred(); + if (!this.model_color) { + var dataset = new data.DataSetSearch(this, this.fields[fields[0]].relation, params.context, []); + this.loadFieldView(dataset, false, 'form').then(function (fieldView) { + self.model_color = fieldView.fields[fields[1]].relation; + def.resolve(); + }); + } else { + def.resolve(); + } + } + } + + this.preload_def = $.Deferred(); + $.when( + def, + this.performModelRPC(this.modelName, 'check_access_rights', ["write", false]), + this.performModelRPC(this.modelName, 'check_access_rights', ["create", false])) + .then(function (write, create) { + self.write_right = write; + self.create_right = create; + self.preload_def.resolve(); + }); + } + + 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, + }; + + // Use scale_zoom attribute in xml file to specify zoom timeline (day,week,month) + // by default month. + this.setDate(params.initialDate, true); + this.setScale(params.mode); + + _.each(this.data.filters, function (filter) { + if (filter.avatar_field && !filter.avatar_model) { + filter.avatar_model = self.modelName; + } + }); + + return this.preload_def.then(this._loadCalendar.bind(this)); + }, + next: function () { + this.setDate(moment(this.data.target_date).add(1, this.data.scale)); + }, + prev: function () { + this.setDate(moment(this.data.target_date).add(-1, this.data.scale)); + }, + /** + * @todo: this should not work. it ignores the domain/context + * + * @override + * @param {any} _handle ignored + * @param {any} _params ignored ? really ? + * @returns {Deferred} + */ + reload: function (_handle, params) { + return this._loadCalendar(); + }, + /** + * @param {any} start + * @param {any} highlight + */ + setDate: function (start, highlight) { + this.data.start_date = this.data.end_date = this.data.target_date = this.data.highlight_date = new Date(start); + switch (this.data.scale) { + case 'month': + this.data.start_date = moment(this.data.start_date) + .startOf('month') + .startOf('week') + .toDate(); + this.data.end_date = moment(this.data.start_date) + .add(5, 'week') + .endOf('week') + .toDate(); + break; + case 'week': + this.data.start_date = moment(this.data.start_date) + .startOf('week') + .toDate(); + this.data.end_date = moment(this.data.end_date) + .endOf('week') + .toDate(); + break; + default: + this.data.start_date = moment(this.data.start_date) + .startOf('day') + .toDate(); + this.data.end_date = moment(this.data.end_date) + .endOf('day') + .toDate(); + } + if (highlight) { + this.data.highlight_date = this.data.target_date; + } + }, + setScale: function (scale) { + if (!_.contains(scales, scale)) { + scale = "week"; + } + this.data.scale = scale; + this.setDate(this.data.target_date); + }, + today: function () { + this.setDate(new Date()); + }, + toggleFullWidth: function () { + var fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') !== 'true'; + this.call('local_storage', 'setItem', 'calendar_fullWidth', fullWidth); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _getFilterDomain: function () { + var domain = []; + _.each(this.data.filters, function (filter) { + if (filter.all) return; + var nb = {}; + var not = {}; + var contains = {}; + _.each(filter.filters, function (f) { + if (!contains[filter.fieldName]) { + contains[filter.fieldName] = []; + not[filter.fieldName] = []; + nb[filter.fieldName] = 0; + } + if (f.value === 'all' && f.active) { + contains[filter.fieldName].all = true; + } else { + nb[filter.fieldName]++; + } + + if (f.active && contains[filter.fieldName].indexOf(f.value) === -1) { + contains[filter.fieldName].push(f.value); + } + if (!f.active && not[filter.fieldName].indexOf(f.value) === -1) { + not[filter.fieldName].push(f.value); + } + }); + + for (var field in contains) { + if (contains[field].all || contains[field].length === nb[field]) { + continue; + } + if (contains[field].length) { + domain.push([field, 'in', contains[field]]); + } else if (not[field].length && not[field].length !== nb[field]) { + domain.push([field, 'not in', not[field]]); + } + } + }); + return domain; + }, + /** + * @returns {Object} + */ + _getFullCalendarOptions: function () { + return { + defaultView: (this.mode == "month")? "month" : ((this.mode === "week")? "agendaWeek" : ((this.mode === "day")? "agendaDay" : "agendaWeek")), + header: false, + selectable: this.editable && this.create_right, + selectHelper: true, + editable: this.editable, + droppable: true, + navLinks: false, + eventLimit: this.eventLimit, // allow "more" link when too many events + snapMinutes: 15, + eventResizableFromStart: true, + weekNumberCalculation: function (date) { + return moment(date).week(); + }, + weekNumberTitle: _t("W"), + allDayText: _t("All day"), + views: { + week: { + columnFormat: 'ddd ' + time.strftime_to_moment_format(_t.database.parameters.date_format), + } + }, + monthNames: moment.months(), + monthNamesShort: moment.monthsShort(), + dayNames: moment.weekdays(), + dayNamesShort: moment.weekdaysShort(), + }; + }, + _getRangeDomain: function () { + // Build OpenERP Domain to filter object by this.mapping.date_start field + // between given start, end dates. + var format = time.datetime_to_str; + var domain = [[this.mapping.date_start, '<=', format(this.data.end_date)]]; + if (this.mapping.date_stop) { + domain.push([this.mapping.date_stop, '>=', format(this.data.start_date)]); + } else if (!this.mapping.date_delay) { + domain.push([this.mapping.date_start, '>=', format(this.data.start_date)]); + } + return domain; + }, + /** + * @returns {Deferred} + */ + _loadCalendar: function () { + var self = this; + this.data.fullWidth = this.call('local_storage', 'getItem', 'calendar_fullWidth') === 'true'; + this.data.fc_options = this._getFullCalendarOptions(); + + var defs = _.map(this.data.filters, this._loadFilter.bind(this)); + + return $.when.apply($, defs).then(function () { + return self.performRPC('/web/dataset/search_read', { + model: self.modelName, + context: self.data.context, + domain: self.data.domain.concat(self._getRangeDomain()).concat(self._getFilterDomain()), + fields: self.fieldNames, + }).then(function (events) { + self.data.data = _.map(events.records, self._recordToCalendarEvent.bind(self)); + return $.when( + self._loadColors(self.data, self.data.data), + self._loadRecordsToFilters(self.data, self.data.data) + ); + }); + }); + }, + /** + * @param {any} element + * @param {any} events + * @returns {Deferred} + */ + _loadColors: function (element, events) { + if (this.field_color) { + var fields = this.field_color.split('.'); + var first = fields[0]; + var self = this; + + if (fields.length === 1) { + _.each(events, function (event) { + var value = event.record[first]; + event.color_index = _.isArray(value) ? value[0] : value; + }); + this.model_color = this.fields[first].relation || element.model; + } else { + var model = this.fields[first].relation; + var ids = []; + _.each(events, function (event) { + ids.push(event.record[first][0]); + }); + ids = _.uniq(ids); + + return self.performModelRPC(model, 'read', [ids, [fields[1]]]).then(function (res) { + res = _.object(_.pluck(res, ['id']), _.pluck(res, [fields[1]])); + _.each(events, function (event) { + var value = res[event.record[first][0]]; + event.color_index = _.isArray(value) ? value[0] : value; + }); + }); + } + } + return $.Deferred().resolve(); + }, + /** + * @param {any} filter + * @returns {Deferred} + */ + _loadFilter: function (filter) { + if (!filter.write_model) { + return; + } + + var field = this.fields[filter.fieldName]; + return this.performRPC('/web/dataset/search_read', { + model: filter.write_model, + domain: [["user_id", "=", session.uid]], + fields: [filter.write_field] + }).then(function (res) { + var records = _.map(res.records, 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 = field_utils['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; + }); + }, + /** + * @param {any} element + * @param {any} events + * @returns {Deferred} + */ + _loadRecordsToFilters: function (element, events) { + var self = this; + var new_filters = {}; + var to_read = {}; + _.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 = []; + _.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; + fs.push({ + 'color': self.model_color === (field.relation || element.model) ? value : false, + 'value': value, + 'label': field_utils['format_'+ field.type](_value, field), + 'avatar_model': field.relation || element.model, + }); + }); + }); + _.each(fs, function (f) { + var f1 = _.findWhere(filter.filters, f); + if (f1) { + f1.display = true; + } else { + f.display = f.active = true; + filter.filters.push(f); + } + }); + }); + + var defs = []; + _.each(to_read, function (ids, model) { + defs.push(self.performModelRPC(model, 'name_get', [_.uniq(ids)]).then(function (res) { + to_read[model] = _.object(res); + })); + }); + return $.when.apply($, 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]; + }); + } + }); + }); + }, + /** + * Transform OpenERP event object to fullcalendar event object + */ + _recordToCalendarEvent: function (evt) { + var date_start; + var date_stop; + var date_delay = evt[this.mapping.date_delay] || 1.0, + all_day = this.mapping.all_day ? evt[this.mapping.all_day] : false, + the_title = '', + attendees = []; + + if (!all_day) { + date_start = time.auto_str_to_date(evt[this.mapping.date_start]); + date_stop = this.mapping.date_stop ? time.auto_str_to_date(evt[this.mapping.date_stop]) : null; + } else { + date_start = time.auto_str_to_date(evt[this.mapping.date_start].split(' ')[0],'start'); + date_stop = this.mapping.date_stop ? time.auto_str_to_date(evt[this.mapping.date_stop].split(' ')[0],'start') : null; + } + + if (!date_stop && date_delay) { + var m_start = moment(date_start).add(date_delay,'hours'); + date_stop = m_start.toDate(); + } + + date_start = moment(date_start); + date_stop = moment(date_stop); + + var r = { + 'record': evt, + 'start': date_start.format('YYYY-MM-DD HH:mm:ss'), + 'end': date_stop.format('YYYY-MM-DD HH:mm:ss'), + 'r_start':date_start.clone(), + 'r_end':date_stop.clone(), + 'title': the_title, + 'allDay': this.mapping.all_day && evt[this.mapping.all_day] || false, + 'id': evt.id, + 'attendees':attendees, + }; + + if (this.mapping.all_day && evt[this.mapping.all_day]) { + // r.start = date_start.format('YYYY-MM-DD'); + // r.end = date_stop.format('YYYY-MM-DD'); + } else if (this.data.scale === 'month' && this.fields[this.mapping.date_start].type !== 'date') { + // allow to resize in month mode + r.reset_allday = r.allDay; + r.allDay = true; + r.start = date_start.format('YYYY-MM-DD'); + r.end = date_stop.clone().add(1, 'day').startOf('day').format('YYYY-MM-DD'); + } + + return r; + }, +}); + +}); 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 00000000000..326c36ea9eb --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_quick_create.js @@ -0,0 +1,140 @@ +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({ + /** + * @constructor + * @param {Widget} parent + * @param {string} modelName + * @param {any} buttons + * @param {any} options + * @param {any} data_template + * @param {any} data_calendar + */ + init: function (parent, modelName, buttons, options, data_template, data_calendar) { + this.modelName = modelName; + this._buttons = buttons || false; + this.options = options; + + // Can hold data pre-set from where you clicked on agenda + this.data_template = data_template || {}; + this.$input = $(); + + var self = this; + this._super(parent, { + title: this._getTitle(), + size: 'small', + buttons: this._buttons ? [ + {text: _t("Create"), classes: 'btn-primary', click: function () { + if (!self._quickAdd()) { + self.focus(); + } + }}, + {text: _t("Edit"), click: function () { + data_calendar.disable_quick_create = true; + data_calendar.title = self.$input.val().trim(); + data_calendar.on_save = self.destroy.bind(self); + self.trigger_up('openCreate', data_calendar); + }}, + {text: _t("Cancel"), close: true}, + ] : [], + $content: QWeb.render('CalendarView.quick_create', {widged: this}) + }); + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + var self = this; + + if (this.options.disable_quick_create) { + this.slow_create(); + return; + } + this.on('added', this, function () { + self.close(); + }); + + this.$input = this.$('input').keyup(function enterHandler (e) { + if(e.keyCode === $.ui.keyCode.ENTER) { + self.$input.off('keyup', enterHandler); + if (!self._quickAdd()){ + self.$input.on('keyup', enterHandler); + } + } else if (e.keyCode === $.ui.keyCode.ESCAPE && self._buttons) { + self.close(); + } + }); + + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + focus: function () { + this.$input.focus(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + _getTitle: function () { + var parent = this.getParent(); + if (_.isUndefined(parent)) { + return _t("Create"); + } + var title = (_.isUndefined(parent.field_widget)) ? + (parent.title || parent.string || parent.name) : + (parent.field_widget.string || parent.field_widget.name || ''); + return _t("Create: ") + title; + }, + /** + * Gathers data from the quick create dialog a launch quick_create(data) method + */ + _quickAdd: function () { + var val = this.$input.val().trim(); + return (val)? this._quickCreate({'name': val}, this.options) : false; + }, + /** + * Handles saving data coming from quick create box + */ + _quickCreate: function (data, options) { + var self = this; + return this.performModelRPC(this.modelName, 'create', + [$.extend({}, this.data_template, data)], _.pick(options, 'context')) + .then(function (id) { + self.trigger('added', id); + self.$input.val(""); + }, function (r, event) { + event.preventDefault(); + // This will occurs if there are some more fields required + self.slow_create(data); + }); + }, +}); + +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 00000000000..5062051b8da --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -0,0 +1,450 @@ +odoo.define('web.CalendarRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var relational_fields = require('web.relational_fields'); +var FieldManagerMixin = require('web.FieldManagerMixin'); +var field_utils = require('web.field_utils'); +var session = require('web.session'); +var Dialog = require('web.Dialog'); +var Widget = require('web.Widget'); +var utils = require('web.utils'); +var core = require('web.core'); +var QWeb = require('web.QWeb'); + +var _t = core._t; +var qweb = core.qweb; + +var scales = { + day: 'agendaDay', + week: 'agendaWeek', + month: 'month' +}; + +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.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; + + if (this.write_model || this.write_field) { + this.many2one = new SidebarFilterM2O(this, + this.write_field, + this.model.get(this.model.makeRecord(this.write_model, [{ + name: this.write_field, + relation: options.fields[this.fieldName].relation, + type: 'many2one', + }])), + { + mode: 'edit', + can_create: false, + }); + } + }, + 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_checkbox input', this._onFilterActive.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var self = this; + event.stopPropagation(); + var value = event.data.changes[this.write_field].id; + this.model.performModelRPC(this.write_model, 'create', [{'user_id': session.uid,'partner_id': value,}]) + .then(function () { + self.trigger_up('changeFilter', { + 'fieldName': self.fieldName, + 'value': value, + 'active': true, + }); + }); + }, + _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'), + }); + }, + /** + * @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.performModelRPC(self.write_model, 'unlink', [[$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", + events: _.extend({}, AbstractRenderer.prototype.events, { + 'click .o_calendar_sidebar_toggler': '_onToggleSidebar', + }), + + /** + * @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.filters = []; + this.color_map = {}; + + if (params.eventTemplate) { + this.qweb = new QWeb(session.debug, {_s: session.origin}); + params.eventTemplate.children = [{ + 'tag': "t", + 'attrs': {'t-name': "calendar-box"}, + 'children': params.eventTemplate.children + }]; + this.qweb.add_template(utils.json_node_to_xml(params.eventTemplate)); + } + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + this._initSidebar(); + this._initCalendar(); + return this._super(); + }, + /** + * @override + */ + destroy: function () { + if (this.$calendar) { + this.$calendar.fullCalendar('destroy'); + } + if (this.$small_calendar) { + this.$small_calendar.datepicker('destroy'); + } + 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]; + var value = this._format(record, 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 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; + } + var index = (((_.keys(this.color_map).length + 1) * 5) % 24) + 1; + this.color_map[key] = index; + return index; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @param {any} event + * @returns {string} the html for the rendered event + */ + _eventRender: function (event) { + var qweb_context = { + event: event, + record: event.record, + widget: this, + read_only_mode: this.read_only_mode, + user_context: session.user_context, + format: this._format.bind(this), + fields: this.state.fields + }; + this.qweb_context = qweb_context; + if (_.isEmpty(qweb_context.record)) { + return ''; + } else { + return (this.qweb || qweb).render("calendar-box", qweb_context); + } + }, + /** + * @param {any} record + * @param {any} fieldName + * @returns {string} + */ + _format: function (record, fieldName) { + var field = this.state.fields[fieldName]; + return field_utils['format_'+field.type](record[fieldName], field); + }, + /** + * Initialize the main calendar + */ + _initCalendar: function () { + var self = this; + + this.$calendar = this.$(".o_calendar_widget"); + + //Documentation here : http://arshaw.com/fullcalendar/docs/ + var fc_options = $.extend({}, this.state.fc_options, { + eventDrop: function (event) { + self.trigger_up('dropRecord', event); + }, + eventResize: function (event) { + self.trigger_up('updateRecord', event); + }, + eventClick: function (event) { + self.trigger_up('openEvent', event); + self.$calendar.fullCalendar('unselect'); + }, + select: function (target_date, end_date, event, _js_event, _view) { + self.trigger_up('openCreate', {'start': target_date, 'end': end_date}); + self.$calendar.fullCalendar('unselect'); + }, + eventRender: function (event, element) { + var $render = $(self._eventRender(event)); + event.title = $render.find('.o_field_type_char:first').text(); + element.find('.fc-content').html($render.html()); + element.addClass($render.attr('class')); + var display_hour = (event.start.format('HH:mm') === '00:00' ? event.r_start.format('HH:mm') : event.start.format('HH:mm')) + ' - ' + + (event.end && event.end.format('HH:mm') !== '00:00' ? event.end.format('HH:mm') : event.r_end.format('HH:mm')); + if (display_hour === '00:00 - 00:00') { + display_hour = _t('All the day'); + } + element.find('.fc-content .fc-time').text(display_hour); + }, + + unselectAuto: false, + }); + + this.$calendar.fullCalendar(fc_options); + }, + /** + * Initialize the mini calendar in the sidebar + */ + _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: new Date(+obj.currentYear , +obj.currentMonth, +obj.currentDay).toString() + }); + }, + 'dayNamesMin' : this.state.fc_options.dayNamesShort, + 'monthNames': this.state.fc_options.monthNamesShort, + 'firstDay': this.state.fc_options.firstDay, + }); + }, + /** + * Initialize the sidebar + */ + _initSidebar: function () { + this.$sidebar = this.$('.o_calendar_sidebar'); + this.$sidebar_container = this.$(".o_calendar_sidebar_container"); + this._initCalendarMini(); + }, + /** + * Render the calendar view, this is the main entry point. + * + * @override method from AbstractRenderer + * @returns {Deferred} + */ + _render: function () { + var $calendar = this.$calendar; + var $fc_view = $calendar.find('.fc-view'); + var scrollPosition = $fc_view.scrollLeft(); + + $fc_view.scrollLeft(0); + $calendar.fullCalendar('unselect'); + + if (scales[this.state.scale] !== $calendar.data('fullCalendar').getView().type) { + $calendar.fullCalendar('changeView', scales[this.state.scale]); + } + + if (this.target_date !== this.state.target_date.toString()) { + $calendar.fullCalendar('gotoDate', moment(this.state.target_date)); + this.target_date = this.state.target_date.toString(); + } + + var highlightDate = moment(this.state.highlight_date).format('YYYY-MM-DD'); + $calendar.find('.o_target_date').removeClass('o_target_date'); + $calendar.find('.fc-bg .fc-day[data-date="'+highlightDate+'"]') + .addClass('o_target_date'); + + this.$small_calendar.datepicker("setDate", this.state.highlight_date) + .find('.o_selected_range') + .removeClass('o_color o_selected_range'); + var $a; + switch (this.state.scale) { + case 'month': $a = this.$small_calendar.find('td a'); break; + case 'week': $a = this.$small_calendar.find('tr:has(.ui-state-active) a'); 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'); + }); + + $fc_view.scrollLeft(scrollPosition); + + var fullWidth = this.state.fullWidth; + this.$('.o_calendar_sidebar_toggler') + .toggleClass('fa-close', !fullWidth) + .toggleClass('fa-chevron-left', fullWidth) + .attr('title', !fullWidth ? _('Close Sidebar') : _('Open Sidebar')); + this.$sidebar_container.toggleClass('o_sidebar_hidden', fullWidth); + this.$sidebar.toggleClass('o_hidden', fullWidth); + + this._renderFilters(); + this.$calendar.appendTo('body'); + this.$calendar.fullCalendar('render'); + this._renderEvents(); + this.$calendar.prependTo(this.$('.o_calendar_view')); + + return this._super.apply(this, arguments); + }, + /** + * Render all events + */ + _renderEvents: function () { + this.$calendar.fullCalendar('removeEvents'); + this.$calendar.fullCalendar('addEventSource', this.state.data); + }, + /** + * Render all filters + */ + _renderFilters: function () { + var self = this; + _.each(this.filters || (this.filters = []), function (filter) { + filter.destroy(); + }); + if (this.state.fullWidth) { + return; + } + _.each(this.state.filters, function (options) { + if (!_.find(options.filters, function (f) {return f.display == null || f.display;})) { + return; + } + options.getColor = self.getColor.bind(self); + options.fields = self.state.fields; + var filter = new SidebarFilter(self, options); + filter.appendTo(self.$sidebar); + self.filters.push(filter); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Toggle the sidebar + */ + _onToggleSidebar: function () { + this.trigger_up('toggleFullWidth'); + }, +}); + +}); + + // dead code ? + // + // calendarMiniChanged: function (datum, obj) { + // this.$calendar.fullCalendar('gotoDate', +obj.currentYear , +obj.currentMonth, +obj.currentDay); + // }, 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 00000000000..288e908797c --- /dev/null +++ b/addons/web/static/src/js/views/calendar/calendar_view.js @@ -0,0 +1,129 @@ +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 utils = require('web.utils'); + +var _lt = core._lt; + +// gather the fields to get +var fieldsToGather = [ + "date_start", + "date_delay", + "date_stop", + "all_day", +]; + +var CalendarView = AbstractView.extend({ + display_name: _lt('Calendar'), + icon: 'fa-calendar', + config: { + Model: CalendarModel, + Controller: CalendarController, + Renderer: CalendarRenderer, + }, + init: function (arch, fields, params) { + this._super.apply(this, arguments); + var attrs = arch.attrs; + + if (!attrs.date_start) { + throw new Error(_t("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); + } + }); + + if (attrs.color) { + fieldNames.push(attrs.color.split('.')[0]); + } + + var eventLimit = attrs.event_limit !== null && (isNaN(+attrs.event_limit) ? _.str.toBool(attrs.event_limit) : +attrs.event_limit); + + var filters = {}; + + _.each(arch.children, function (child) { + switch (child.tag) { + case "filter": + if (params.sidebar === false) break; // if we have not sidebar, (eg: Dashboard), we don't use the filter "coworkers" + var fieldName = child.attrs.name; + filters[fieldName] = { + 'title': fields[fieldName].string, + 'fieldName': fieldName, + 'filters': [], + }; + if (child.attrs.avatar_field) { + filters[fieldName].avatar_field = child.attrs.avatar_field; + filters[fieldName].avatar_model = fields[fieldName].relation; + } + if (child.attrs.write_model) { + filters[fieldName].write_model = child.attrs.write_model; + filters[fieldName].write_field = child.attrs.write_field; // can't use a x2many fields + } + fieldNames.push(child.attrs.name); + break; + case "field": + fieldNames.push(child.attrs.name); + if (!child.attrs.invisible) { + displayFields[child.attrs.name] = child.attrs; + } + } + }); + + if (_.isEmpty(displayFields)) { + displayFields = fields.display_name ? {'display_name': {}} : []; + } + + //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.quick_add_pop = (attrs.quick_add || utils.toBoolElse(attrs.quick_add+'', true)); + this.controllerParams.disable_quick_create = params.disable_quick_create || !this.controllerParams.quick_add_pop; + this.controllerParams.confirm_on_delete = true; + // If this field is set ot true, we don't open the event in form view, but in a popup with the view_id passed by this parameter + this.controllerParams.formViewId = !attrs.form_view_id || !utils.toBoolElse(attrs.form_view_id, true) ? false : attrs.form_view_id; + this.controllerParams.readonlyFormViewId = !attrs.readonly_form_view_id || !utils.toBoolElse(attrs.readonly_form_view_id, true) ? false : attrs.readonly_form_view_id; + this.controllerParams.mapping = mapping; + this.controllerParams.context = params.context || {}; + + this.rendererParams.displayFields = displayFields; + this.rendererParams.eventTemplate = _.findWhere(arch.children, {'tag': 'template'}); + + this.loadParams.fieldNames = _.uniq(fieldNames); + this.loadParams.mapping = mapping; + this.loadParams.fields = fields; + this.loadParams.editable = !this.controllerParams.read_only_mode && !fields[mapping.date_start].readonly; + this.loadParams.eventLimit = eventLimit; + this.loadParams.field_color = attrs.color; + + this.loadParams.filters = filters; + this.loadParams.mode = attrs.mode; + this.loadParams.scale_zoom = attrs.scale_zoom; + this.loadParams.initialDate = params.initialDate || new Date(); + + // is_action_enabled: function (action) { + // if (action === 'create' && !this.options.creatable) { + // return false; + // } + // return this._super(action); + // }, + + + }, +}); + +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 00000000000..f1a238e7316 --- /dev/null +++ b/addons/web/static/src/js/views/field_manager_mixin.js @@ -0,0 +1,164 @@ +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'); + +return { + custom_events: { + field_changed: '_onFieldChanged', + name_create: '_onNameCreate', + name_get: '_onNameGet', + name_search: '_onNameSearch', + load: '_onLoad', + }, + /** + * 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); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 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 + */ + _confirmChange: function (id, fields, event) { + // to be implemented + }, + /** + * 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 + */ + _confirmSave: function (id) { + // to be implemented, if necessary + }, + + //-------------------------------------------------------------------------- + // 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) { + var self = this; + // 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(); + var dataPointID = event.data.dataPointID; + this.model.notifyChanges(dataPointID, event.data.changes).then(function (result) { // FIXME if datamodel is not a BasicModel, notifyChanges does not exists + if (event.data.force_save) { + self.model.save(dataPointID).then(function () { + self._confirmSave(dataPointID); + }); + } else { + self._confirmChange(dataPointID, result, event); + } + }); + }, + /** + * 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; + 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.model.reload(data.id, params).then(function (db_id) { + data.on_success(self.model.get(db_id)); + }); + }, + /** + * When a name_create event arrives, the nameCreate method from the model + * should be called. + * + * @param {OdooEvent} event + */ + _onNameCreate: function (event) { + var data = event.data; + if (!data.on_success) { return; } + this.model + .nameCreate(data.model, data.name) + .then(data.on_success) + .fail(function () { + if (data.on_fail) { + data.on_fail(); + } + }); + }, + /** + * When a name_get event arrives, the name_get method from the model should + * be called. + * + * @param {OdooEvent} event + */ + _onNameGet: function (event) { + var data = event.data; + if (!data.on_success) { return; } + this.model.name_get(data.model, data.ids) + .then(data.on_success); // fixme: handle context + }, + /** + * Some field widgets need to perform a namesearch. For example, a many2one + * widget when it needs to fetch its autocompletion data. + * + * @todo Note: I think that this is wrong, the field widget should just perform + * a name_search rpc on its own, no need for this. + * + * @deprecated Just do a regular name_search + * @param {OdooEvent} event + */ + _onNameSearch: function (event) { + var data = event.data; + if (!data.on_success) { return; } + this.model + .nameSearch(data.model, data.search_val, data.domain, data.operator, data.limit) + .then(data.on_success); + }, +}; + +}); 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 00000000000..d1badda7993 --- /dev/null +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -0,0 +1,642 @@ +odoo.define('web.FormController', function (require) { +"use strict"; + +var BasicController = require('web.BasicController'); +var dialogs = require('web.view_dialogs'); +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var Sidebar = require('web.Sidebar'); + +var _t = core._t; +var qweb = core.qweb; + +var FormController = BasicController.extend({ + // className: "o_form_view", + custom_events: _.extend({}, BasicController.prototype.custom_events, { + open_record: '_onOpenRecord', + toggle_column_order: '_onToggleColumnOrder', + form_button_clicked: '_onFormButtonClicked', + bounce_edit: '_onBounceEdit', + add_one2many_record: '_onAddOne2ManyRecord', + }), + /** + * @override + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.actionButtons = params.actionButtons; + this.footerToButtons = params.footerToButtons; + this.defaultButtons = params.defaultButtons; + this.hasSidebar = params.hasSidebar; + this.toolbar = params.toolbar; + this.mode = params.mode; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method is supposed to focus the first active control, I think. It + * is currently only called by the FormViewDialog. + * + * @todo To be implemented + */ + autofocus: function () { + }, + /** + * This method switches the form view in edit mode, with a new record. + * + * @returns {Deferred} + */ + createRecord: function () { + var self = this; + var record = this.model.get(this.handle, {raw: true}); + return this.model.makeDefaultRecord(this.modelName, { + context: this.context, + fieldNames: record.fieldNames, + fields: record.fields, + res_ids: record.res_ids, + }).then(function (handle) { + self.handle = handle; + self._updateEnv(); + self._toEditMode(); + }); + }, + /** + * Returns the current res_id, wrapped in a list. This is only used by the + * sidebar (and the debugmanager) + * + * @todo This should be private. Need to change sidebar code + * + * @returns {number[]} either [current res_id] or [] + */ + getSelectedIds: function () { + var env = this.model.get(this.handle, {env: true}); + // FIX ME : fix sidebar widget + return env.currentId ? [env.currentId] : []; + }, + /** + * @override method from AbstractController + * @returns {string} + */ + getTitle: function () { + var dataPoint = this.model.get(this.handle, {raw: true}); + return dataPoint.data.display_name || _t('New'); + }, + /** + * 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 {jQueryElement} $node + */ + renderButtons: function ($node) { + var $footer = this.$('footer'); + if (!this.defaultButtons && (!this.footerToButtons || !$footer.length)) { + return; + } + this.$buttons = $('<div/>'); + if (this.footerToButtons && $footer.length) { + this.$buttons.append($footer.detach().contents()); + } else { + this.$buttons.append(qweb.render("FormView.buttons", {widget: this})); + this.$buttons.find('.o_form_button_edit') + .click(this._toEditMode.bind(this)); + this.$buttons.find('.o_form_button_save') + .click(this.saveRecord.bind(this)); + this.$buttons.find('.o_form_button_cancel') + .click(this._onDiscardChange.bind(this)); + this.$buttons.find('.o_form_button_create') + .click(this.createRecord.bind(this)); + + this._updateButtons(); + } + 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 + */ + renderPager: function ($node, options) { + options = _.extend({}, options, { + validate: this._canBeDiscarded.bind(this), + }); + this._super($node, options); + }, + /** + * Instantiate and render the sidebar if a sidebar is requested + * Sets this.sidebar + * @param {jQuery} [$node] a jQuery node where the sidebar should be + * inserted + **/ + renderSidebar: function ($node) { + if (!this.sidebar && this.hasSidebar) { + this.sidebar = new Sidebar(this, { + editable: this.is_action_enabled('edit') + }); + if (this.toolbar) { + this.sidebar.add_toolbar(this.toolbar); + } + var otherItems = []; + if (this.is_action_enabled('delete')) { + otherItems.push({ + label: _t('Delete'), + callback: this._deleteRecords.bind(this, [this.handle]), + }); + } + if (this.is_action_enabled('create')) { + otherItems.push({ + label: _t('Duplicate'), + callback: this._onDuplicateRecord.bind(this), + }); + } + this.sidebar.add_items('other', otherItems); + this.sidebar.appendTo($node); + + // Show or hide the sidebar according to the view mode + this._updateSidebar(); + } + }, + /** + * Save the current record + * + * @param {Object} options + * @param {boolean} [options.stayInEdit=false] if true, don't switch to + * readonly mode after saving the record + * @param {boolean} [options.reload=true] if true, reload the record after + * saving + * @returns {Deferred} + */ + saveRecord: function (options) { + var self = this; + options = options || {}; + var stayInEdit = 'stayInEdit' in options ? options.stayInEdit : false; + var shouldReload = 'reload' in options ? options.reload : true; + var db_id = this.handle; + if (!this.model.isDirty(db_id) && !this.model.isNew(db_id)) { + if (!stayInEdit) { + this._toReadOnlyMode(); + } + return $.Deferred().resolve(); + } else { + var invalid_fields = this.renderer.checkInvalidFields(); + if (invalid_fields.length) { + this._notifyInvalidFields(invalid_fields); + return $.Deferred().reject(); + } else { + return this.model.save(db_id, {reload: shouldReload}) + .then(function (element) { + if (!stayInEdit) { + self._toReadOnlyMode(); + } + self.trigger_up('saved', _.clone(element.data)); + }); + } + } + }, + /** + * We need to check for all 'mode' change, because the information is not + * available from the model. + * + * @override + * @param {Object} params + * @returns {Deferred} + */ + update: function (params) { + this.mode = params.mode || this.mode; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * When the user clicks on a form button, this function determines what + * should happen. + * + * @param {Object} attrs the attrs of the button clicked + * @param {Object} [record] the current state of the view + * @returns {Deferred} + */ + _callButtonAction: function (attrs, record) { + var self = this; + var def = $.Deferred(); + var db_id = this.handle; + record = record || this.model.get(db_id); + var recordID = record.data.id; + this.trigger_up('execute_action', { + action_data: _.extend({}, attrs, { + context: data.build_context(record, attrs.context) + }), + model: record.model, + record_id: recordID, + on_close: function (reason) { + if (!_.isObject(reason)) { + return self.load_record(recordID) + .then(self.update_state.bind(self)); + } + }, + on_fail: function () { + self.reload(); + }, + on_success: def.resolve.bind(def), + }); + return def; + }, + /** + * Determines if we can discard the current change. If the model is not + * dirty, that is not a problem. However, if it is dirty, we have to ask + * the user for confirmation. + * + * @returns {Deferred} If the deferred is resolved, we assume the changes + * can be discarded. If it has been rejected, then we cannot discard. + */ + _canBeDiscarded: function () { + if (!this.model.isDirty(this.handle)) { + return $.when(); + } + var message = _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?"); + var def = $.Deferred(); + var options = { + title: _t("Warning"), + confirm_callback: function () { + this.on('closed', null, def.resolve.bind(def)); + }, + cancel_callback: def.reject.bind(def) + }; + var dialog = Dialog.confirm(this, message, options); + dialog.$modal.on('hidden.bs.modal', def.reject.bind(def)); + return def; + }, + /** + * @override method from FieldManagerMixin + * @param {string} id it is ignored, because we already know it. + * @param {string[]} fields + * @param {OdooEvent} event the event that triggered the change + */ + _confirmChange: function (id, fields, event) { + var record = this.model.get(this.handle); + this.renderer.updateWidgets(fields, record, event); + }, + /** + * When a save operation has been confirmed from the model, this method is + * called. + * + * @override method from field manager mixin + * @param {string} id + */ + _confirmSave: function (id) { + if (id === this.handle) { + this.reload(); + } else { + // a subrecord 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); + var fieldsChanged = _.findKey(record.data, function (d) { + return _.isObject(d) && + (d.id === id || _.findWhere(d.data, {id: id})); + }); + this.renderer.updateWidgets([fieldsChanged], record); + } + }, + /** + * Helper function to display a warning that some field have an invalid + * value. This is used when a save operation cannot be completed. + * + * @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 (field_name) { + var fieldStr = fields[field_name].string; + return _.str.sprintf('<li>%s</li>', _.escape(fieldStr)); + }); + warnings.unshift('<ul>'); + warnings.push('</ul>'); + this.do_warn(_t("The following fields are invalid:"), warnings.join('')); + }, + /** + * We just add the current ID to the state pushed. This allows the web + * client to add it in the url, for example. + * + * @override method from AbstractController + * @param {Object} [state] + */ + _pushState: function (state) { + state = state || {}; + var env = this.model.get(this.handle, {env: true}); + state.id = env.currentId; + this._super(state); + }, + /** + * Change the view mode to 'edit' + * + * @see _toReadOnlyMode + */ + _toEditMode: function () { + this.$el.addClass('o_form_editable'); + this.update({mode: "edit"}, {reload: false}); + }, + /** + * Change the view mode to 'readonly' + * + * @see _toEditMode + */ + _toReadOnlyMode: function () { + this.$el.removeClass('o_form_editable'); + this.update({mode: "readonly"}, {reload: false}); + }, + /** + * Updates the controller's title according to the new state + * + * @override + * @param {Object} state + * @returns {Deferred} + */ + _update: function (state) { + var title = this.getTitle(); + this.set('title', title); + this._updateButtons(); + this._updateSidebar(); + return this._super.apply(this, arguments); + }, + /** + * @todo move this in _update + */ + _updateButtons: function () { + if (this.$buttons) { + var edit_mode = (this.mode === 'edit'); + this.$buttons.find('.o_form_buttons_edit') + .toggleClass('o_hidden', !edit_mode); + this.$buttons.find('.o_form_buttons_view') + .toggleClass('o_hidden', edit_mode); + } + }, + /** + * Show or hide the sidebar according to the actual_mode + * @todo move this in _update + */ + _updateSidebar: function () { + if (this.sidebar) { + this.sidebar.do_toggle(this.mode === 'readonly'); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} event + */ + _onAddOne2ManyRecord: function (event) { + var field = event.data.field; + new dialogs.FormViewDialog(this, { + res_model: field.relation, + domain: event.data.domain, + context: event.data.context, + on_save: event.data.on_save, + title: _t('Create: ') + field.string, + initial_view: 'form', + fields_view: field.views ? field.views.form : undefined, + form_view_options: {'not_interactible_on_create': true}, + model: this.model, + }).open(); + }, + /** + * Bounce the 'Edit' button. + */ + _onBounceEdit: function () { + if (this.$buttons) { + this.$buttons.find('.o_form_button_edit').openerpBounce(); + } + }, + /** + * If the user clicks on 'Discard', we have to check if the changes can be + * discarded, then actually do it. + */ + _onDiscardChange: function () { + var self = this; + this._canBeDiscarded().then(function () { + self.model.discardChanges(self.handle); + if (!self.model.isNew(self.handle)) { + self._toReadOnlyMode(); + } else { + self.do_action('history_back'); + } + }); + }, + /** + * Called when the user clicks on 'Duplicate Record' in the sidebar + */ + _onDuplicateRecord: function () { + var self = this; + this.model.duplicateRecord(this.handle) + .then(function (handle) { + self.handle = handle; + self._updateEnv(); + self._toEditMode(); + }); + }, + /** + * This method comes from the field manager mixin. We force to save directly + * the changes if the form is in readonly, because in that case the changes + * come from widgets that are editable even in readonly (e.g. Priority). + * + * @override + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + if (this.mode === 'readonly') { + event.data.force_save = true; + } + this._super.apply(this, arguments); + }, + /** + * @param {OdooEvent} event + */ + _onFormButtonClicked: function (event) { + // stop the event's propagation as a form controller might have other + // form controllers in its descendants (e.g. in a FormViewDialog) + event.stopPropagation(); + var self = this; + var def; + + var attrs = event.data.attrs; + if (attrs.confirm) { + var d = $.Deferred(); + Dialog.confirm(this, attrs.confirm, { confirm_callback: function () { + self._callButtonAction(attrs, event.data.record); + }}).on("closed", null, function () { + d.resolve(); + }); + def = d.promise(); + } else if (attrs.special) { + def = this._callButtonAction(attrs, event.data.record); + } else { + def = this.saveRecord().then(function () { + return self._callButtonAction(attrs, event.data.record); + }); + } + def.then(function () { + self.reload(); + }); + + if (event.data.show_wow) { + def.then(function () { + self.show_wow(); + }); + } + }, + /** + * Open a record in a form view dialog + * + * @param {OdooEvent} event + */ + _onOpenRecord: function (event) { + var self = this; + var record = this.model.get(event.data.id); + var model = record.model; + var res_id = record.res_id; + var fieldsViewDef; + if (event.data.form_view) { + fieldsViewDef = $.when(event.data.form_view); + } else { + var context = event.data.context; + var domain = event.data.domain; + var dataset = new data.DataSetSearch(this, model, context, domain); + fieldsViewDef = this.loadFieldView(dataset, false, 'form'); + } + fieldsViewDef.then(function (form_fields_view) { + new dialogs.FormViewDialog(self, { + res_model: model, + res_id: res_id, + fields_view: form_fields_view, + context: data.build_context(record), + readonly: event.data.readonly, + title: _t("Open: ") + event.data.string, + on_save: event.data.on_save, + on_saved: event.data.on_saved, + }).open(); + }); + }, + /** + * This method is called when someone tries to sort a column, most likely + * in a x2many list view + * + * @param {OdooEvent} event + */ + _onToggleColumnOrder: function (event) { + this.model.setSort(event.data.id, event.data.name); + var field = event.data.field; + var state = this.model.get(this.handle); + this.renderer.updateWidgets([field], state); + }, + +}); + +return FormController; + +}); + + // load_record: function (id) { + // var index = _.indexOf(this.datasetIds, this.currentId); + // return this.model.load({ + // // context: this.options.context, + // id: id, + // model: this.modelName, + // offset: index, + // res_ids: this.datasetIds, + // count: this.datasetIds.length, + // }); + // }, + // do_show: function (view_options) { + // var defs = []; + // defs.push(this._super.apply(this, arguments)); + // if (this.has_been_started) { + // var dataset = view_options && view_options.dataset; + // this.dataset_ids = dataset ? dataset.res_ids : this.dataset.ids; + // this.current_id = dataset ? dataset.current_id : this.dataset.ids[this.dataset.index]; + // defs.push(this.load_record(this.current_id).then(this.update_state.bind(this))); + // } + // this.has_been_started = true; + // return $.when.apply($, defs); + // }, + // _onDeletedRecords: function (ids) { + // var index = this.dataset_ids.indexOf(this.current_id); + // this.dataset_ids.splice(index, 1); + // this.current_id = this.dataset_ids[index]; + // if (!this.current_id) { + // this.current_id = this.dataset_ids[this.dataset_ids.length - 1]; + // } + // if (!this.current_id) { + // this.do_action('history_back'); + // } else { + // this.load_record(this.current_id).then(this.update_state.bind(this)); + // } + // }, + // _callButtonAction: function (attrs, record) { + // var self = this; + // var def = $.Deferred(); + // record = record || this.model.get(this.db_id); + // var record_id = record.data.id; + // this.trigger_up('execute_action', { + // action_data: _.extend({}, attrs, { context: data.build_context(record, attrs.context) }), + // dataset: this.dataset, + // record_id: record_id, + // on_close: function (reason) { + // if (!_.isObject(reason)) { + // return self.load_record(record_id).then(self.update_state.bind(self)); + // } + // }, + // on_fail: function () { + // self.load_record(record_id).then(function (db_id) { + // self.update_state(db_id); + // def.resolve(); + // }); + // }, + // on_success: def.resolve.bind(def), + // }); + // return def; + // }, + // show_wow: function () { + // var others = ['o_wow_peace', 'o_wow_heart']; + // var wow_class = Math.random() <= 0.9 ? 'o_wow_thumbs' : _.sample(others); + // var $body = $('body'); + // $body.addClass(wow_class); + // setTimeout(function () { + // $body.removeClass(wow_class); + // }, 1000); + // }, + // _onFieldChanged: function (event) { + // if (this.mode === 'readonly') { + // event.data.force_save = true; + // } + // this._super.apply(this, arguments); + // }, + // update_renderer: function () { + // var state = this.model.get(this.db_id); + // this.renderer.update(state, {mode: this.mode}); + // }, + // do_action: function (event) { + // this.do_action(event.data.action, event.data.options || {}).then(function (result) { + // if (event.data.on_success) { + // event.data.on_success(result); + // } + // }); + // }, + + // this.dataset_ids = this.options.dataset ? this.options.dataset.res_ids : this.dataset.ids; + // this.current_id = this.options.dataset ? this.options.dataset.current_id : this.dataset.ids[this.dataset.index]; + // this.options.context = _.extend({}, this.options.action.context, this.options.context); 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 00000000000..91887d45b5d --- /dev/null +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -0,0 +1,603 @@ +odoo.define('web.FormRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var Domain = require('web.Domain'); +var field_registry = require('web.field_registry'); + +var _t = core._t; +var qweb = core.qweb; + +var FIELD_CLASSES = { + 'one2many': 'o_field_one2many', +}; + +return BasicRenderer.extend({ + className: "o_form_view", + /** + * @override method from BasicRenderer + * @param {Object} params + * @param {string} params.mode either 'readonly' on 'edit' + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.idsForLabels = {}; + this.widgets = []; + this.mode = params.mode; + this.field_values = this._getFieldValues(this.state); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method has two responsabilities: find every invalid fields in the + * current form, 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 there is a label. + * + * @returns {string[]} the list of invalid field names + */ + checkInvalidFields: function () { + var self = this; + var invalid_fields = []; + _.each(this.widgets, function (widget) { + var is_valid = widget.isValid(); + if (!is_valid) { + invalid_fields.push(widget.name); + } + widget.$el.toggleClass('o_form_invalid', !is_valid); + var idForLabel = self.idsForLabels[widget.name]; + var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); + $label.toggleClass('o_form_invalid', !is_valid); + }); + return invalid_fields; + }, + /** + * 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 () { + var state = {}; + this.$('div.o_notebook').each(function () { + var $notebook = $(this); + var name = $notebook.data('name'); + var index = -1; + $notebook.find('li').each(function (i) { + if ($(this).hasClass('active')) { + index = i; + } + }); + state[name] = index; + }); + return state; + }, + /** + * restore active tab pages for each notebook + * + * @todo make sure this method is called + * + * @param {Object} state the result from a getLocalState call + */ + setLocalState: function (state) { + this.$('div.o_notebook').each(function () { + var $notebook = $(this); + var name = $notebook.data('name'); + if (name in state) { + $notebook.find('> ul > li > a[data-toggle="tab"]') + .eq(state[name]) + .click(); + } + }); + }, + /** + * @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 {Deferred} + */ + updateState: function (state, params) { + 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 + // updateWidgets method + if (params.fieldNames) { + // only update the given fields + return this.updateWidgets(params.fieldNames, state); + } + this.field_values = this._getFieldValues(state); + return this._super.apply(this, arguments); + }, + /** + * Update some field widgets with a new state. We could rerender the form + * 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 {string[]} fields list of fields to be updated + * @param {Object} state new state + * @param {OdooEvent} [event] the event that triggered the change + * @returns {Deferred} + */ + updateWidgets: function (fields, state, event) { + this.state = state; + this.field_values = this._getFieldValues(this.state); + var defs = []; + _.each(this.widgets, function (widget) { + if (_.contains(fields, widget.name) || widget.resetOnAnyFieldChange) { + defs.push(widget.reset(state, event)); + } + }); + // re-evalute the modifiers with the new state + this._evaluateModifiers(); + return $.when.apply($, defs); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds the adequate classnames to a field widget's $el. + * + * @param {Object} widget a field widget + * @param {Object} field description of the field + */ + _addFieldClassNames: function (widget, field) { + widget.$el.addClass('o_form_field'); + // classname 'o_form_field_empty' hides the widget, so we apply it in + // on existing records for fields with no value, in readonly mode or if the + // field is readonly + if (this.state.data.id && !widget.isSet() && (this.mode === 'readonly' || field.readonly)) { + widget.$el.addClass('o_form_field_empty'); + } + }, + _addOnClickAction: function ($el, node) { + var self = this; + $el.click(function () { + self.trigger_up('form_button_clicked', { + attrs: node.attrs, + show_wow: self.$el.hasClass('o_wow'), // TODO: implement this (in view) + }); + }); + }, + _evaluateModifiers: function () { + var self = this; + _.each(this.nodeModifiers, function (d) { + if ('invisible' in d.modifiers) { + var is_invisible = new Domain(d.modifiers.invisible).compute(self.field_values); + d.$el.toggleClass('o_form_invisible', is_invisible); + if (d.onEvalCallback) { + d.onEvalCallback({invisible: is_invisible}); + } + } + }); + }, + _getIDForLabel: function (name) { + var idForLabel = this.idsForLabels[name]; + if (!idForLabel) { + idForLabel = _.uniqueId('o_field_input_'); + this.idsForLabels[name] = idForLabel; + } + return idForLabel; + }, + _handleAttributes: function ($el, node, onEvalCallback) { + // register the modifiers with their corresponding $el so that they can + // be re-evaluated at each field changed + this.nodeModifiers.push({ + $el: $el, + modifiers: JSON.parse(node.attrs.modifiers || "{}"), + onEvalCallback: onEvalCallback, + }); + if (node.attrs.class) { + $el.addClass(node.attrs.class); + } + }, + _renderButtonBox: function (node) { + var $result = $('<' + node.tag + '>', { 'class': 'o_not_full' }); + // Avoid to show buttons if we are in create mode (edit mode without res_id) + if (this.mode === 'edit' && !this.state.res_id) { + return $result; + } + var buttons = _.map(node.children, this._renderStatButton.bind(this)); + var buttons_partition = _.partition(buttons, function ($button) { + return $button.is('.o_form_invisible'); + }); + var invisible_buttons = buttons_partition[0]; + var visible_buttons = buttons_partition[1]; + + // Get the unfolded buttons according to window size + var nb_buttons = [2, 4, 6, 7][config.device.size_class]; + 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 (LESS 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($("<button>", { + type: 'button', + 'class': "btn btn-sm oe_stat_button o_button_more dropdown-toggle", + 'data-toggle': "dropdown", + text: _t("More"), + })); + + var $ul = $("<ul>", {'class': "dropdown-menu o_dropdown_more", role: "menu"}); + _.each(folded_buttons, function ($button) { + $('<li>').appendTo($ul).append($button); + }); + $ul.appendTo($result); + } + + this._handleAttributes($result, node); + return $result; + }, + _renderFieldWidget: function (node) { + var name = node.attrs.name; + var field = this.state.fields[name]; + var widget_keys = ['form.' + field.type, field.type, 'abstract']; + if (node.attrs.widget) { + widget_keys = ['form.' + node.attrs.widget, node.attrs.widget].concat(widget_keys); + } + var Widget = field_registry.getAny(widget_keys); + + if (node.attrs.widget && !field_registry.contains(node.attrs.widget)) { + console.warn('missing widget: ' + node.attrs.widget + ' for field', name, 'of type', field.type); + } + + var modifiers = JSON.parse(node.attrs.modifiers || "{}"); + var is_readonly = new Domain(modifiers.readonly).compute(this.field_values); + var is_required = field.required || new Domain(modifiers.required).compute(this.field_values); + var options = { + mode: is_readonly ? 'readonly' : this.mode, + required: is_required, + idForLabel: this._getIDForLabel(name), + widgets_registry: field_registry, + }; + var widget = new Widget(this, name, this.state, options); + this.widgets.push(widget); + var def = widget.__widgetRenderAndInsert(function () {}); + if (def.state() === 'pending') { + this.defs.push(def); + } + widget.$el.addClass(FIELD_CLASSES[field.type]); + this._handleAttributes(widget.$el, node); + this._addFieldClassNames(widget, field); + + return widget; + }, + _renderGenericTag: function (node) { + var $result = $('<' + node.tag + '>'); + _.each(node.attrs, function (attr, name) { + if (name !== 'class' && name !== 'modifiers') { + $result.attr(name, attr); + } + }); + this._handleAttributes($result, node); + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + _renderHeaderButton: function (node) { + var $button = $('<button>') + .text(node.attrs.string) + .addClass('btn btn-sm btn-default'); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + return $button; + }, + _renderInnerField: function (node) { + var field = this.state.fields[node.attrs.name]; + var field_descr = node.attrs.string || field.string; + var hasLabel = !node.attrs.nolabel; + var widget = this._renderFieldWidget(node); + if (hasLabel) { + var $label = $('<label>') + .addClass('o_form_label') + .attr('for', this._getIDForLabel(node.attrs.name)) + .text(field_descr); + var isReadOnly = this.mode === 'readonly' || field.readonly; + if (this.state.data.id && !widget.isSet() && isReadOnly) { + $label.addClass('o_form_label_empty'); + } + this._handleAttributes($label, node); + return $('<tr>') + .append($('<td class="o_td_label">').append($label)) + .append($('<td style="width: 100%">').append(widget.$el)); + } else { + var style = { + width: 100/2 + '%' + }; + return $('<tr>') + .append($('<td colspan="1">').css(style).append(widget.$el)); + } + }, + _renderInnerGroup: function (node) { + var $result = $('<table class="o_group o_inner_group"/>'); + this._handleAttributes($result, node); + if (node.attrs.string) { + var $sep = $('<tr><td colspan="2" style="width:100%;"><div class="o_horizontal_separator">' + node.attrs.string + '</div></td></tr>'); + $result.append($sep); + } + var children = node.children; + for (var i = 0; i < children.length; i++) { + if (children[i].tag === 'field') { + var fieldNodes = [children[i]]; + if (children[i].attrs.nolabel && children[i+1] && + children[i+1].tag === 'field' && children[i+1].attrs.nolabel) { + fieldNodes.push(children[i+1]); + i++; + } + $result = this._renderInnerGroupRow($result, fieldNodes); + } else if (children[i].tag === 'label') { + var label = children[i]; + // If there is a "for" attribute, we expect to have an id concerned in the next node. + if (label.attrs.for) { + var linked_node = children[i+1]; + $result = this._renderInnerGroupLabel($result, label, linked_node); + i++; // Skip the rendering of the next node because we just did it. + } else { + $result = this._renderInnerGroupLabel($result, label); + } + } else { + var $td = $('<td colspan="2" style="width:100%;">').append(this._renderNode(children[i])); + $result.append($('<tr>').append($td)); + } + } + return $result; + }, + _renderInnerGroupLabel: function ($result, label, linked_node) { + var $first = $('<td class="o_td_label">') + .append(this._renderNode(label)); + var $second = linked_node ? $('<td>').append(this._renderNode(linked_node)) : $('<td>'); + var $tr = $('<tr>').append($first).append($second); + return $result.append($tr); + }, + _renderInnerGroupRow: function ($result, nodes) { + var $tr = $('<tr>'); + for (var i = 0; i < nodes.length; i++) { + $tr.append(this._renderInnerField(nodes[i]).contents()); + } + return $result.append($tr); + }, + /** + * 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 deferred in this.defs, and + * the _renderView method will properly wait. + * + * @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); + }, + _renderStatButton: function (node) { + var $button = $('<button>').addClass('btn btn-sm oe_stat_button'); + if (node.attrs.icon) { + $('<div>') + .addClass('fa fa-fw o_button_icon') + .addClass(node.attrs.icon) + .appendTo($button); + } + $button.append(_.map(node.children, this._renderNode.bind(this))); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + return $button; + }, + _renderTabHeader: function (page, page_id) { + var $a = $('<a>', { + 'data-toggle': 'tab', + disable_anchor: 'true', + href: '#' + page_id, + role: 'tab', + text: page.attrs.string, + }); + return $('<li>').append($a); + }, + _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; + }, + _renderTagButton: function (node) { + var widget = { + node: node, + string: (node.attrs.string || '').replace(/_/g, '') + }; + if (node.attrs.icon) { + widget.fa_icon = node.attrs.icon.indexOf('fa-') === 0; + } + var $button = $(qweb.render('WidgetButton', {widget: widget})); + this._addOnClickAction($button, node); + this._handleAttributes($button, node); + return $button; + }, + _renderTagField: function (node) { + return this._renderFieldWidget(node).$el; + }, + _renderTagForm: function (node) { + var $result = $('<div/>'); + if (node.attrs.class) { + $result.addClass(node.attrs.class); + } + $result.append(_.map(node.children, this._renderNode.bind(this))); + return $result; + }, + _renderTagGroup: function (node) { + var self = this; + var is_outer_group = _.some(node.children, function (child) { + return child.tag === 'group'; + }); + if (!is_outer_group) { + return this._renderInnerGroup(node); + } + + var $result = $('<div class="o_group"/>'); + var $child; + _.each(node.children, function (child) { + if (child.tag === 'group') { + $child = self._renderInnerGroup(child); + } else { + $child = self._renderNode(child); + } + $child.addClass('o_group_col_6'); + $child.appendTo($result); + }); + + return $result; + }, + _renderTagHeader: function (node) { + var self = this; + var $statusbar = $('<div>').addClass('o_form_statusbar'); + var $buttons = $('<div>').addClass('o_statusbar_buttons'); + $statusbar.append($buttons); + _.each(node.children, function (child) { + if (child.tag === 'button') { + $buttons.append(self._renderHeaderButton(child)); + } + if (child.tag === 'field') { + var widget = self._renderFieldWidget(child); + $statusbar.append(widget.$el); + } + }); + return $statusbar; + }, + _renderTagLabel: function (node) { + var text; + if ('string' in node.attrs) { // allow empty string + text = node.attrs.string; + } else if (node.attrs.for) { + text = this.state.fields[node.attrs.for].string; + } else { + return this._renderGenericTag(node); + } + var $result = $('<label>') + .addClass('o_form_label') + .attr('for', this._getIDForLabel(node.attrs.for)) + .text(text); + this._handleAttributes($result, node); + return $result; + }, + _renderTagNotebook: function (node) { + var self = this; + var $headers = $('<ul class="nav nav-tabs">'); + var $pages = $('<div class="tab-content nav nav-tabs">'); + _.each(node.children, function (child, index) { + var pageID = _.uniqueId('notebook_page_'); + var $header = self._renderTabHeader(child, pageID); + var $page = self._renderTabPage(child, pageID); + if (index === 0) { + $header.addClass('active'); + $page.addClass('active'); + } + self._handleAttributes($header, child, function (attrs) { + // if the active tab is invisible, activate the first visible tab instead + if (attrs.invisible && $header.hasClass('active')) { + $header.removeClass('active'); + $page.removeClass('active'); + var $firstVisibleTab = $headers.find('li:not(.o_form_invisible):first()'); + $firstVisibleTab.addClass('active'); + $pages.find($firstVisibleTab.find('a').attr('href')).addClass('active'); + } + }); + $headers.append($header); + $pages.append($page); + }); + return $('<div class="o_notebook">') + .data('name', node.attrs.name || '_default_') + .append($headers) + .append($pages); + }, + _renderTagSeparator: function (node) { + return $('<div/>').addClass('o_horizontal_separator').text(node.attrs.string); + }, + _renderTagSheet: function (node) { + this.has_sheet = true; + var $result = $('<div>').addClass('o_form_sheet_bg'); + var $sheet = $('<div>').addClass('o_form_sheet'); + $sheet.append(_.map(node.children, this._renderNode.bind(this))); + $result.append($sheet); + return $result; + }, + /** + * Main entry point for the rendering. From here, we call _renderNode on + * the root of the arch, then, when every deferred (from the field widgets) + * are done, it will resolves itself. + * + * @override method from BasicRenderer + * @returns {Deferred} + */ + _renderView: function () { + var self = this; + this.defs = []; + this.nodeModifiers = []; + + // render the form and evaluate the modifiers + var $form = this._renderNode(this.arch).addClass(this.className); + this._evaluateModifiers(); + + var defs = this.defs; + delete this.defs; + return $.when.apply($, defs).then(function () { + self.$el.html($form.contents()); + + // necessary to allow all sub widgets to use their dimensions in layout + // related activities, such as autoresize on fieldtexts + core.bus.trigger('DOM_updated'); + + self.$el.toggleClass('o_form_nosheet', !self.has_sheet); + self.$el.toggleClass('o_form_editable', self.mode === 'edit'); + self.$el.toggleClass('o_form_readonly', self.mode === 'readonly'); + // Attach the tooltips on the fields' label + _.each(self.widgets, function (widget) { + if (core.debug || widget.attrs.help || widget.field.help) { + var idForLabel = self.idsForLabels[widget.name]; + var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $(); + self._addFieldTooltip(widget, $label); + } + }); + + }); + }, +}); + +}); 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 00000000000..ca667f6886d --- /dev/null +++ b/addons/web/static/src/js/views/form/form_view.js @@ -0,0 +1,44 @@ +odoo.define('web.FormView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var FormRenderer = require('web.FormRenderer'); +var FormController = require('web.FormController'); +var core = require('web.core'); + +var _lt = core._lt; + +var FormView = BasicView.extend({ + display_name: _lt('Form'), + icon: 'fa-edit', + multi_record: false, + searchable: false, + + config: _.extend({}, BasicView.prototype.config, { + Renderer: FormRenderer, + Controller: FormController, + }), + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var mode = params.mode || (params.currentId ? 'readonly' : 'edit'); + this.loadParams.type = 'record'; + + this.controllerParams.hasSidebar = params.sidebar; + this.controllerParams.toolbar = false; + this.controllerParams.footerToButtons = params.footer_to_buttons; + if ('action' in params) { + this.controllerParams.footerToButtons = params.action.flags.footer_to_buttons; + } + var defaultButtons = 'default_buttons' in params ? params.default_buttons : true; + this.controllerParams.defaultButtons = defaultButtons; + this.controllerParams.mode = mode; + + this.rendererParams.mode = mode; + this.model = params.model; + }, +}); + +return FormView; + +}); diff --git a/addons/web/static/src/js/views/form_common.js b/addons/web/static/src/js/views/form_common.js deleted file mode 100644 index 273da34f678..00000000000 --- a/addons/web/static/src/js/views/form_common.js +++ /dev/null @@ -1,1115 +0,0 @@ -odoo.define('web.form_common', function (require) { -"use strict"; - -var core = require('web.core'); -var data = require('web.data'); -var data_manager = require('web.data_manager'); -var Dialog = require('web.Dialog'); -var ListView = require('web.ListView'); -var pyeval = require('web.pyeval'); -var SearchView = require('web.SearchView'); -var session = require('web.session'); -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -/** - * Interface implemented by the form view or any other object - * able to provide the features necessary for the fields to work. - * - * Properties: - * - display_invalid_fields : if true, all fields where is_valid() return true should - * be displayed as invalid. - * - actual_mode : the current mode of the field manager. Can be "view", "edit" or "create". - * Events: - * - view_content_has_changed : when the values of the fields have changed. When - * this event is triggered all fields should reprocess their modifiers. - * - field_changed:<field_name> : when the value of a field change, an event is triggered - * named "field_changed:<field_name>" with <field_name> replaced by the name of the field. - * This event is not related to the on_change mechanism of OpenERP and is always called - * when the value of a field is setted or changed. This event is only triggered when the - * value of the field is syntactically valid, but it can be triggered when the value - * is sematically invalid (ie, when a required field is false). It is possible that an event - * about a precise field is never triggered even if that field exists in the view, in that - * case the value of the field is assumed to be false. - */ -var FieldManagerMixin = { - /** - * Must return the asked field as in fields_get. - */ - get_field_desc: function(field_name) {}, - /** - * Returns the current value of a field present in the view. See the get_value() method - * method in FieldInterface for further information. - */ - get_field_value: function(field_name) {}, - /** - Gives new values for the fields contained in the view. The new values could not be setted - right after the call to this method. Setting new values can trigger on_changes. - - @param {Object} values A dictonary with key = field name and value = new value. - @return {$.Deferred} Is resolved after all the values are setted. - */ - set_values: function(values) {}, - /** - Computes an OpenERP domain. - - @param {Array} expression An OpenERP domain. - @return {boolean} The computed value of the domain. - */ - compute_domain: function(expression) {}, - /** - Builds an evaluation context for the resolution of the fields' contexts. Please note - the field are only supposed to use this context to evualuate their own, they should not - extend it. - - @return {CompoundContext} An OpenERP context. - */ - build_eval_context: function() {}, -}; - -/** - Welcome. - - If you read this documentation, it probably means that you were asked to use a form view widget outside of - a form view. Before going further, you must understand that those fields were never really created for - that usage. Don't think that this class will hold the answer to all your problems, at best it will allow - you to hack the system with more style. -*/ -var DefaultFieldManager = Widget.extend({ - init: function(parent, eval_context) { - this._super(parent); - this.field_descs = {}; - this.eval_context = eval_context || {}; - this.set({ - display_invalid_fields: false, - actual_mode: 'create', - }); - }, - get_field_desc: function(field_name) { - if (this.field_descs[field_name] === undefined) { - this.field_descs[field_name] = { - string: field_name, - }; - } - return this.field_descs[field_name]; - }, - extend_field_desc: function(fields) { - var self = this; - _.each(fields, function(v, k) { - _.extend(self.get_field_desc(k), v); - }); - }, - get_field_value: function(field_name) { - return false; - }, - set_values: function(values) { - // nothing - }, - compute_domain: function(expression) { - return data.compute_domain(expression, {}); - }, - build_eval_context: function() { - return new data.CompoundContext(this.eval_context); - }, -}); - -/** - * A mixin to apply on any FormWidget that has to completely re-render when its readonly state - * switch. - */ -var ReinitializeWidgetMixin = { - /** - * Default implementation of, you should not override it, use initialize_field() instead. - */ - start: function() { - this.initialize_field(); - this._super(); - }, - initialize_field: function() { - this.on("change:effective_readonly", this, this.reinitialize); - this.initialize_content(); - }, - reinitialize: function() { - this.destroy_content(); - this.renderElement(); - this.initialize_content(); - }, - /** - * Called to destroy anything that could have been created previously, called before a - * re-initialization. - */ - destroy_content: function() {}, - /** - * Called to initialize the content. - */ - initialize_content: function() {}, -}; - -/** - * A mixin to apply on any field that has to completely re-render when its readonly state - * switch. - */ -var ReinitializeFieldMixin = _.extend({}, ReinitializeWidgetMixin, { - reinitialize: function() { - ReinitializeWidgetMixin.reinitialize.call(this); - if (!this.no_rerender) { - var res = this.render_value(); - if (this.view && this.view.render_value_defs){ - this.view.render_value_defs.push(res); - } - } - }, -}); - -/** - A mixin containing some useful methods to handle completion inputs. - - The widget containing this option can have these arguments in its widget options: - - no_quick_create: if true, it will disable the quick create -*/ -var CompletionFieldMixin = { - init: function() { - this.limit = 7; - this.orderer = new utils.DropMisordered(); - this.can_create = this.node.attrs.can_create || true; - this.can_write = this.node.attrs.can_write || true; - }, - /** - * Call this method to search using a string. - */ - get_search_result: function(search_val) { - var self = this; - - var dataset = new data.DataSet(this, this.field.relation, self.build_context()); - this.last_query = search_val; - var exclusion_domain = [], ids_blacklist = this.get_search_blacklist(); - if (!_(ids_blacklist).isEmpty()) { - exclusion_domain.push(['id', 'not in', ids_blacklist]); - } - - return this.orderer.add(dataset.name_search( - search_val, new data.CompoundDomain(self.build_domain(), exclusion_domain), - 'ilike', this.limit + 1, self.build_context())).then(function(_data) { - self.last_search = _data; - // possible selections for the m2o - var values = _.map(_data, function(x) { - x[1] = x[1].split("\n")[0]; - return { - label: _.str.escapeHTML(x[1].trim()) || data.noDisplayContent, - value: x[1], - name: x[1], - id: x[0], - }; - }); - - // search more... if more results that max - if (values.length > self.limit) { - values = values.slice(0, self.limit); - values.push({ - label: _t("Search More..."), - action: function() { - dataset.name_search(search_val, self.build_domain(), 'ilike', 160).done(function(_data) { - self._search_create_popup("search", _data); - }); - }, - classname: 'o_m2o_dropdown_option' - }); - } - // quick create - var raw_result = _(_data.result).map(function(x) {return x[1];}); - if (search_val.length > 0 && !_.include(raw_result, search_val) && - ! (self.options && (self.options.no_create || self.options.no_quick_create))) { - self.can_create && values.push({ - label: _.str.sprintf(_t('Create "<strong>%s</strong>"'), - $('<span />').text(search_val).html()), - action: function() { - self._quick_create(search_val); - }, - classname: 'o_m2o_dropdown_option' - }); - } - // create... - if (!(self.options && (self.options.no_create || self.options.no_create_edit)) && self.can_create){ - values.push({ - label: _t("Create and Edit..."), - action: function() { - self._search_create_popup("form", undefined, self._create_context(search_val)); - }, - classname: 'o_m2o_dropdown_option' - }); - } - else if (values.length === 0) { - values.push({ - label: _t("No results to show..."), - action: function() {}, - }); - } - - return values; - }); - }, - get_search_blacklist: function() { - return []; - }, - _quick_create: function(name) { - var self = this; - var slow_create = function () { - self._search_create_popup("form", undefined, self._create_context(name)); - }; - if (self.options.quick_create === undefined || self.options.quick_create) { - new data.DataSet(this, this.field.relation, self.build_context()) - .name_create(name).done(function(data) { - if (!self.get('effective_readonly')) - self.add_id(data[0]); - }).fail(function(error, event) { - event.preventDefault(); - slow_create(); - }); - } else - slow_create(); - }, - // all search/create popup handling - _search_create_popup: function(view, ids, context) { - var self = this; - new SelectCreateDialog(this, _.extend({}, (this.options || {}), { - res_model: self.field.relation, - domain: self.build_domain(), - context: new data.CompoundContext(self.build_context(), context || {}), - title: (view === 'search' ? _t("Search: ") : _t("Create: ")) + this.string, - initial_ids: ids ? _.map(ids, function(x) {return x[0];}) : undefined, - initial_view: view, - disable_multiple_selection: true, - on_selected: function(element_ids) { - self.add_id(element_ids[0]); - self.focus(); - } - })).open(); - }, - /** - * To implement. - */ - add_id: function(id) {}, - _create_context: function(name) { - var tmp = {}; - var field = (this.options || {}).create_name_field; - if (field === undefined) - field = "name"; - if (field !== false && name && (this.options || {}).quick_create !== false) - tmp["default_" + field] = name; - return tmp; - }, -}; - -/** - * Must be applied over an class already possessing the PropertiesMixin. - * - * Apply the result of the "invisible" domain to this.$el. - */ -var InvisibilityChangerMixin = { - init: function(field_manager, invisible_domain) { - var self = this; - this._ic_field_manager = field_manager; - this._ic_invisible_modifier = invisible_domain; - this._ic_field_manager.on("view_content_has_changed", this, function() { - var result = self._ic_invisible_modifier === undefined ? false : - self._ic_field_manager.compute_domain(self._ic_invisible_modifier); - self.set({"invisible": result}); - }); - this.set({invisible: this._ic_invisible_modifier === true, force_invisible: false}); - var check = function() { - if (self.get("invisible") || self.get('force_invisible')) { - self.set({"effective_invisible": true}); - } else { - self.set({"effective_invisible": false}); - } - }; - this.on('change:invisible', this, check); - this.on('change:force_invisible', this, check); - check.call(this); - }, - start: function() { - this.on("change:effective_invisible", this, this._check_visibility); - this._check_visibility(); - }, - _check_visibility: function() { - this.$el.toggleClass('o_form_invisible', this.get("effective_invisible")); - }, -}; - -var InvisibilityChanger = core.Class.extend(core.mixins.PropertiesMixin, InvisibilityChangerMixin, { - init: function(parent, field_manager, invisible_domain, $el) { - this.setParent(parent); - core.mixins.PropertiesMixin.init.call(this); - InvisibilityChangerMixin.init.call(this, field_manager, invisible_domain); - this.$el = $el; - this.start(); - }, -}); - -// Specialization of InvisibilityChanger for the `notebook` form element to handle more -// elegantly its special cases (i.e. activate the closest visible sibling) -var NotebookInvisibilityChanger = InvisibilityChanger.extend({ - // Override start so that it does not call _check_visibility since it will be - // called again when view content will be loaded (event view_content_has_changed) - start: function() { - this.on("change:effective_invisible", this, this._check_visibility); - }, - _check_visibility: function() { - this._super(); - if (this.get("effective_invisible") === true) { - // Switch to invisible - // Remove this element as active and set a visible sibling active (if there is one) - if (this.$el.hasClass('active')) { - this.$el.removeClass('active'); - var visible_siblings = this.$el.siblings(':not(.o_form_invisible)'); - if (visible_siblings.length) { - $(visible_siblings[0]).addClass('active'); - } - } - } else { - // Switch to visible - // If there is no visible active sibling, set this element as active, - // otherwise if that sibling hasn't autofocus and if we are in edit mode, - // remove that sibling as active and set this element as active - var visible_active_sibling = this.$el.siblings(':not(.o_form_invisible).active'); - if (!(visible_active_sibling.length)) { - this.$el.addClass('active'); - } else if (!$(visible_active_sibling[0]).data('autofocus') && this._ic_field_manager.get('actual_mode') === "edit") { - this.$el.addClass('active'); - $(visible_active_sibling[0]).removeClass('active'); - } - } - }, -}); - -/** - Base class for all fields, custom widgets and buttons to be displayed in the form view. - - Properties: - - effective_readonly: when it is true, the widget is displayed as readonly. Vary depending - the values of the "readonly" property and the "mode" property on the field manager. -*/ -var FormWidget = Widget.extend(InvisibilityChangerMixin, { - /** - * @constructs instance.web.form.FormWidget - * @extends instance.web.Widget - * - * @param field_manager - * @param node - */ - init: function(field_manager, node) { - this._super(field_manager); - this.field_manager = field_manager; - this.view = this.field_manager; - this.node = node; - this.session = session; - this.modifiers = JSON.parse(this.node.attrs.modifiers || '{}'); - InvisibilityChangerMixin.init.call(this, this.field_manager, this.modifiers.invisible); - - this.field_manager.on("view_content_has_changed", this, this.process_modifiers); - - this.set({ - required: false, - readonly: false, - }); - // some events to make the property "effective_readonly" sync automatically with "readonly" and - // "mode" on field_manager - var self = this; - var test_effective_readonly = function() { - self.set({"effective_readonly": self.get("readonly") || self.field_manager.get("actual_mode") === "view"}); - }; - this.on("change:readonly", this, test_effective_readonly); - this.field_manager.on("change:actual_mode", this, test_effective_readonly); - test_effective_readonly.call(this); - }, - renderElement: function() { - this.process_modifiers(); - this._super(); - this.$el.addClass(this.node.attrs["class"] || ""); - this.$el.attr('style', this.node.attrs.style); - }, - destroy: function() { - $.fn.tooltip('destroy'); - this._super.apply(this, arguments); - }, - /** - * Sets up blur/focus forwarding from DOM elements to a widget (`this`). - * - * This method is an utility method that is meant to be called by child classes. - * - * @param {jQuery} $e jQuery object of elements to bind focus/blur on - */ - setupFocus: function ($e) { - var self = this; - $e.on({ - focus: function () { self.trigger('focused'); }, - blur: function () { self.trigger('blurred'); } - }); - }, - process_modifiers: function() { - var to_set = {}; - for (var a in this.modifiers) { - if (!this.modifiers.hasOwnProperty(a)) { continue; } - if (!_.include(["invisible"], a)) { - var val = this.field_manager.compute_domain(this.modifiers[a]); - to_set[a] = val; - } - } - this.set(to_set); - }, - do_attach_tooltip: function(widget, trigger, options) { - widget = widget || this; - trigger = trigger || this.$el; - options = _.extend({ - delay: { show: 1000, hide: 0 }, - title: function() { - var template = widget.template + '.tooltip'; - if (!QWeb.has_template(template)) { - template = 'WidgetLabel.tooltip'; - } - return QWeb.render(template, { - debug: session.debug, - widget: widget - }); - } - }, options || {}); - //only show tooltip if we are in debug or if we have a help to show, otherwise it will display - //as empty - if (session.debug || widget.node.attrs.help || (widget.field && widget.field.help)){ - $(trigger).tooltip(options); - } - }, - /** - * Builds a new context usable for operations related to fields by merging - * the fields'context with the action's context. - */ - build_context: function() { - // only use the model's context if there is not context on the node - var v_context = this.node.attrs.context; - if (! v_context) { - v_context = (this.field || {}).context || {}; - } - - if (v_context.__ref || true) { //TODO: remove true - var fields_values = this.field_manager.build_eval_context(); - v_context = new data.CompoundContext(v_context).set_eval_context(fields_values); - } - return v_context; - }, - build_domain: function() { - var f_domain = this.field.domain || []; - var n_domain = this.node.attrs.domain || null; - // if there is a domain on the node, overrides the model's domain - var final_domain = n_domain !== null ? n_domain : f_domain; - if (!(final_domain instanceof Array) || true) { //TODO: remove true - var fields_values = this.field_manager.build_eval_context(); - final_domain = new data.CompoundDomain(final_domain).set_eval_context(fields_values); - } - return final_domain; - } -}); - -/* -# Values: (0, 0, { fields }) create -# (1, ID, { fields }) update -# (2, ID) remove (delete) -# (3, ID) unlink one (target id or target of relation) -# (4, ID) link -# (5) unlink all (only valid for one2many) -*/ -var commands = { - // (0, _, {values}) - CREATE: 0, - 'create': function (values) { - return [commands.CREATE, false, values]; - }, - // (1, id, {values}) - UPDATE: 1, - 'update': function (id, values) { - return [commands.UPDATE, id, values]; - }, - // (2, id[, _]) - DELETE: 2, - 'delete': function (id) { - return [commands.DELETE, id, false]; - }, - // (3, id[, _]) removes relation, but not linked record itself - FORGET: 3, - 'forget': function (id) { - return [commands.FORGET, id, false]; - }, - // (4, id[, _]) - LINK_TO: 4, - 'link_to': function (id) { - return [commands.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]; - } -}; - -/** - * Interface to be implemented by fields. - * - * Events: - * - changed_value: triggered when the value of the field has changed. This can be due - * to a user interaction or a call to set_value(). - * - */ -var FieldInterface = { - /** - * Constructor takes 2 arguments: - * - field_manager: Implements FieldManagerMixin - * - node: the "<field>" node in json form - */ - init: function(field_manager, node) {}, - /** - * Called by the form view to indicate the value of the field. - * - * Multiple calls to set_value() can occur at any time and must be handled correctly by the implementation, - * regardless of any asynchronous operation currently running. Calls to set_value() can and will also occur - * before the widget is inserted into the DOM. - * - * set_value() must be able, at any moment, to handle the syntax returned by the "read" method of the - * osv class in the OpenERP server as well as the syntax used by the set_value() (see below). It must - * also be able to handle any other format commonly used in the _defaults key on the models in the addons - * as well as any format commonly returned in a on_change. It must be able to autodetect those formats as - * no information is ever given to know which format is used. - */ - set_value: function(value_) {}, - /** - * Get the current value of the widget. - * - * Must always return a syntactically correct value to be passed to the "write" method of the osv class in - * the OpenERP server, although it is not assumed to respect the constraints applied to the field. - * For example if the field is marked as "required", a call to get_value() can return false. - * - * get_value() can also be called *before* a call to set_value() and, in that case, is supposed to - * return a default value according to the type of field. - * - * This method is always assumed to perform synchronously, it can not return a promise. - * - * If there was no user interaction to modify the value of the field, it is always assumed that - * get_value() return the same semantic value than the one passed in the last call to set_value(), - * although the syntax can be different. This can be the case for type of fields that have a different - * syntax for "read" and "write" (example: m2o: set_value([0, "Administrator"]), get_value() => 0). - */ - get_value: function() {}, - /** - * Inform the current object of the id it should use to match a html <label> that exists somewhere in the - * view. - */ - set_input_id: function(id) {}, - /** - * Returns true if is_syntax_valid() returns true and the value is semantically - * valid too according to the semantic restrictions applied to the field. - */ - is_valid: function() {}, - /** - * Returns true if the field holds a value which is syntactically correct, ignoring - * the potential semantic restrictions applied to the field. - */ - is_syntax_valid: function() {}, - /** - * Must set the focus on the field. Return false if field is not focusable. - */ - focus: function() {}, - /** - * Called when the translate button is clicked. - */ - on_translate: function() {}, - /** - This method is called by the form view before reading on_change values and before saving. It tells - the field to save its value before reading it using get_value(). Must return a promise. - */ - commit_value: function() {}, -}; - -/** - * Abstract class for classes implementing FieldInterface. - * - * Properties: - * - value: useful property to hold the value of the field. By default, set_value() and get_value() - * set and retrieve the value property. Changing the value property also triggers automatically - * a 'changed_value' event that inform the view to trigger on_changes. - * - */ -var AbstractField = FormWidget.extend(FieldInterface, { - /** - * @constructs instance.web.form.AbstractField - * @extends instance.web.form.FormWidget - * - * @param field_manager - * @param node - */ - init: function(field_manager, node) { - this._super(field_manager, node); - this.name = this.node.attrs.name; - this.field = this.field_manager.get_field_desc(this.name); - this.widget = this.node.attrs.widget; - this.string = this.node.attrs.string || this.field.string || this.name; - this.options = pyeval.py_eval(this.node.attrs.options || '{}'); - this.set({'value': false}); - - this.on("change:value", this, function() { - this.trigger('changed_value'); - this._check_css_flags(); - }); - - this.$translate = (_t.database.multi_lang && this.field.translate) ? $('<button/>', { - type: 'button', - }).addClass('o_field_translate fa fa-globe btn btn-link') : $(); - }, - - renderElement: function() { - var self = this; - this._super(); - this.$el.addClass('o_form_field'); - this.$label = this.view ? this.view.$('label[for=' + this.id_for_label + ']') : $(); - this.do_attach_tooltip(this, this.$label[0] || this.$el); - if (session.debug) { - this.$label.off('dblclick').on('dblclick', function() { - console.log("Field '%s' of type '%s' in View: %o", self.name, (self.node.attrs.widget || self.field.type), self.view); - window.w = self; - console.log("window.w =", window.w); - }); - } - if (!this.disable_utility_classes) { - this.off("change:required", this, this._set_required); - this.on("change:required", this, this._set_required); - this._set_required(); - } - this._check_visibility(); - this.field_manager.off("change:display_invalid_fields", this, this._check_css_flags); - this.field_manager.on("change:display_invalid_fields", this, this._check_css_flags); - this._check_css_flags(); - }, - start: function() { - this._super(); - this.on("change:value", this, function() { - if (!this.no_rerender) { - this.render_value(); - } - this._toggle_label(); - }); - this.on("change:effective_readonly", this, function() { - this._toggle_label(); - }); - this.render_value(); - this._toggle_label(); - - if (this.view) { - this.$translate - .insertAfter(this.$el) - .on('click', _.bind(this.on_translate, this)); - } - }, - _toggle_label: function() { - var empty = this.get('effective_readonly') && this.is_false(); - this.$label.toggleClass('o_form_label_empty', empty).toggleClass('o_form_label_false', this.get('effective_readonly') && this.get('value') === false); - this.$el.toggleClass('o_form_field_empty', empty); - }, - /** - * Private. Do not use. - */ - _set_required: function() { - this.$el.toggleClass('o_form_required', this.get("required")); - }, - set_value: function(value_) { - this.set({'value': value_}); - }, - get_value: function() { - return this.get('value'); - }, - /** - Utility method that all implementations should use to change the - value without triggering a re-rendering. - */ - internal_set_value: function(value_) { - var tmp = this.no_rerender; - this.no_rerender = true; - this.set({'value': value_}); - this.no_rerender = tmp; - }, - /** - This method is called each time the value is modified. - */ - render_value: function() {}, - is_valid: function() { - return this.is_syntax_valid() && !(this.get('required') && this.is_false()); - }, - is_syntax_valid: function() { - return true; - }, - /** - * Method useful to implement to ease validity testing. Must return true if the current - * value is similar to false in OpenERP. - */ - is_false: function() { - return this.get('value') === false; - }, - _check_css_flags: function() { - var show_translate = (!this.get('effective_readonly') && this.field_manager.get('actual_mode') !== "create"); - this.$translate.toggleClass('o_translate_active', !!show_translate); - - this.$el.add(this.$label) - .toggleClass('o_form_invalid', !this.disable_utility_classes && !!this.field_manager.get('display_invalid_fields') && !this.is_valid()); - }, - focus: function() { - return false; - }, - set_input_id: function(id) { - this.id_for_label = id; - }, - on_translate: function() { - var self = this; - var trans = new data.DataSet(this, 'ir.translation'); - return trans.call_button('translate_fields', [this.view.model, this.view.datarecord.id, this.name, this.view.dataset.get_context()]).done(function(r) { - self.do_action(r); - }); - }, - set_dimensions: function(height, width) { - this.$el.css({ - width: width, - height: height, - }); - }, - commit_value: function() { - return $.when(); - }, -}); - -/** - * Class with everything which is common between FormViewDialog and SelectCreateDialog. - */ -var ViewDialog = Dialog.extend({ // FIXME should use ViewManager - /** - * options: - * -readonly: only applicable when not in creation mode, default to false - * - alternative_form_view - * - write_function - * - read_function - * - create_function - * - parent_view - * - child_name - * - form_view_options - */ - init: function(parent, options) { - options = options || {}; - options.dialogClass = options.dialogClass || ''; - options.dialogClass += ' o_act_window'; - - this._super(parent, $.extend(true, {}, options)); - - this.res_model = options.res_model || null; - this.res_id = options.res_id || null; - this.domain = options.domain || []; - this.context = options.context || {}; - this.options = _.extend(this.options || {}, options || {}); - - this.on_selected = options.on_selected || (function() {}); - }, - - init_dataset: function() { - var self = this; - this.created_elements = []; - this.dataset = new data.ProxyDataSet(this, this.res_model, this.context); - this.dataset.read_function = this.options.read_function; - this.dataset.create_function = function(data, options, sup) { - var fct = self.options.create_function || sup; - return fct.call(this, data, options).done(function(r) { - self.trigger('create_completed saved', r); - self.created_elements.push(r); - }); - }; - this.dataset.write_function = function(id, data, options, sup) { - var fct = self.options.write_function || sup; - return fct.call(this, id, data, options).done(function(r) { - self.trigger('write_completed saved', r); - }); - }; - this.dataset.parent_view = this.options.parent_view; - this.dataset.child_name = this.options.child_name; - - this.on('closed', this, this.select); - }, - - select: function() { - if (this.created_elements.length > 0) { - this.on_selected(this.created_elements); - this.created_elements = []; - } - } -}); - -/** - * Create and edit dialog (displays a form view record and leave once saved) - */ -var FormViewDialog = ViewDialog.extend({ - init: function(parent, options) { - var self = this; - - var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection; - var readonly = _.isNumber(options.res_id) && options.readonly; - - if(!options || !options.buttons) { - options = options || {}; - options.buttons = [ - {text: (readonly ? _t("Close") : _t("Discard")), classes: "btn-default o_form_button_cancel", close: true, click: function() { - self.view_form.trigger('on_button_cancel'); - }} - ]; - - if(!readonly) { - options.buttons.splice(0, 0, {text: _t("Save") + ((multi_select)? " " + _t(" & Close") : ""), classes: "btn-primary", click: function() { - self.view_form.onchanges_mutex.def.then(function() { - if (!self.view_form.warning_displayed) { - $.when(self.view_form.save()).done(function() { - self.view_form.reload_mutex.exec(function() { - self.trigger('record_saved'); - self.close(); - }); - }); - } - }); - } - }); - - if(multi_select) { - options.buttons.splice(1, 0, {text: _t("Save & New"), classes: "btn-primary", click: function() { - $.when(self.view_form.save()).done(function() { - self.view_form.reload_mutex.exec(function() { - self.view_form.on_button_new(); - }); - }); - }}); - } - } - } - - this._super(parent, options); - }, - - open: function() { - var self = this; - var _super = this._super.bind(this); - this.init_dataset(); - - if (this.res_id) { - this.dataset.ids = [this.res_id]; - this.dataset.index = 0; - } else { - this.dataset.index = null; - } - var options = _.clone(this.options.form_view_options) || {}; - if (this.res_id !== null) { - options.initial_mode = this.options.readonly ? "view" : "edit"; - } - _.extend(options, { - $buttons: this.$buttons, - }); - var FormView = core.view_registry.get('form'); - var fields_view_def; - if (this.options.alternative_form_view) { - fields_view_def = $.when(this.options.alternative_form_view); - } else { - fields_view_def = data_manager.load_fields_view(this.dataset, this.options.view_id, 'form', false); - } - fields_view_def.then(function (fields_view) { - self.view_form = new FormView(self, self.dataset, fields_view, options); - var fragment = document.createDocumentFragment(); - self.view_form.appendTo(fragment).then(function () { - self.view_form.do_show().then(function() { - _super().$el.append(fragment); - self.view_form.autofocus(); - }); - }); - }); - - return this; - }, -}); - -var SelectCreateListView = ListView.extend({ - do_add_record: function () { - this.popup.create_edit_record(); - }, - select_record: function(index) { - if (this.popup.options.readonly) return; - this.popup.on_selected([this.dataset.ids[index]]); - this.popup.close(); - }, - do_select: function(ids, records) { - this._super.apply(this, arguments); - this.popup.on_click_element(ids); - } -}); - -/** - * Search dialog (displays a list of records and permits to create a new one by switching to a form view) - */ -var SelectCreateDialog = ViewDialog.extend({ - /** - * options: - * - initial_ids - * - initial_view: form or search (default search) - * - disable_multiple_selection - * - list_view_options - */ - init: function(parent, options) { - this._super(parent, options); - - _.defaults(this.options, { initial_view: "search" }); - this.initial_ids = this.options.initial_ids; - }, - - open: function() { - if(this.options.initial_view !== "search") { - return this.create_edit_record(); - } - - var _super = this._super.bind(this); - this.init_dataset(); - var context = pyeval.sync_eval_domains_and_contexts({ - domains: [], - contexts: [this.context] - }).context; - var search_defaults = {}; - _.each(context, function (value_, key) { - var match = /^search_default_(.*)$/.exec(key); - if (match) { - search_defaults[match[1]] = value_; - } - }); - data_manager - .load_views(this.dataset, [[false, 'list'], [false, 'search']], {}) - .then(this.setup.bind(this, search_defaults)) - .then(function (fragment) { - _super().$el.append(fragment); - }); - return this; - }, - - setup: function(search_defaults, fields_views) { - var self = this; - if (this.searchview) { - this.searchview.destroy(); - } - var fragment = document.createDocumentFragment(); - var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment); - var $pager = $('<div/>').addClass('o_pager').appendTo($header); - var options = { - $buttons: $('<div/>').addClass('o_search_options').appendTo($header), - search_defaults: search_defaults, - }; - - this.searchview = new SearchView(this, this.dataset, fields_views.search, options); - this.searchview.on('search_data', this, function(domains, contexts, groupbys) { - if (this.initial_ids) { - this.do_search(domains.concat([[["id", "in", this.initial_ids]], this.domain]), - contexts.concat(this.context), groupbys); - this.initial_ids = undefined; - } else { - this.do_search(domains.concat([this.domain]), contexts.concat(this.context), groupbys); - } - }); - return this.searchview.prependTo($header).then(function() { - self.searchview.toggle_visibility(true); - - self.view_list = new SelectCreateListView(self, - self.dataset, fields_views.list, - _.extend({'deletable': false, - 'selectable': !self.options.disable_multiple_selection, - 'import_enabled': false, - '$buttons': self.$buttons, - 'disable_editable_mode': true, - 'pager': true, - }, self.options.list_view_options || {})); - self.view_list.on('edit:before', self, function (e) { - e.cancel = true; - }); - self.view_list.popup = self; - self.view_list.on('list_view_loaded', self, function() { - this.on_view_list_loaded(); - }); - - var buttons = [ - {text: _t("Cancel"), classes: "btn-default o_form_button_cancel", close: true} - ]; - if(!self.options.no_create) { - buttons.splice(0, 0, {text: _t("Create"), classes: "btn-primary", click: function() { - self.create_edit_record(); - }}); - } - if(!self.options.disable_multiple_selection) { - buttons.splice(0, 0, {text: _t("Select"), classes: "btn-primary o_selectcreatepopup_search_select", disabled: true, close: true, click: function() { - self.on_selected(self.selected_ids); - }}); - } - self.set_buttons(buttons); - - return self.view_list.appendTo(fragment).then(function() { - self.view_list.do_show(); - self.view_list.render_pager($pager); - if (self.options.initial_facet) { - self.searchview.query.reset([self.options.initial_facet], { - preventSearch: true, - }); - } - self.searchview.do_search(); - - return fragment; - }); - }); - }, - do_search: function(domains, contexts, groupbys) { - var results = pyeval.sync_eval_domains_and_contexts({ - domains: domains || [], - contexts: contexts || [], - group_by_seq: groupbys || [] - }); - this.view_list.do_search(results.domain, results.context, results.group_by); - }, - on_click_element: function(ids) { - this.selected_ids = ids || []; - this.$footer.find(".o_selectcreatepopup_search_select").prop('disabled', this.selected_ids.length <= 0); - }, - create_edit_record: function() { - this.close(); - return new FormViewDialog(this.__parentedParent, this.options).open(); - }, - on_view_list_loaded: function() {}, -}); - -return { - // mixins - FieldManagerMixin: FieldManagerMixin, - ReinitializeWidgetMixin: ReinitializeWidgetMixin, - ReinitializeFieldMixin: ReinitializeFieldMixin, - CompletionFieldMixin: CompletionFieldMixin, - - // misc - FormWidget: FormWidget, - DefaultFieldManager: DefaultFieldManager, - InvisibilityChanger: InvisibilityChanger, - NotebookInvisibilityChanger: NotebookInvisibilityChanger, - commands: commands, - AbstractField: AbstractField, - - // Dialogs - ViewDialog: ViewDialog, - FormViewDialog: FormViewDialog, - SelectCreateDialog: SelectCreateDialog, -}; - -}); diff --git a/addons/web/static/src/js/views/form_relational_widgets.js b/addons/web/static/src/js/views/form_relational_widgets.js deleted file mode 100644 index 63d04aaeef3..00000000000 --- a/addons/web/static/src/js/views/form_relational_widgets.js +++ /dev/null @@ -1,1726 +0,0 @@ -odoo.define('web.form_relational', function (require) { -"use strict"; - -var ControlPanel = require('web.ControlPanel'); -var core = require('web.core'); -var data = require('web.data'); -var Dialog = require('web.Dialog'); -var common = require('web.form_common'); -var ListView = require('web.ListView'); -require('web.ListEditor'); // one must be sure that the include of ListView are done (for eg: add start_edition methods) -var Model = require('web.DataModel'); -var session = require('web.session'); -var utils = require('web.utils'); -var ViewManager = require('web.ViewManager'); - -var _t = core._t; -var QWeb = core.qweb; -var COMMANDS = common.commands; -var list_widget_registry = core.list_widget_registry; - -var M2ODialog = Dialog.extend({ - template: "M2ODialog", - init: function(parent) { - this.name = parent.string; - this._super(parent, { - title: _.str.sprintf(_t("Create a %s"), parent.string), - size: 'medium', - buttons: [ - {text: _t('Create'), classes: 'btn-primary', click: function(e) { - if (this.$("input").val() !== ''){ - this.getParent()._quick_create(this.$("input").val()); - this.close(); - } else { - e.preventDefault(); - this.$("input").focus(); - } - }}, - - {text: _t('Create and edit'), classes: 'btn-primary', close: true, click: function() { - this.getParent()._search_create_popup("form", undefined, this.getParent()._create_context(this.$("input").val())); - }}, - - {text: _t('Cancel'), close: true} - ] - }); - }, - start: function() { - var text = _.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), this.name); - this.$("p").text(text); - this.$("input").val(this.getParent().$input.val()); - }, -}); - -var FieldMany2One = common.AbstractField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, { - template: "FieldMany2One", - events: { - 'keydown input': function (e) { - switch (e.which) { - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - e.stopPropagation(); - } - }, - }, - init: function(field_manager, node) { - this._super(field_manager, node); - common.CompletionFieldMixin.init.call(this); - this.display_value = {}; - this.display_value_backup = {}; - this.last_search = []; - this.floating = false; - this.current_display = null; - this.is_started = false; - this.ignore_focusout = false; - }, - reinit_value: function(val) { - this.internal_set_value(val); - this.floating = false; - if (this.is_started && !this.no_rerender) { - this.render_value(); - } - }, - initialize_field: function() { - this.is_started = true; - core.bus.on('click', this, function() { - if (!this.get("effective_readonly") && this.$input && this.$input.autocomplete('widget').is(':visible')) { - this.$input.autocomplete("close"); - } - }); - common.ReinitializeFieldMixin.initialize_field.call(this); - }, - initialize_content: function() { - if (!this.get("effective_readonly")) { - this.render_editable(); - } - }, - destroy_content: function () { - if (this.$dropdown) { - this.$dropdown.off('click'); - delete this.$dropdown; - } - if (this.$input) { - if (this.$input.data('ui-autocomplete')) { - this.$input.autocomplete("destroy"); - } - this.$input.closest(".modal .modal-content").off('scroll'); - this.$input.off('keyup blur autocompleteclose autocompleteopen ' + - 'focus focusout change keydown'); - delete this.$input; - } - if (this.$follow_button) { - this.$follow_button.off('blur focus click'); - delete this.$follow_button; - } - }, - destroy: function () { - this.destroy_content(); - return this._super(); - }, - init_error_displayer: function() { - // nothing - }, - hide_error_displayer: function() { - // doesn't work - }, - show_error_displayer: function() { - new M2ODialog(this).open(); - }, - render_editable: function() { - var self = this; - this.$input = this.$("input"); - - this.init_error_displayer(); - self.$input.on('focus', function() { - self.hide_error_displayer(); - }); - - this.$dropdown = this.$(".o_dropdown_button"); - this.$follow_button = this.$(".o_external_button"); - - this.$follow_button.click(function(ev) { - ev.preventDefault(); - if (!self.get('value')) { - self.focus(); - return; - } - var context = self.build_context().eval(); - var model_obj = new Model(self.field.relation); - model_obj.call('get_formview_id', [[self.get("value")], context]).then(function(view_id){ - var pop = new common.FormViewDialog(self, { - res_model: self.field.relation, - res_id: self.get("value"), - context: self.build_context(), - title: _t("Open: ") + self.string, - view_id: view_id, - readonly: !self.can_write - }).open(); - pop.on('write_completed', self, function(){ - self.display_value = {}; - self.display_value_backup = {}; - self.render_value(); - self.focus(); - self.trigger('changed_value'); - }); - }); - }); - - // some behavior for input - var input_changed = function() { - if (self.current_display !== self.$input.val()) { - self.current_display = self.$input.val(); - if (self.$input.val() === "") { - self.internal_set_value(false); - self.floating = false; - } else { - self.floating = true; - } - } - }; - this.$input.keydown(input_changed); - this.$input.change(input_changed); - this.$input.on('click', function() { - if (self.$input.autocomplete("widget").is(":visible")) { - self.$input.autocomplete("close"); - } else { - if (self.get("value") && ! self.floating) { - self.$input.autocomplete("search", ""); - } else { - self.$input.autocomplete("search"); - } - } - }); - - // Autocomplete close on dialog content scroll - var close_autocomplete = _.debounce(function() { - if (self.$input.autocomplete("widget").is(":visible")) { - self.$input.autocomplete("close"); - } - }, 50); - this.$input.closest(".modal .modal-content").on('scroll', this, close_autocomplete); - - self.ed_def = $.Deferred(); - self.uned_def = $.Deferred(); - var ed_delay = 200; - var ed_duration = 15000; - var anyoneLoosesFocus = function (e) { - if (self.ignore_focusout) { return; } - var used = false; - if (self.floating) { - if (self.last_search.length > 0) { - if (self.last_search[0][0] != self.get("value")) { - self.display_value = {}; - self.display_value_backup = {}; - self.display_value["" + self.last_search[0][0]] = self.last_search[0][1]; - self.reinit_value(self.last_search[0][0]); - self.last_search = []; - } else { - used = true; - self.render_value(); - } - } else { - used = true; - } - self.floating = false; - } - var has_changed = (self.get("value") === false || self.display_value["" + self.get("value")] !== self.$input.val()); - if (used && has_changed && ! self.no_ed && ! (self.options && (self.options.no_create || self.options.no_quick_create))) { - self.ed_def.reject(); - self.uned_def.reject(); - self.ed_def = $.Deferred(); - self.ed_def.done(function() { - self.can_create && self.$input && self.show_error_displayer(); - ignore_blur = false; - self.trigger('focused'); - }); - ignore_blur = true; - setTimeout(function() { - self.ed_def.resolve(); - self.uned_def.reject(); - self.uned_def = $.Deferred(); - self.uned_def.done(function() { - self.hide_error_displayer(); - }); - setTimeout(function() {self.uned_def.resolve();}, ed_duration); - }, ed_delay); - } else { - self.no_ed = false; - self.ed_def.reject(); - } - }; - var ignore_blur = false; - this.$input.on({ - focusout: anyoneLoosesFocus, - focus: function () { self.trigger('focused'); }, - autocompleteopen: function () { ignore_blur = true; }, - autocompleteclose: function () { setTimeout(function() {ignore_blur = false;},0); }, - blur: function () { - // autocomplete open - if (ignore_blur) { $(this).focus(); return; } - if (_(self.getChildren()).any(function (child) { - return child instanceof common.ViewDialog; - })) { return; } - self.trigger('blurred'); - } - }); - - var isSelecting = false; - // autocomplete - this.$input.autocomplete({ - source: function(req, resp) { - self.get_search_result(req.term).done(function(result) { - resp(result); - }); - }, - select: function(event, ui) { - isSelecting = true; - var item = ui.item; - if (item.id) { - self.display_value = {}; - self.display_value_backup = {}; - self.display_value["" + item.id] = item.name; - self.reinit_value(item.id); - } else if (item.action) { - item.action(); - // Cancel widget blurring, to avoid form blur event - self.trigger('focused'); - } - return false; - }, - focus: function(e, ui) { - e.preventDefault(); - }, - autoFocus: true, - html: true, - // disabled to solve a bug, but may cause others - //close: anyoneLoosesFocus, - minLength: 0, - delay: 200, - }); - // set position for list of suggestions box - this.$input.autocomplete( "option", "position", { my : "left top", at: "left bottom" } ); - // used to correct a bug when selecting an element by pushing 'enter' in an editable list - this.$input.keyup(function(e) { - if (e.which === 13) { // ENTER - if (isSelecting) - e.stopPropagation(); - } - isSelecting = false; - }); - this.setupFocus(this.$follow_button); - }, - render_value: function(no_recurse) { - var self = this; - if (!this.get("value")) { - this.display_string(null); - return; - } - var display = this.display_value["" + this.get("value")]; - if (display) { - this.display_string(display); - return; - } - if (!no_recurse) { - var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context()); - var def = this.alive(dataset.name_get([self.get("value")])).done(function(data) { - if (!data[0]) { - self.do_warn(_t("Render"), - _.str.sprintf(_t("No value found for the field %s for value %s"), self.field.string, self.get("value"))); - return; - } - self.display_value["" + self.get("value")] = data[0][1]; - self.render_value(true); - }).fail( function (data, event) { - // avoid displaying crash errors as many2One should be name_get compliant - event.preventDefault(); - self.display_value["" + self.get("value")] = self.display_value_backup["" + self.get("value")]; - self.render_value(true); - }); - if (this.view && this.view.render_value_defs){ - this.view.render_value_defs.push(def); - } - } - }, - display_string: function (str) { - var noValue = (str === null); - if (!this.get("effective_readonly")) { - this.$input.val(noValue ? "" : (str.split("\n")[0].trim() || $(data.noDisplayContent).text())); - this.current_display = this.$input.val(); - this.$follow_button.toggle(!this.is_false()); - this.$el.toggleClass('o_with_button', !!this.$follow_button && this.$follow_button.length > 0 && !this.is_false()); - } else { - this.$el.html(noValue ? "" : (_.escape(str.trim()).split("\n").join("<br/>") || data.noDisplayContent)); - // Define callback to perform when clicking on the field - if (!this.options.no_open) { - // Remove potential previously added event handler - this.$el.off('click'); - // Ensure that the callback is performed only once even with multiple clicks - var execute_formview_action_once = _.once(this.execute_formview_action.bind(this)); - this.$el.click(function (ev) { - ev.preventDefault(); - execute_formview_action_once(); - }); - } - } - }, - execute_formview_action: function() { - var self = this; - var context = self.build_context().eval(); - (new Model(self.field.relation)).call('get_formview_action', [[self.get("value")], context]).then(function(action) { - self.do_action(action); - }); - }, - set_value: function(value_) { - if (value_ instanceof Array) { - this.display_value = {}; - this.display_value_backup = {}; - if (! this.options.always_reload) { - this.display_value["" + value_[0]] = value_[1]; - } - else { - this.display_value_backup["" + value_[0]] = value_[1]; - } - value_ = value_[0]; - } - value_ = value_ || false; - this.reinit_value(value_); - }, - get_displayed: function() { - return this.display_value["" + this.get("value")]; - }, - add_id: function(id) { - this.display_value = {}; - this.display_value_backup = {}; - this.reinit_value(id); - }, - is_false: function() { - return ! this.get("value"); - }, - focus: function () { - var input = !this.get('effective_readonly') && this.$input && this.$input[0]; - return input ? input.focus() : false; - }, - _quick_create: function() { - this.no_ed = true; - this.ed_def.reject(); - return common.CompletionFieldMixin._quick_create.apply(this, arguments); - }, - _search_create_popup: function() { - this.no_ed = true; - this.ed_def.reject(); - this.ignore_focusout = true; - this.reinit_value(false); - var res = common.CompletionFieldMixin._search_create_popup.apply(this, arguments); - this.ignore_focusout = false; - this.no_ed = false; - return res; - }, -}); - -/** - * A Abstract field for one2many and many2many field - * For all fields on2many or many2many: - * - this.get('value') contains a list of ids and virtual ids - * - get_value() return an odoo write command list - */ -var AbstractManyField = common.AbstractField.extend({ - init: function(field_manager, node) { - var self = this; - this._super(field_manager, node); - this.dataset = new X2ManyDataSet(this, this.field.relation, this.build_context()); - this.dataset.x2m = this; - this.dataset.parent_view = this.view; - this.dataset.child_name = this.name; - this.set('value', []); - this.starting_ids = []; - this.mutex = new utils.Mutex(); - this.view.on("load_record", this, this._on_load_record); - this.dataset.on('dataset_changed', this, function() { - var options = _.clone(_.last(arguments)); - if (!_.isObject(options) || _.isArray(options)) { - options = {}; - } - // don't trigger changes if all commands are not resolved - // the editable lists change the dataset without call AbstractManyField methods - if (!self.internal_dataset_changed && !options.internal_dataset_changed) { - self.trigger("change:commands", options); - } - }); - this.on("change:commands", this, function (options) { - self._inhibit_on_change_flag = !!options._inhibit_on_change_flag; - self.set({'value': self.dataset.ids.slice()}); - self._inhibit_on_change_flag = false; - }); - }, - - _on_load_record: function (record) { - this.starting_ids = []; - // don't set starting_ids for the new record - if (record.id && record[this.name] && (!isNaN(record.id) || record.id.indexOf(this.dataset.virtual_id_prefix) === -1)) { - this.starting_ids = this.get('value').slice(); - } - this.trigger("load_record", record); - }, - - set_value: function(ids) { - ids = (ids || []).slice(); - if (_.find(ids, function(id) { return typeof(id) === "string"; } )) { - throw new Error("set_value of '"+this.name+"' must receive an list of ids without virtual ids.", ids); - } - if (_.find(ids, function(id) { return typeof(id) !== "number"; } )) { - return this.send_commands(ids, {'_inhibit_on_change_flag': this._inhibit_on_change_flag}); - } - this.dataset.reset_ids(ids); - return $.when(this._super(ids)); - }, - - internal_set_value: function(ids) { - if (_.isEqual(ids, this.get("value"))) { - return; - } - var tmp = this.no_rerender; - this.no_rerender = true; - var def = this.data_replace(ids.slice()); - this.no_rerender = tmp; - return def; - }, - - commit_value: function() { - return this.mutex.def; - }, - - /* - *@value: data {object} contains all value to send to the db - * options {object} options sent to the dataset (like the default values) - *@return deferred resolve with the created virtual id - */ - data_create: function (data, options) { - return this.send_commands([COMMANDS.create(data)], options); - }, - - /* - *@value: id {int or string} id or virtual id of the record to update - * data {object} contains all value to send to the db - * options {object} options sent to the dataset - *@return deferred - */ - data_update: function (id, data, options) { - return this.send_commands([COMMANDS.update(id, data)], options); - }, - - /* - *@value: id {int or string} id or virtual id of the record to add - * options {object} options sent to the dataset - *@return deferred - */ - data_link: function (id, options) { - return this.send_commands([COMMANDS.link_to(id)], options); - }, - - /* - *@value: ids {array} list of ids or virtual ids of the record to add - * options {object} options sent to the dataset - *@return deferred - */ - data_link_multi: function (ids, options) { - return this.send_commands(_.map(ids, function (id) { return COMMANDS.link_to(id); }), options); - }, - - /* - *@value: id {int or string} id or virtual id of the record to unlink or delete (function of field type) - *@return deferred - */ - data_delete: function (id) { - return this.send_commands([COMMANDS.delete(id)]); - }, - - /* - *@value: id {int or string} id or virtual id of the record to removes relation (unlink or delete function of field type) - *@return deferred - */ - data_forget: function (id) { - return this.send_commands([COMMANDS.forget(id)]); - }, - - /* - *@value: ids {array} list of ids or virtual ids of the record who replace the previous list - * options {object} options sent to the dataset - *@return deferred - */ - data_replace: function (ids, options) { - return this.send_commands([COMMANDS.replace_with(ids)], options); - }, - - /* - *@value: ids {array} list of ids or virtual ids of the record to read - * fields {array} list of the field to read - * options {object} options sent to the dataset - *@return deferred resolve with the records - */ - data_read: function (ids, fields, options) { - return this.dataset.read_ids(ids, fields, options); - }, - - /** - *Compute the write command list into the dataset - *@value: command_list {array} command list - * options {object} options for the datasets (eg: the default values) - *@return : deferred - */ - send_commands: function (command_list, options) { - var self = this; - var def = $.Deferred(); - var dataset = this.dataset; - var res = true; - options = options || {}; - var internal_options = _.extend({}, options, {'internal_dataset_changed': true}); - - _.each(command_list, function(command) { - self.mutex.exec(function() { - var id = command[1]; - switch (command[0]) { - case COMMANDS.CREATE: - var data = _.clone(command[2]); - delete data.id; - return dataset.create(data, internal_options).then(function (id) { - dataset.ids.push(id); - res = id; - }); - case COMMANDS.UPDATE: - return dataset.write(id, command[2], internal_options).then(function () { - if (dataset.ids.indexOf(id) === -1) { - dataset.ids.push(id); - res = id; - } - }); - case COMMANDS.FORGET: - return dataset.unlink([id]); - case COMMANDS.DELETE: - return dataset.unlink([id]); - case COMMANDS.LINK_TO: - if (dataset.ids.indexOf(id) === -1) { - return dataset.add_ids([id], internal_options); - } - return; - case COMMANDS.DELETE_ALL: - return dataset.reset_ids([], {keep_read_data: true}); - case COMMANDS.REPLACE_WITH: - dataset.ids = []; - return dataset.alter_ids(command[2], internal_options); - default: - throw new Error("send_commands to '"+self.name+"' receive a non command value." + - "\n" + JSON.stringify(command_list)); - } - }); - }); - - this.mutex.def.then(function () { - self.trigger("change:commands", options); - def.resolve(res); - }); - return def; - }, - - /** - *return list of commands: create and update (and delete_all if need) (function of the field type) - */ - get_value: function() { - var self = this, - is_one2many = this.field.type === "one2many", - not_delete = this.options.not_delete, - starting_ids = this.starting_ids.slice(), - replace_with_ids = [], - add_ids = [], - command_list = [], - id, index, record; - - _.each(this.get('value'), function (id) { - index = starting_ids.indexOf(id); - if (index !== -1) { - starting_ids.splice(index, 1); - } - var record = self.dataset.get_cache(id); - if (!_.isEmpty(record.changes)) { - var values = _.clone(record.changes); - // format many2one values - for (var k in values) { - if ((values[k] instanceof Array) && values[k].length === 2 && typeof values[k][0] === "number" && typeof values[k][1] === "string") { - values[k] = values[k][0]; - } - } - if (record.to_create) { - command_list.push(COMMANDS.create(values)); - } else { - command_list.push(COMMANDS.update(record.id, values)); - } - return; - } - if (!is_one2many || not_delete || self.dataset.delete_all) { - replace_with_ids.push(id); - } else { - command_list.push(COMMANDS.link_to(id)); - } - }); - if ((!is_one2many || not_delete || self.dataset.delete_all) && (replace_with_ids.length || starting_ids.length)) { - _.each(command_list, function (command) { - if (command[0] === COMMANDS.UPDATE) { - replace_with_ids.push(command[1]); - } - }); - command_list.unshift(COMMANDS.replace_with(replace_with_ids)); - } - - _.each(starting_ids, function(id) { - if (is_one2many && !not_delete) { - command_list.push(COMMANDS.delete(id)); - } else if (is_one2many && !self.dataset.delete_all) { - command_list.push(COMMANDS.forget(id)); - } - }); - - return command_list; - }, - - is_valid: function () { - return this.mutex.def.state() === "resolved" && this._super(); - }, - - is_false: function() { - return _(this.get('value')).isEmpty(); - }, - - destroy: function () { - this.view.off("load_record", this, this._on_load_record); - this._super(); - } -}); - -var FieldX2Many = AbstractManyField.extend({ - multi_selection: false, - disable_utility_classes: true, - x2many_views: {}, - view_options: {}, - default_view: 'tree', - init: function(field_manager, node) { - this._super(field_manager, node); - - this.is_loaded = $.Deferred(); - this.initial_is_loaded = this.is_loaded; - this.is_started = false; - this.set_value([]); - }, - start: function() { - this._super.apply(this, arguments); - var self = this; - - this.load_views(); - var destroy = function() { - self.is_loaded = self.is_loaded.then(function() { - self.renderElement(); - self.viewmanager.destroy(); - return $.when(self.load_views()).done(function() { - self.reload_current_view(); - }); - }); - }; - this.is_loaded.done(function() { - self.on("change:effective_readonly", self, destroy); - }); - this.view.on("on_button_cancel", this, destroy); - this.is_started = true; - this.reload_current_view(); - }, - load_views: function() { - var self = this; - - var view_types = this.node.attrs.mode; - view_types = !!view_types ? view_types.split(",") : [this.default_view]; - var views = []; - _.each(view_types, function(view_type) { - if (! _.include(["list", "tree", "graph", "kanban"], view_type)) { - throw new Error(_.str.sprintf(_t("View type '%s' is not supported in X2Many."), view_type)); - } - var view = { - view_id: false, - view_type: view_type === "tree" ? "list" : view_type, - fields_view: self.field.views && self.field.views[view_type], - options: {}, - }; - if(view.view_type === "list") { - _.extend(view.options, { - action_buttons: false, // to avoid 'Save' and 'Discard' buttons to appear in X2M fields - addable: null, - selectable: self.multi_selection, - sortable: true, - import_enabled: false, - deletable: true - }); - if (self.get("effective_readonly")) { - _.extend(view.options, { - deletable: null, - reorderable: false, - }); - } - } else if (view.view_type === "kanban") { - _.extend(view.options, { - action_buttons: true, - confirm_on_delete: false, - }); - if (self.get("effective_readonly")) { - _.extend(view.options, { - action_buttons: false, - quick_creatable: false, - creatable: false, - read_only_mode: true, - }); - } - } - views.push(view); - }); - this.views = views; - - this.viewmanager = new X2ManyViewManager(this, this.dataset, views, this.view_options, this.x2many_views); - this.viewmanager.x2m = self; - var def = $.Deferred().done(function() { - self.initial_is_loaded.resolve(); - }); - this.viewmanager.on("controller_inited", self, function(view_type, controller) { - controller.x2m = self; - if (view_type == "list") { - if (self.get("effective_readonly")) { - controller.on('edit:before', self, function (e) { - e.cancel = true; - }); - _(controller.columns).find(function (column) { - if (!(column instanceof list_widget_registry.get('field.handle'))) { - return false; - } - column.modifiers.invisible = true; - return true; - }); - } - } else if (view_type == "graph") { - self.reload_current_view(); - } - def.resolve(); - }); - this.viewmanager.on("switch_mode", self, function(n_mode) { - $.when(self.commit_value()).done(function() { - if (n_mode === "list") { - utils.async_when().done(function() { - self.reload_current_view(); - }); - } - }); - }); - utils.async_when().done(function () { - self.$el.addClass('o_view_manager_content'); - self.alive(self.viewmanager.attachTo(self.$el)); - }); - return def; - }, - reload_current_view: function() { - var self = this; - self.is_loaded = self.is_loaded.then(function() { - var view = self.get_active_view(); - if (view.type === "list") { - view.controller.current_min = 1; - return view.controller.reload_content(); - } else if (view.controller.do_search) { - return view.controller.do_search(self.build_domain(), self.dataset.get_context(), []); - } - }, undefined); - return self.is_loaded; - }, - get_active_view: function () { - /** - * Returns the current active view if any. - */ - return (this.viewmanager && this.viewmanager.active_view); - }, - set_value: function(value_) { - var self = this; - this._super(value_).then(function () { - if (self.is_started && !self.no_rerender) { - return self.reload_current_view(); - } - }); - }, - commit_value: function() { - var view = this.get_active_view(); - if (view && view.type === "list" && view.controller.__focus) { - return $.when(this.mutex.def, view.controller._on_blur_one2many()); - } - return this.mutex.def; - }, - is_syntax_valid: function() { - var view = this.get_active_view(); - if (!view){ - return true; - } - switch (this.viewmanager.active_view.type) { - case 'form': - return _(view.controller.fields).chain() - .invoke('is_valid') - .all(_.identity) - .value(); - case 'list': - return view.controller.is_valid(); - } - return true; - }, - is_false: function() { - return _(this.dataset.ids).isEmpty(); - }, -}); - -var X2ManyDataSet = data.BufferedDataSet.extend({ - get_context: function() { - this.context = this.x2m.build_context(); - var self = this; - _.each(arguments, function(context) { - self.context.add(context); - }); - return this.context; - }, -}); - -var X2ManyViewManager = ViewManager.extend({ - custom_events: { - // Catch event scrollTo to prevent scrolling to the top when using the - // pager of List and Kanban views in One2Many fields - 'scrollTo': function() {}, - }, - init: function(parent, dataset, views, flags, x2many_views) { - // By default, render buttons and pager in X2M fields, but no sidebar - flags = _.extend({}, flags, { - headless: false, - search_view: false, - action_buttons: true, - pager: true, - sidebar: false, - }); - this.control_panel = new ControlPanel(parent, "X2ManyControlPanel"); - this.set_cp_bus(this.control_panel.get_bus()); - this._super(parent, dataset, views, flags); - this.registry = core.view_registry.extend(x2many_views); - }, - start: function() { - this.control_panel.prependTo(this.$el); - return this._super(); - }, - switch_mode: function(mode, unused) { - if (mode !== 'form') { - return this._super(mode, unused); - } - var self = this; - var id = self.x2m.dataset.index !== null ? self.x2m.dataset.ids[self.x2m.dataset.index] : null; - var pop = new common.FormViewDialog(this, { - res_model: self.x2m.field.relation, - res_id: id, - context: self.x2m.build_context(), - title: _t("Open: ") + self.x2m.string, - create_function: function(data, options) { - return self.x2m.data_create(data, options); - }, - write_function: function(id, data, options) { - return self.x2m.data_update(id, data, options).done(function() { - self.x2m.reload_current_view(); - }); - }, - alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined, - parent_view: self.x2m.view, - child_name: self.x2m.name, - read_function: function(ids, fields, options) { - return self.x2m.data_read(ids, fields, options); - }, - form_view_options: {'not_interactible_on_create':true}, - readonly: self.x2m.get("effective_readonly") - }).open(); - pop.on("elements_selected", self, function() { - self.x2m.reload_current_view(); - }); - }, -}); - -var X2ManyListView = ListView.extend({ - is_valid: function () { - if (!this.fields_view || !this.editable()){ - return true; - } - if (_.isEmpty(this.records.records)){ - return true; - } - var fields = this.editor.form.fields; - var current_values = {}; - _.each(fields, function(field){ - field._inhibit_on_change_flag = true; - field.__no_rerender = field.no_rerender; - field.no_rerender = true; - current_values[field.name] = field.get('value'); - }); - var ids = _.map(this.records.records, function (item) { return item.attributes.id; }); - var cached_records = _.filter(this.dataset.cache, function(item){return _.contains(ids, item.id) && !_.isEmpty(item.values) && !item.to_delete;}); - var valid = _.every(cached_records, function(record){ - _.each(fields, function(field){ - var value = record.values[field.name]; - field._inhibit_on_change_flag = true; - field.no_rerender = true; - field.set_value(_.isArray(value) && _.isArray(value[0]) ? [COMMANDS.delete_all()].concat(value) : value); - }); - return _.every(fields, function(field){ - field.process_modifiers(); - field._check_css_flags(); - return field.is_valid(); - }); - }); - _.each(fields, function(field){ - field.set('value', current_values[field.name], {silent: true}); - field._inhibit_on_change_flag = false; - field.no_rerender = field.__no_rerender; - }); - return valid; - }, - render_pager: function($node, options) { - options = _.extend(options || {}, { - single_page_hidden: true, - }); - this._super($node, options); - }, - display_nocontent_helper: function () { - return false; - }, -}); - -/** - * ListView.List subclass adding an "Add an item" row to replace the Create - * button in the ControlPanel. - */ -var X2ManyList = ListView.List.extend({ - pad_table_to: function (count) { - if (!this.view.is_action_enabled('create') || this.view.x2m.get('effective_readonly')) { - this._super(count); - return; - } - - this._super(count > 0 ? count - 1 : 0); - - var self = this; - var columns = _(this.columns).filter(function (column) { - return column.invisible !== '1'; - }).length; - if (this.options.selectable) { columns++; } - if (this.options.deletable) { columns++; } - - var $cell = $('<td>', { - colspan: columns, - 'class': 'o_form_field_x2many_list_row_add' - }).append( - $('<a>', {href: '#'}).text(_t("Add an item")) - .click(function (e) { - e.preventDefault(); - e.stopPropagation(); - var def; - if (self.view.editable()) { - // FIXME: there should also be an API for that one - if (self.view.editor.form.__blur_timeout) { - clearTimeout(self.view.editor.form.__blur_timeout); - self.view.editor.form.__blur_timeout = false; - } - def = self.view.save_edition(); - } - $.when(def).done(self.view.do_add_record.bind(self)); - })); - - var $padding = this.$current.find('tr:not([data-id]):first'); - var $newrow = $('<tr>').append($cell); - if ($padding.length) { - $padding.before($newrow); - } else { - this.$current.append($newrow); - } - }, -}); - -var One2ManyListView = X2ManyListView.extend({ - init: function () { - this._super.apply(this, arguments); - this.options = _.extend(this.options, { - GroupsType: One2ManyGroups, - ListType: X2ManyList - }); - this.on('edit:after', this, this.proxy('_after_edit')); - this.on('save:before cancel:before', this, this.proxy('_before_unedit')); - - /* detect if the user try to exit the one2many widget */ - core.bus.on('click', this, this._on_click_outside); - - this.dataset.on('dataset_changed', this, function () { - this._dataset_changed = true; - this.dataset.x2m._dirty_flag = true; - }); - this.dataset.x2m.on('load_record', this, function () { - this._dataset_changed = false; - }); - - this.on('warning', this, function(e) { // In case of editable list view, we do not want any warning which comes from the editor - if (this.editable()) { - e.stop_propagation(); - } - }); - }, - do_add_record: function () { - if (this.editable()) { - this._super.apply(this, arguments); - } else { - var self = this; - new common.SelectCreateDialog(this, { - res_model: self.x2m.field.relation, - domain: self.x2m.build_domain(), - context: self.x2m.build_context(), - title: _t("Create: ") + self.x2m.string, - initial_view: "form", - alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined, - create_function: function(data, options) { - return self.x2m.data_create(data, options); - }, - read_function: function(ids, fields, options) { - return self.x2m.data_read(ids, fields, options); - }, - parent_view: self.x2m.view, - child_name: self.x2m.name, - form_view_options: {'not_interactible_on_create':true}, - on_selected: function() { - self.x2m.reload_current_view(); - } - }).open(); - } - }, - do_activate_record: function(index, id) { - var self = this; - new common.FormViewDialog(self, { - res_model: self.x2m.field.relation, - res_id: id, - context: self.x2m.build_context(), - title: _t("Open: ") + self.x2m.string, - write_function: function(id, data, options) { - return self.x2m.data_update(id, data, options).done(function() { - self.x2m.reload_current_view(); - }); - }, - alternative_form_view: self.x2m.field.views ? self.x2m.field.views.form : undefined, - parent_view: self.x2m.view, - child_name: self.x2m.name, - read_function: function(ids, fields, options) { - return self.x2m.data_read(ids, fields, options); - }, - form_view_options: {'not_interactible_on_create':true}, - readonly: !this.is_action_enabled('edit') || self.x2m.get("effective_readonly") - }).open(); - }, - do_button_action: function (name, id, callback) { - if (!_.isNumber(id)) { - this.do_warn(_t("Action Button"), - _t("The o2m record must be saved before an action can be used")); - return; - } - var parent_form = this.x2m.view; - var self = this; - this.save_edition().then(function () { - if (parent_form) { - return parent_form.save(); - } else { - return $.when(); - } - }).done(function () { - var ds = self.x2m.dataset; - var changed_records = _.find(ds.cache, function(record) { - return record.to_create || record.to_delete || !_.isEmpty(record.changes); - }); - if (!self.x2m.options.reload_on_button && !changed_records) { - self.handle_button(name, id, callback); - } else { - self.handle_button(name, id, function(){ - self.x2m.view.reload(); - }); - } - }); - }, - start_edition: function (record, options) { - if (!this.__focus) { - this._on_focus_one2many(); - } - return this._super(record, options); - }, - reload_content: function () { - var self = this; - if (self.__focus) { - self._on_blur_one2many(); - return this._super().then(function () { - var record_being_edited = self.records.get(self.editor.form.datarecord.id); - if (record_being_edited) { - self.start_edition(record_being_edited); - } - }); - } - return this._super(); - }, - _on_focus_one2many: function () { - if(!this.editor.is_editing()) { - return; - } - this.dataset.x2m.internal_dataset_changed = true; - this._dataset_changed = false; - this.__focus = true; - }, - _on_click_outside: function(e) { - if(this.__ignore_blur || !this.editor.is_editing()) { - return; - } - - var $target = $(e.target); - - // If click on a button, a ui-autocomplete dropdown or modal-backdrop, it is not considered as a click outside - var click_outside = ($target.closest('.ui-autocomplete,.btn,.modal-backdrop').length === 0); - - // Check if click inside the current list editable - var $o2m = $target.closest(".o_list_editable"); - if($o2m.length && $o2m[0] === this.el) { - click_outside = false; - } - - // Check if click inside a modal which is on top of the current list editable - var $modal = $target.closest(".modal"); - if($modal.length) { - var $currentModal = this.$el.closest(".modal"); - if($currentModal.length === 0 || $currentModal[0] !== $modal[0]) { - click_outside = false; - } - } - - if (click_outside) { - this._on_blur_one2many(); - } - }, - _on_blur_one2many: function() { - if(this.__ignore_blur) { - return $.when(); - } - - this.__ignore_blur = true; - this.__focus = false; - this.dataset.x2m.internal_dataset_changed = false; - - var self = this; - return this.save_edition(true).done(function () { - if (self._dataset_changed) { - self.dataset.trigger('dataset_changed'); - } - }).always(function() { - self.__ignore_blur = false; - }); - }, - _after_edit: function () { - this.editor.form.on('blurred', this, this._on_blur_one2many); - - // The form's blur thing may be jiggered during the edition setup, - // potentially leading to the x2m instasaving the row. Cancel any - // blurring triggered the edition startup here - this.editor.form.widgetFocused(); - }, - _before_unedit: function () { - this.editor.form.off('blurred', this, this._on_blur_one2many); - }, - do_delete: function (ids) { - var confirm = window.confirm; - window.confirm = function () { return true; }; - try { - return this._super(ids); - } finally { - window.confirm = confirm; - } - }, - reload_record: function (record, options) { - if (!options || !options.do_not_evict) { - // Evict record.id from cache to ensure it will be reloaded correctly - this.dataset.evict_record(record.get('id')); - } - - return this._super(record); - }, -}); - -var One2ManyGroups = ListView.Groups.extend({ - setup_resequence_rows: function () { - if (!this.view.x2m.get('effective_readonly')) { - this._super.apply(this, arguments); - } - } -}); - -var FieldOne2Many = FieldX2Many.extend({ - init: function() { - this._super.apply(this, arguments); - this.x2many_views = { - kanban: core.view_registry.get('one2many_kanban'), - list: One2ManyListView, - }; - }, - start: function() { - this.$el.addClass('o_form_field_one2many'); - return this._super.apply(this, arguments); - }, - commit_value: function() { - var self = this; - return this.is_loaded.then(function() { - var view = self.viewmanager.active_view; - if(view.type === "list" && view.controller.editable()) { - return self.mutex.def.then(function () { - return view.controller.save_edition(); - }); - } - return self.mutex.def; - }); - }, - is_false: function() { - return false; - }, -}); - -var Many2ManyListView = X2ManyListView.extend({ - init: function () { - this._super.apply(this, arguments); - this.options = _.extend(this.options, { - ListType: X2ManyList, - }); - this.on('edit:after', this, this.proxy('_after_edit')); - this.on('save:before cancel:before', this, this.proxy('_before_unedit')); - }, - do_add_record: function () { - var self = this; - - new common.SelectCreateDialog(this, { - res_model: this.model, - domain: new data.CompoundDomain(this.x2m.build_domain(), ["!", ["id", "in", this.x2m.dataset.ids]]), - context: this.x2m.build_context(), - title: _t("Add: ") + this.x2m.string, - alternative_form_view: this.x2m.field.views ? this.x2m.field.views.form : undefined, - no_create: this.x2m.options.no_create || !this.is_action_enabled('create'), - on_selected: function(element_ids) { - return self.x2m.data_link_multi(element_ids).then(function() { - self.x2m.reload_current_view(); - }); - } - }).open(); - }, - do_activate_record: function(index, id) { - var self = this; - var pop = new common.FormViewDialog(this, { - res_model: this.model, - res_id: id, - context: this.x2m.build_context(), - title: _t("Open: ") + this.x2m.string, - alternative_form_view: this.x2m.field.views ? this.x2m.field.views.form : undefined, - readonly: !this.is_action_enabled('edit') || self.x2m.get("effective_readonly"), - }).open(); - pop.on('write_completed', self, function () { - self.dataset.evict_record(id); - self.reload_content(); - }); - }, - do_button_action: function(name, id, callback) { - var self = this; - var _sup = _.bind(this._super, this); - if (! this.x2m.options.reload_on_button) { - return _sup(name, id, callback); - } else { - return this.x2m.view.save().then(function() { - return _sup(name, id, function() { - self.x2m.view.reload(); - }); - }); - } - }, - _after_edit: function () { - this.editor.form.on('blurred', this, this._on_blur_many2many); - }, - _before_unedit: function () { - this.editor.form.off('blurred', this, this._on_blur_many2many); - }, - _on_blur_many2many: function() { - return this.save_edition().done(function () { - if (self._dataset_changed) { - self.dataset.trigger('dataset_changed'); - } - }); - }, - -}); - -var FieldMany2Many = FieldX2Many.extend({ - init: function() { - this._super.apply(this, arguments); - this.x2many_views = { - list: Many2ManyListView, - kanban: core.view_registry.get('many2many_kanban'), - }; - }, - start: function() { - this.$el.addClass('o_form_field_many2many'); - return this._super.apply(this, arguments); - } -}); - -var FieldMany2ManyKanban = FieldMany2Many.extend({ - default_view: 'kanban', - init: function() { - this._super.apply(this, arguments); - this.view_options = _.extend({}, this.view_options, { - 'create_text': _t("Add"), - }); - } -}); - -var FieldMany2ManyTags = AbstractManyField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, { - className: "o_form_field_many2manytags", - tag_template: "FieldMany2ManyTag", - - events: { - 'click .o_delete': function(e) { - this.remove_id($(e.target).parent().data('id')); - }, - 'click .badge': 'open_color_picker', - 'mousedown .o_colorpicker span': 'update_color', - 'focusout .o_colorpicker': 'close_color_picker', - }, - - init: function(field_manager, node) { - this._super(field_manager, node); - common.CompletionFieldMixin.init.call(this); - this.set({"value": []}); - }, - willStart: function () { - var self = this; - return this.dataset.call('fields_get', []).then(function(fields) { - self.fields = fields; - }); - }, - commit_value: function() { - this.dataset.cancel_read(); - return this._super(); - }, - initialize_content: function() { - if(!this.get("effective_readonly")) { - this.many2one = new FieldMany2One(this.field_manager, this.node); - this.many2one.options.no_open = true; - this.many2one.on('changed_value', this, function() { - var newValue = this.many2one.get('value'); - if(newValue) { - this.add_id(newValue); - this.many2one.set({'value': false}); - } - }); - - this.many2one.prependTo(this.$el); - - var self = this; - this.many2one.$('input').on('keydown', function(e) { - if(!$(e.target).val() && e.which === 8) { - var $badges = self.$('.badge'); - if($badges.length) { - self.remove_id($badges.last().data('id')); - } - } - }); - this.many2one.get_search_blacklist = function () { - return self.get('value'); - }; - } - }, - destroy_content: function() { - if(this.many2one) { - this.many2one.destroy(); - this.many2one = undefined; - } - }, - get_render_data: function(ids){ - this.dataset.cancel_read(); - var fields = this.fields.color ? ['display_name', 'name', 'color'] : ['display_name', 'name']; // TODO master: remove useless 'name' - return this.dataset.read_ids(ids, fields); - }, - render_tag: function(data) { - this.$('.badge').remove(); - this.$el.prepend(QWeb.render(this.tag_template, {elements: data, readonly: this.get('effective_readonly')})); - }, - render_value: function() { - var self = this; - var values = this.get("value"); - var handle_names = function(_data) { - _.each(_data, function(el) { - el.display_name = el.display_name.trim() ? _.str.escapeHTML(el.display_name) : data.noDisplayContent; - }); - self.render_tag(_data); - }; - if (!values || values.length > 0) { - return this.alive(this.get_render_data(values)).done(handle_names); - } else { - handle_names([]); - } - }, - add_id: function(id) { - this.set({'value': _.uniq(this.get('value').concat([id]))}); - }, - remove_id: function(id) { - this.set({'value': _.without(this.get("value"), id)}); - }, - focus: function () { - if(!this.get("effective_readonly")) { - return this.many2one.focus(); - } - return false; - }, - set_dimensions: function(height, width) { - if(this.many2one) { - this.many2one.$el.css('height', 'auto'); - } - this.$el.css({ - width: width, - minHeight: height, - }); - if(this.many2one) { - this.many2one.$el.css('height', this.$el.height()); - } - }, - open_color_picker: function(ev){ - - if (this.fields.color) { - this.$color_picker = $(QWeb.render('FieldMany2ManyTag.colorpicker', { - 'widget': this, - 'tag_id': $(ev.currentTarget).data('id'), - })); - - $(ev.currentTarget).append(this.$color_picker); - this.$color_picker.dropdown('toggle'); - this.$color_picker.attr("tabindex", 1).focus(); - } - }, - close_color_picker: function(){ - this.$color_picker.remove(); - }, - update_color: function(ev) { - ev.preventDefault(); - - var color = $(ev.currentTarget).data('color'); - var id = $(ev.currentTarget).data('id'); - - var self = this; - this.dataset.call('write', [id, {'color': color}]).done(function(){ - self.dataset.cache[id].from_read = {}; - self.dataset.evict_record(id); - var tag = self.$el.find("span.badge[data-id='" + id + "']"); - var old_color = tag.data('color'); - tag.removeClass('o_tag_color_' + old_color); - tag.data('color', color); - tag.addClass('o_tag_color_' + color); - }); - }, -}); - -/** - * Widget for (many2many field) to upload one or more file in same time and display in list. - * The user can delete his files. - * Options on attribute ; "blockui" {Boolean} block the UI or not during the file is uploading - */ -var FieldMany2ManyBinaryMultiFiles = AbstractManyField.extend(common.ReinitializeFieldMixin, { - template: "FieldBinaryFileUploader", - events: { - 'click .o_attach': function(e) { - this.$('.o_form_input_file').click(); - }, - 'change .o_form_input_file': function(event) { - event.stopPropagation(); - var files = event.target.files, - attachments = this.get('value'); - - if(this.node.attrs.blockui){ - framework.blockUI(); - } - _.each(files, function(file){ - var attachment = _.findWhere(_.values(this.data), {filename: file.name}); - if(attachment && !attachment.no_unlink){ - this.ds_file.unlink([attachment.id]); - attachments = _.without(attachments, attachment.id); - this.data = _.omit(this.data, attachment.id); - } - this.files_uploading.push(file); - }.bind(this)); - this.set({value: attachments}); - this.$('form.o_form_binary_form').submit(); - this.$(".oe_fileupload").hide(); - this.render_value(); - }, - 'click .oe_delete': function(e) { - e.preventDefault(); - e.stopPropagation(); - - var file_id = $(e.currentTarget).data("id"); - if(file_id) { - var files = _.without(this.get('value'), file_id); - if(!this.data[file_id].no_unlink) { - this.ds_file.unlink([file_id]); - this.data = _.omit(this.data, file_id); - } - this.set({'value': files}); - } - }, - }, - init: function(field_manager, node) { - this._super.apply(this, arguments); - this.session = session; - if(this.field.type != "many2many" || this.field.relation != 'ir.attachment') { - throw _.str.sprintf(_t("The type of the field '%s' must be a many2many field with a relation to 'ir.attachment' model."), this.field.string); - } - this.data = {}; - this.files_uploading = []; - this.set_value([]); - this.ds_file = new data.DataSetSearch(this, 'ir.attachment'); - this.fileupload_id = _.uniqueId('oe_fileupload_temp'); - $(window).on(this.fileupload_id, _.bind(this.on_file_loaded, this)); - }, - get_file_url: function(attachment) { - return '/web/content/' + attachment.id + '?download=true'; - }, - read_name_values : function() { - var self = this; - // don't reset know values - var ids = this.get('value'); - var _value = _.filter(ids, function(id) { return self.data[id] === undefined; }); - // send request for get_name - if(_value.length) { - return this.ds_file.call('read', [_value, ['id', 'name', 'datas_fname', 'mimetype']]) - .then(process_data); - } else { - return $.when(ids); - } - - function process_data(datas) { - _.each(datas, function(data) { - data.no_unlink = true; - data.url = self.get_file_url(data); - self.data[data.id] = data; - }); - return ids; - } - }, - render_value: function() { - var self = this; - this.read_name_values().then(function (ids) { - self.$('.oe_placeholder_files, .oe_attachments') - .replaceWith($(QWeb.render('FieldBinaryFileUploader.files', {'widget': self, 'values': ids}))); - self.$(".oe_fileupload").show(); - - // display image thumbnail - self.$(".o_image[data-mimetype^='image']").each(function () { - var $img = $(this); - if (/gif|jpe|jpg|png/.test($img.data('mimetype')) && $img.data('src')) { - $img.css('background-image', "url('" + $img.data('src') + "')"); - } - }); - }); - }, - on_file_loaded: function(e, result) { - var attachments = this.get('value'), - files = Array.prototype.slice.call(arguments, 1); - this.files_uploading = []; // files has been uploaded clear uploading - - if(this.node.attrs.blockui) { // unblock UI - framework.unblockUI(); - } - var upload_error = _.filter(files, function(attachment) {return attachment.error;}); - if (upload_error.length) { - this.do_warn(_t('Uploading Error'), upload_error[0].error); - } - _.each(files, function(file){ - if(!file.error){ - attachments.push(file.id); - file.url = this.get_file_url(file); - this.data[file.id] = file; - } - }.bind(this)); - this.set({value: _.clone(attachments)}); - this.render_value(); - }, -}); - -/* - This type of field display a list of checkboxes. It works only with m2ms. This field will display one checkbox for each - record existing in the model targeted by the relation, according to the given domain if one is specified. Checked records - will be added to the relation. -*/ -var FieldMany2ManyCheckBoxes = AbstractManyField.extend(common.ReinitializeFieldMixin, { - className: "oe_form_many2many_checkboxes", - init: function() { - this._super.apply(this, arguments); - this.set("records", []); - this.field_manager.on("view_content_has_changed", this, function() { - var domain = new data.CompoundDomain(this.build_domain()).eval(); - if (!_.isEqual(domain, this.get("domain"))) { - this.set("domain", domain); - } - }); - this.records_orderer = new utils.DropMisordered(); - }, - initialize_field: function() { - common.ReinitializeFieldMixin.initialize_field.call(this); - this.on("change:domain", this, this.query_records); - this.set("domain", new data.CompoundDomain(this.build_domain()).eval()); - this.on("change:records", this, this.render_value); - }, - query_records: function() { - var self = this; - var model = new Model(this.field.relation); - this.records_orderer.add(model.call("search", [this.get("domain")], {"context": this.build_context()}).then(function(record_ids) { - return model.call("name_get", [record_ids] , {"context": self.build_context()}); - })).then(function(res) { - self.set("records", res); - }); - }, - render_value: function() { - this.$el.html(QWeb.render("FieldMany2ManyCheckBoxes", {widget: this})); - var $inputs = this.$("input"); - $inputs.change(_.bind(this.from_dom, this)); - if (this.get("effective_readonly")) { - $inputs.attr("disabled", "true"); - } - }, - from_dom: function() { - var new_value = this.$("input:checked").map(function() { return +$(this).data("record-id"); }).get(); - if (!_.isEqual(new_value, this.get("value"))) { - this.internal_set_value(new_value); - } - }, - is_false: function() { - return false; - }, -}); - -core.form_widget_registry - .add('many2one', FieldMany2One) - .add('many2many', FieldMany2Many) - .add('many2many_tags', FieldMany2ManyTags) - .add('many2many_kanban', FieldMany2ManyKanban) - .add('one2many', FieldOne2Many) - .add('one2many_list', FieldOne2Many) - .add('many2many_binary', FieldMany2ManyBinaryMultiFiles) - .add('many2many_checkboxes', FieldMany2ManyCheckBoxes); - -core.one2many_view_registry - .add('list', One2ManyListView); - -return { - FieldMany2ManyTags: FieldMany2ManyTags, - AbstractManyField: AbstractManyField, - FieldMany2One: FieldMany2One, -}; - -}); diff --git a/addons/web/static/src/js/views/form_upgrade_widgets.js b/addons/web/static/src/js/views/form_upgrade_widgets.js deleted file mode 100644 index 57f7ec858a3..00000000000 --- a/addons/web/static/src/js/views/form_upgrade_widgets.js +++ /dev/null @@ -1,101 +0,0 @@ -odoo.define('web.form_upgrade_widgets', function (require) { -"use strict"; - -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var form_widgets = require('web.form_widgets'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); - -var _t = core._t; -var QWeb = core.qweb; - -/** - * This widget is intended to be used in config settings. - * When checked, an upgrade popup is showed to the user. - */ -var AbstractFieldUpgrade = { - events: { - 'click input': 'on_click_input', - }, - - start: function() { - this._super.apply(this, arguments); - this.get_enterprise_label().after($("<span>", { - text: "Enterprise", - 'class': "label label-primary oe_inline" - })); - }, - - open_dialog: function() { - var message = $(QWeb.render('EnterpriseUpgrade')); - - var buttons = [ - { - text: _t("Upgrade now"), - classes: 'btn-primary', - close: true, - click: this.confirm_upgrade, - }, - { - text: _t("Cancel"), - close: true, - }, - ]; - - return new Dialog(this, { - size: 'medium', - buttons: buttons, - $content: $('<div>', { - html: message, - }), - title: _t("Odoo Enterprise"), - }).open(); - }, - - confirm_upgrade: function() { - new Model("res.users").call("search_count", [[["share", "=", false]]]).then(function(data) { - framework.redirect("https://www.odoo.com/odoo-enterprise/upgrade?num_users=" + data); - }); - }, - - get_enterprise_label: function() {}, - on_click_input: function() {}, -}; - -var UpgradeBoolean = form_widgets.FieldBoolean.extend(AbstractFieldUpgrade, { - template: "FieldUpgradeBoolean", - - get_enterprise_label: function() { - return this.$label; - }, - - on_click_input: function() { - if(this.$checkbox.prop("checked")) { - this.open_dialog().on('closed', this, function() { - this.$checkbox.prop("checked", false); - }); - } - }, -}); - -var UpgradeRadio = form_widgets.FieldRadio.extend(AbstractFieldUpgrade, { - get_enterprise_label: function() { - // override the margin:0px - this.$('label').addClass('mr4'); - return this.$('label').last(); - }, - on_click_input: function(event) { - if($(event.target).val() === "1") { - this.open_dialog().on('closed', this, function() { - this.$('input').first().prop("checked", true); - }); - } - }, -}); - -core.form_widget_registry - .add('upgrade_boolean', UpgradeBoolean) - .add('upgrade_radio', UpgradeRadio); - -}); diff --git a/addons/web/static/src/js/views/form_view.js b/addons/web/static/src/js/views/form_view.js deleted file mode 100644 index 0471f6d3156..00000000000 --- a/addons/web/static/src/js/views/form_view.js +++ /dev/null @@ -1,1653 +0,0 @@ -odoo.define('web.FormView', function (require) { -"use strict"; - -var common = require('web.form_common'); -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var data = require('web.data'); -var Dialog = require('web.Dialog'); -var FormRenderingEngine = require('web.FormRenderingEngine'); -var Model = require('web.DataModel'); -var Pager = require('web.Pager'); -var Sidebar = require('web.Sidebar'); -var utils = require('web.utils'); -var View = require('web.View'); - -var _t = core._t; -var _lt = core._lt; -var QWeb = core.qweb; - -/** - * Properties: - * - actual_mode: always "view", "edit" or "create". Read-only property. Determines - * the mode used by the view. - */ -var FormView = View.extend(common.FieldManagerMixin, { - className: "o_form_view", - defaults: _.extend({}, View.prototype.defaults, { - not_interactible_on_create: false, - initial_mode: "view", - disable_autofocus: false, - footer_to_buttons: false, - }), - display_name: _lt('Form'), - icon: 'fa-edit', - multi_record: false, - // Indicates that this view is not searchable, and thus that no search view should be displayed. - searchable: false, - - /** - * Called each time the form view is attached into the DOM - */ - on_attach_callback: function() { - this.trigger('attached'); - this.autofocus(); - }, - /** - * Called each time the form view is detached from the DOM - */ - on_detach_callback: function() { - this.trigger('detached'); - }, - init: function() { - var self = this; - this._super.apply(this, arguments); - this.fields = {}; - this.fields_order = []; - this.datarecord = {}; - this.onchanges_mutex = new utils.Mutex(); - this.default_focus_field = null; - this.default_focus_button = null; - this.fields_registry = core.form_widget_registry; - this.tags_registry = core.form_tag_registry; - this.widgets_registry = core.form_custom_registry; - this.has_been_loaded = $.Deferred(); - this.translatable_fields = []; - this.is_initialized = $.Deferred(); - this.record_loaded = $.Deferred(); - this.mutating_mutex = new utils.Mutex(); - this.save_list = []; - this.render_value_defs = []; - this.reload_mutex = new utils.Mutex(); - this.__clicked_inside = false; - this.__blur_timeout = null; - this.rendering_engine = new FormRenderingEngine(this); - this.set({actual_mode: this.options.initial_mode}); - this.has_been_loaded.done(function() { - self.on("change:actual_mode", self, self.toggle_buttons); - self.on("change:actual_mode", self, self.toggle_sidebar); - }); - self.on("load_record", self, self.load_record); - core.bus.on('clear_uncommitted_changes', this, function(chain_callbacks) { - var self = this; - chain_callbacks(function() { - return self.can_be_discarded(); - }); - }); - }, - start: function() { - if (this.$pager) { - this.$pager.off(); - } - var self = this; - - this.rendering_engine.set_fields_registry(this.fields_registry); - this.rendering_engine.set_tags_registry(this.tags_registry); - this.rendering_engine.set_widgets_registry(this.widgets_registry); - this.rendering_engine.set_fields_view(this.fields_view); - this.rendering_engine.render_to(this.$el); - - this.$el.on('mousedown.formBlur', function () { - self.__clicked_inside = true; - }); - - this.has_been_loaded.resolve(); - - // Add bounce effect on button 'Edit' when click on readonly page view. - this.$(".oe_title,.o_group").on('click', function (e) { - if(self.get("actual_mode") === "view" && self.$buttons && !$(e.target).is('[data-toggle]')) { - self.$buttons.find(".o_form_button_edit").openerpBounce(); - core.bus.trigger('click', e); - } - }); - return this._super(); - }, - destroy: function() { - _.each(this.get_widgets(), function(w) { - w.off('focused blurred'); - w.destroy(); - }); - if (this.$el) { - this.$el.off('.formBlur'); - } - this._super(); - }, - /** - * Render the buttons according to the FormView.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 FormView inserts them into this.options.$buttons - * if it exists - */ - render_buttons: function($node) { - this.$buttons = $('<div/>'); - - var $footer = this.$('footer'); - if (this.options.action_buttons !== false || this.options.footer_to_buttons && $footer.children().length === 0) { - this.$buttons.append(QWeb.render("FormView.buttons", {'widget': this})); - } - if (this.options.footer_to_buttons) { - $footer.appendTo(this.$buttons); - } - - // Show or hide the buttons according to the view mode - this.toggle_buttons(); - this.$buttons.on('click', '.o_form_button_create', this.on_button_create); - this.$buttons.on('click', '.o_form_button_edit', this.on_button_edit); - this.$buttons.on('click', '.o_form_button_save', this.on_button_save); - this.$buttons.on('click', '.o_form_button_cancel', this.on_button_cancel); - - this.$buttons.appendTo($node); - }, - /** - * Instantiate and render the sidebar if a sidebar is requested - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - **/ - render_sidebar: function($node) { - if (!this.sidebar && this.options.sidebar) { - this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); - if (this.fields_view.toolbar) { - this.sidebar.add_toolbar(this.fields_view.toolbar); - } - this.sidebar.add_items('other', _.compact([ - this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.on_button_delete }, - this.is_action_enabled('create') && { label: _t('Duplicate'), callback: this.on_button_duplicate } - ])); - - this.sidebar.appendTo($node); - - // Show or hide the sidebar according to the view mode - this.toggle_sidebar(); - } - }, - /** - * Instantiate and render the pager and add listeners on it. - * Set this.pager - * @param {jQuery} [$node] a jQuery node where the pager should be inserted - * $node may be undefined, in which case the FormView inserts the pager into this.options.$pager - */ - render_pager: function($node) { - if (this.options.pager) { - var self = this; - var options = { - validate: _.bind(this.can_be_discarded, this), - }; - - this.pager = new Pager(this, this.dataset.ids.length, this.dataset.index + 1, 1, options); - this.pager.on('pager_changed', this, function (new_state) { - this.pager.disable(); - this.dataset.index = new_state.current_min - 1; - this.trigger('pager_action_executed'); - $.when(this.reload()).then(function () { - self.pager.enable(); - }); - }); - - this.pager.appendTo($node = $node || this.options.$pager); - - // Hide the pager in create mode - if (this.get("actual_mode") === "create") { - this.pager.do_hide(); - } - } - }, - /** - * Show or hide the buttons according to the actual_mode - */ - toggle_buttons: function() { - var view_mode = this.get("actual_mode") === "view"; - if (this.$buttons) { - this.$buttons.find('.o_form_buttons_view').toggle(view_mode); - this.$buttons.find('.o_form_buttons_edit').toggle(!view_mode); - } - }, - /** - * Show or hide the sidebar according to the actual_mode - */ - toggle_sidebar: function() { - if (this.sidebar) { - this.sidebar.do_toggle(this.get("actual_mode") === "view"); - } - }, - update_pager: function() { - if (this.pager) { - // Hide the pager in create mode - if (this.get("actual_mode") === "create") { - this.pager.do_hide(); - } else { - this.pager.update_state({size: this.dataset.ids.length, current_min: this.dataset.index + 1}); - } - } - }, - widgetFocused: function() { - // Clear click flag if used to focus a widget - this.__clicked_inside = false; - if (this.__blur_timeout) { - clearTimeout(this.__blur_timeout); - this.__blur_timeout = null; - } - }, - widgetBlurred: function() { - if (this.__clicked_inside) { - // clicked in an other section of the form (than the currently - // focused widget) => just ignore the blurring entirely? - this.__clicked_inside = false; - return; - } - var self = this; - // clear timeout, if any - this.widgetFocused(); - this.__blur_timeout = setTimeout(function () { - self.trigger('blurred'); - }, 0); - }, - - do_load_state: function(state, warm) { - if (state.id && this.datarecord.id != state.id) { - if (this.dataset.get_id_index(state.id) === null) { - this.dataset.ids.push(state.id); - } - this.dataset.select_id(state.id); - this.do_show(); - } - }, - /** - * @param {Boolean} [options.mode=undefined] If specified, switch the form to specified mode. Can be "edit" or "view". - * @param {Boolean} [options.reload=true] whether the form should reload its content on show, or use the currently loaded record - * @return {$.Deferred} - */ - do_show: function (options) { - var self = this; - options = options || {}; - this.$el.removeClass('oe_form_dirty'); - - var shown = this.has_been_loaded; - if (options.reload !== false) { - shown = shown.then(function() { - if (self.dataset.index === null) { - // null index means we should start a new record - return self.on_button_new(); - } - var fields = _.keys(self.fields_view.fields); - fields.push('display_name'); - fields.push('__last_update'); - return self.dataset.read_index(fields, { - context: { 'bin_size': true } - }).then(function(r) { - self.trigger('load_record', r); - }); - }); - } - return $.when(shown, this._super()).then(function() { - self._actualize_mode(options.mode || self.options.initial_mode); - core.bus.trigger('form_view_shown', self); - }); - }, - load_record: function(record) { - var self = this, set_values = []; - if (!record) { - this.set({ 'title' : undefined }); - this.do_warn(_t("Form"), _t("The record could not be found in the database."), true); - return $.Deferred().reject(); - } - this.datarecord = record; - - this.record_loaded = $.Deferred(); - _(this.fields).each(function (field, f) { - field._dirty_flag = false; - field._inhibit_on_change_flag = true; - var result = field.set_value(self.datarecord[f] || false); - field._inhibit_on_change_flag = false; - set_values.push(result); - }); - this._actualize_mode(); // call after updating the fields as it may trigger a re-rendering - this.set({ 'title' : record.id ? record.display_name : _t("New") }); - this.update_pager(); // the mode must be actualized before updating the pager - return $.when.apply(null, set_values).then(function() { - if (!record.id) { - // trigger onchange for new record after x2many with non-embedded views are loaded - var fields_loaded = _.pluck(self.fields, 'is_loaded'); - $.when.apply(null, fields_loaded).done(function() { - self.do_onchange(null); - }); - } - self.on_form_changed(); - self.rendering_engine.init_fields().then(function() { - self.is_initialized.resolve(); - self.record_loaded.resolve(); - if (self.sidebar) { - self.sidebar.do_attachement_update(self.dataset, self.datarecord.id); - } - if (record.id) { - self.do_push_state({id:record.id}); - } else { - self.do_push_state({}); - } - self.$el.removeClass('oe_form_dirty'); - }); - }); - }, - /** - * Loads and sets up the default values for the model as the current - * record - * - * @return {$.Deferred} - */ - load_defaults: function () { - var self = this; - var keys = _.keys(this.fields_view.fields); - if (keys.length) { - return this.dataset.default_get(keys).then(function(r) { - self.trigger('load_record', _.clone(r)); - }); - } - return $.when().then(this.trigger.bind(this, 'load_record', {})); - }, - on_form_changed: function() { - this.trigger("view_content_has_changed"); - }, - do_notify_change: function() { - this.$el.addClass('oe_form_dirty'); - }, - _build_onchange_specs: function() { - var self = this; - var find = function(field_name, root) { - var fields = [root]; - while (fields.length) { - var node = fields.pop(); - if (!node) { - continue; - } - if (node.tag === 'field' && node.attrs.name === field_name) { - return node.attrs.on_change || ""; - } - fields = _.union(fields, node.children); - } - return ""; - }; - - self._onchange_fields = []; - self._onchange_specs = {}; - _.each(this.fields, function(field, name) { - self._onchange_fields.push(name); - self._onchange_specs[name] = find(name, field.node); - - // we get the list of first-level fields of x2many firstly by - // getting them from the field embedded views, then if no embedded - // view is present for a loaded view, we get them from the default - // view that has been loaded - - // gather embedded view objects - var views = _.clone(field.field.views); - // also gather default view objects - if (field.viewmanager) { - _.each(field.viewmanager.views, function(view, view_type) { - // add default view if it was not embedded and it is loaded - if (views[view_type] === undefined && view.controller) { - views[view_type] = view.controller.fields_view; - } - }); - } - _.each(views, function(view) { - _.each(view.fields, function(_, subname) { - self._onchange_specs[name + '.' + subname] = find(subname, view.arch); - }); - }); - }); - }, - _get_onchange_values: function() { - var field_values = this.get_fields_values(); - if (field_values.id.toString().match(data.BufferedDataSet.virtual_id_regex)) { - delete field_values.id; - } - if (this.dataset.parent_view) { - // this belongs to a parent view: add parent field if possible - var parent_view = this.dataset.parent_view; - var child_name = this.dataset.child_name; - var parent_name = parent_view.get_field_desc(child_name).relation_field; - if (parent_name) { - // consider all fields except the inverse of the parent field - var parent_values = parent_view.get_fields_values(); - delete parent_values[child_name]; - field_values[parent_name] = parent_values; - } - } - return field_values; - }, - - do_onchange: function(widget) { - var self = this; - if (self._onchange_specs === undefined) { - self._build_onchange_specs(); - } - var onchange_specs = self._onchange_specs; - try { - var def = $.when({}); - var change_spec = widget ? onchange_specs[widget.name] : null; - if (!widget || (!_.isEmpty(change_spec) && change_spec !== "0")) { - var ids = [], - trigger_field_name = widget ? widget.name : self._onchange_fields, - values = self._get_onchange_values(), - context = new data.CompoundContext(self.dataset.get_context()); - - if (widget && widget.build_context()) { - context.add(widget.build_context()); - } - if (self.dataset.parent_view) { - var parent_name = self.dataset.parent_view.get_field_desc(self.dataset.child_name).relation_field; - context.add({field_parent: parent_name}); - } - - if (self.datarecord.id && !data.BufferedDataSet.virtual_id_regex.test(self.datarecord.id)) { - // In case of a o2m virtual id, we should pass an empty ids list - ids.push(self.datarecord.id); - } - def = self.alive(self.dataset.call( - "onchange", [ids, values, trigger_field_name, onchange_specs, context])); - } - this.onchanges_mutex.exec(function(){ - return def.then(function(response) { - var fields = {}; - if (widget){ - fields[widget.name] = widget.field; - } - else{ - fields = self.fields_view.fields; - } - var defs = []; - _.each(fields, function(field, fieldname){ - if (field && field.change_default) { - var value_; - if (response.value && (fieldname in response.value)) { - // Use value from onchange if onchange executed - value_ = response.value[fieldname]; - } else { - // otherwise get form value for field - value_ = self.fields[fieldname].get_value(); - } - var condition = fieldname + '=' + value_; - - if (value_) { - defs.push(self.alive(new Model('ir.values').call( - 'get_defaults', [self.model, condition] - )).then(function (results) { - if (!results.length) { - return response; - } - if (!response.value) { - response.value = {}; - } - for(var i=0; i<results.length; ++i) { - // [whatever, key, value] - var triplet = results[i]; - response.value[triplet[1]] = triplet[2]; - } - return response; - })); - } - } - }); - return _.isEmpty(defs) ? response : $.when.apply(null, defs); - }).then(function(response) { - return self.on_processed_onchange(response); - }); - }); - return this.onchanges_mutex.def; - } catch(e) { - console.error(e); - crash_manager.show_message(e); - return $.Deferred().reject(); - } - }, - on_processed_onchange: function(result) { - try { - var fields = this.fields; - _(result.domain).each(function (domain, fieldname) { - var field = fields[fieldname]; - if (!field) { return; } - field.node.attrs.domain = domain; - }); - - var def = $.when(!_.isEmpty(result.value) && this._internal_set_values(result.value)); - - // FIXME XXX a list of warnings? - if (!_.isEmpty(result.warning)) { - this.warning_displayed = true; - var dialog = new Dialog(this, { - size: 'medium', - title:result.warning.title, - $content: QWeb.render("CrashManager.warning", result.warning) - }); - dialog.open(); - dialog.on('closed', this, function () { - this.warning_displayed = false; - }); - } - - return def; - } catch(e) { - console.error(e); - crash_manager.show_message(e); - return $.Deferred().reject(); - } - }, - _process_operations: function() { - var self = this; - return this.mutating_mutex.exec(function() { - function onchanges_mutex () {return self.onchanges_mutex.def;} - function iterate() { - var mutex = new utils.Mutex(); - mutex.exec(onchanges_mutex); - _.each(self.fields, function(field) { - mutex.exec(function(){ - return field.commit_value(); - }); - mutex.exec(onchanges_mutex); - }); - - return mutex.def.then(function() { - var save_obj = self.save_list.pop(); - if (save_obj) { - return self._process_save(save_obj).then(function() { - save_obj.ret = _.toArray(arguments); - return iterate(); - }, function() { - save_obj.error = true; - }); - } - return $.when(); - }).fail(function() { - self.save_list.pop(); - return $.when(); - }); - } - return iterate(); - }); - }, - _internal_set_values: function(values) { - for (var f in values) { - if (!values.hasOwnProperty(f)) { continue; } - var field = this.fields[f]; - // If field is not defined in the view, just ignore it - if (field) { - var value_ = values[f]; - if (field.get_value() !== value_) { - field._inhibit_on_change_flag = true; - field.set_value(value_); - field._inhibit_on_change_flag = false; - field._dirty_flag = true; - } - } - } - this.on_form_changed(); - }, - set_values: function(values) { - var self = this; - return this.mutating_mutex.exec(function() { - self._internal_set_values(values); - }); - }, - /** - * Ask the view to switch to view mode if possible. The view may not do it - * if the current record is not yet saved. It will then stay in create mode. - */ - to_view_mode: function() { - this._actualize_mode("view"); - this.trigger('to_view_mode'); - }, - /** - * Ask the view to switch to edit mode if possible. The view may not do it - * if the current record is not yet saved. It will then stay in create mode. - */ - to_edit_mode: function() { - this.onchanges_mutex = new utils.Mutex(); - this._actualize_mode("edit"); - this.trigger('to_edit_mode'); - }, - /** - * Ask the view to switch to a precise mode if possible. The view is free to - * not respect this command if the state of the dataset is not compatible with - * the new mode. For example, it is not possible to switch to edit mode if - * the current record is not yet saved in database. - * - * @param {string} [new_mode] Can be "edit", "view", "create" or undefined. If - * undefined the view will test the actual mode to check if it is still consistent - * with the dataset state. - */ - _actualize_mode: function(switch_to) { - var mode = switch_to || this.get("actual_mode"); - if (! this.datarecord.id) { - mode = "create"; - } else if (mode === "create") { - mode = "edit"; - } - - var viewMode = (mode === "view"); - this.$el.toggleClass('o_form_readonly', viewMode).toggleClass('o_form_editable', !viewMode); - - this.render_value_defs = []; - this.set({actual_mode: mode}); - - if(!viewMode) { - this.autofocus(); - } - }, - autofocus: function() { - if (this.get("actual_mode") !== "view" && !this.options.disable_autofocus) { - var fields_order = this.fields_order.slice(0); - if (this.default_focus_field) { - fields_order.unshift(this.default_focus_field.name); - } - for (var i = 0; i < fields_order.length; i += 1) { - var field = this.fields[fields_order[i]]; - if (!field.get('effective_invisible') && !field.get('effective_readonly') && field.$label) { - if (field.focus() !== false) { - break; - } - } - } - } - }, - disable_button: function () { - this.$('.oe_form_buttons,.o_statusbar_buttons').add(this.$buttons).find('button').addClass('o_disabled').prop('disabled', true); - this.is_disabled = true; - }, - enable_button: function () { - this.$('.oe_form_buttons,.o_statusbar_buttons').add(this.$buttons).find('button.o_disabled').removeClass('o_disabled').prop('disabled', false); - this.is_disabled = false; - }, - on_button_save: function() { - var self = this; - if (this.is_disabled) { - return; - } - this.disable_button(); - return this.save().then(function(result) { - self.trigger("save", result); - return self.reload().then(function() { - self.to_view_mode(); - core.bus.trigger('form_view_saved', self); - }).always(function() { - self.enable_button(); - }); - }).fail(function(){ - self.enable_button(); - }); - }, - on_button_cancel: function() { - var self = this; - this.can_be_discarded().then(function() { - if (self.get('actual_mode') === 'create') { - self.trigger('history_back'); - } else { - self.to_view_mode(); - $.when.apply(null, self.render_value_defs).then(function(){ - self.trigger('load_record', self.datarecord); - }); - } - }); - this.trigger('on_button_cancel'); - return false; - }, - on_button_new: function() { - return $.when(this.has_been_loaded) - .then(this.can_be_discarded.bind(this)) - .then(this.load_defaults.bind(this)); - }, - on_button_edit: function() { - return this.to_edit_mode(); - }, - on_button_create: function() { - this.dataset.index = null; - this.do_show(); - }, - on_button_duplicate: function() { - var self = this; - return this.has_been_loaded.then(function() { - return self.dataset.call('copy', [self.datarecord.id, {}, self.dataset.context]).then(function(new_id) { - self.record_created(new_id); - self.to_edit_mode(); - }); - }); - }, - on_button_delete: function() { - var self = this; - var def = $.Deferred(); - this.has_been_loaded.done(function() { - if (self.datarecord.id && confirm(_t("Do you really want to delete this record?"))) { - self.dataset.unlink([self.datarecord.id]).done(function() { - if (self.dataset.size()) { - self.reload(); - self.update_pager(); - } else { - self.do_action('history_back'); - } - def.resolve(); - }); - } else { - utils.async_when().done(function () { - def.reject(); - }); - } - }); - return def.promise(); - }, - can_be_discarded: function(message) { - if (!this.$el.is('.oe_form_dirty')) { - return $.Deferred().resolve(); - } - - message = message || _t("The record has been modified, your changes will be discarded. Are you sure you want to leave this page ?"); - - var self = this; - var def = $.Deferred(); - var options = { - title: _t("Warning"), - confirm_callback: function() { - self.$el.removeClass('oe_form_dirty'); - this.on('closed', null, function() { // 'this' is the dialog widget - def.resolve(); - }); - }, - cancel_callback: function() { - def.reject(); - }, - }; - var dialog = Dialog.confirm(this, message, options); - dialog.$modal.on('hidden.bs.modal', function() { - def.reject(); - }); - return def; - }, - /** - * Triggers saving the form's record. Chooses between creating a new - * record or saving an existing one depending on whether the record - * already has an id property. - * - * @param {Boolean} [prepend_on_create=false] if ``save`` creates a new - * record, should that record be inserted at the start of the dataset (by - * default, records are added at the end) - */ - save: function(prepend_on_create) { - var self = this; - var save_obj = {prepend_on_create: prepend_on_create, ret: null}; - this.save_list.push(save_obj); - return self._process_operations().then(function() { - if (save_obj.error) - return $.Deferred().reject(); - return $.when.apply($, save_obj.ret); - }).done(function(result) { - self.$el.removeClass('oe_form_dirty'); - }); - }, - _process_save: function(save_obj) { - var self = this; - var prepend_on_create = save_obj.prepend_on_create; - var def_process_save = $.Deferred(); - try { - var form_invalid = false, - values = {}, - first_invalid_field = null, - readonly_values = {}, - deferred = []; - - $.when.apply($, deferred).always(function () { - - _.each(self.fields, function (f) { - if (!f.is_valid()) { - form_invalid = true; - if (!first_invalid_field) { - first_invalid_field = f; - } - } else if (f.name !== 'id' && (!self.datarecord.id || f._dirty_flag)) { - // Special case 'id' field, do not save this field - // on 'create' : save all non readonly fields - // on 'edit' : save non readonly modified fields - if (!f.get("readonly")) { - values[f.name] = f.get_value(true); - } else { - readonly_values[f.name] = f.get_value(true); - } - } - - }); - - // Heuristic to assign a proper sequence number for new records that - // are added in a dataset containing other lines with existing sequence numbers - if (!self.datarecord.id && self.fields.sequence && - !_.has(values, 'sequence') && !_.isEmpty(self.dataset.cache)) { - // Find current max or min sequence (editable top/bottom) - var current = _[prepend_on_create ? "min" : "max"]( - _.map(self.dataset.cache, function(o){return o.values.sequence}) - ); - values['sequence'] = prepend_on_create ? current - 1 : current + 1; - } - if (form_invalid) { - self.set({'display_invalid_fields': true}); - first_invalid_field.focus(); - self.on_invalid(); - def_process_save.reject(); - } else { - self.set({'display_invalid_fields': false}); - var save_deferral; - if (!self.datarecord.id) { - // Creation save - save_deferral = self.dataset.create(values, {readonly_fields: readonly_values}).then(function(r) { - self.display_translation_alert(values); - return self.record_created(r, prepend_on_create); - }, null); - } else if (_.isEmpty(values)) { - // Not dirty, noop save - save_deferral = $.Deferred().resolve({}).promise(); - } else { - // Write save - save_deferral = self.dataset.write(self.datarecord.id, values, {readonly_fields: readonly_values}).then(function(r) { - self.display_translation_alert(values); - return self.record_saved(r); - }, null); - } - save_deferral.then(function(result) { - def_process_save.resolve(result); - }).fail(function() { - def_process_save.reject(); - }); - } - }); - } catch (e) { - console.error(e); - return def_process_save.reject(); - } - return def_process_save; - }, - on_invalid: function() { - var warnings = _(this.fields).chain() - .filter(function (f) { return !f.is_valid(); }) - .map(function (f) { - return _.str.sprintf('<li>%s</li>', - _.escape(f.string)); - }).value(); - warnings.unshift('<ul>'); - warnings.push('</ul>'); - this.do_warn(_t("The following fields are invalid:"), warnings.join('')); - }, - /** - * Reload the form after saving - * - * @param {Object} r result of the write function. - */ - record_saved: function(r) { - this.trigger('record_saved', r); - if (!r) { - // should not happen in the server, but may happen for internal purpose - return $.Deferred().reject(); - } - return r; - }, - /** - * Updates the form' dataset to contain the new record: - * - * * Adds the newly created record to the current dataset (at the end by - * default) - * * Selects that record (sets the dataset's index to point to the new - * record's id). - * * Updates the pager and sidebar displays - * - * @param {Object} r - * @param {Boolean} [prepend_on_create=false] adds the newly created record - * at the beginning of the dataset instead of the end - */ - record_created: function(r, prepend_on_create) { - var self = this; - if (!r) { - // should not happen in the server, but may happen for internal purpose - this.trigger('record_created', r); - return $.Deferred().reject(); - } else { - this.datarecord.id = r; - if (!prepend_on_create) { - this.dataset.alter_ids(this.dataset.ids.concat([this.datarecord.id])); - this.dataset.index = this.dataset.ids.length - 1; - } else { - this.dataset.alter_ids([this.datarecord.id].concat(this.dataset.ids)); - this.dataset.index = 0; - } - this.update_pager(); - if (this.sidebar) { - this.sidebar.do_attachement_update(this.dataset, this.datarecord.id); - } - //openerp.log("The record has been created with id #" + this.datarecord.id); - return $.when(this.reload()).then(function () { - self.trigger('record_created', r); - return _.extend(r, {created: true}); - }); - } - }, - on_action: function (action) { - console.debug('Executing action', action); - }, - reload: function() { - var self = this; - return this.reload_mutex.exec(function() { - if (self.dataset.index === null || self.dataset.index === undefined) { - self.trigger("previous_view"); - return $.Deferred().reject().promise(); - } - if (self.dataset.index < 0) { - return $.when(self.on_button_new()); - } else { - var fields = _.keys(self.fields_view.fields); - fields.push('display_name'); - fields.push('__last_update'); - return self.dataset.read_index(fields, - { - context: { 'bin_size': true }, - check_access_rule: true - }).then(function(r) { - self.trigger('load_record', r); - }).fail(function (){ - self.do_action('history_back'); - }); - } - }); - }, - get_widgets: function() { - return _.filter(this.getChildren(), function(obj) { - return obj instanceof common.FormWidget; - }); - }, - get_fields_values: function() { - var values = {}; - var ids = this.get_selected_ids(); - values["id"] = ids.length > 0 ? ids[0] : false; - _.each(this.fields, function(value_, key) { - values[key] = value_.get_value(); - }); - return values; - }, - get_selected_ids: function() { - var id = this.dataset.ids[this.dataset.index]; - return id ? [id] : []; - }, - recursive_save: function() { - var self = this; - return $.when(this.save()).then(function(res) { - if (self.dataset.parent_view) - return self.dataset.parent_view.recursive_save(); - }); - }, - recursive_reload: function() { - var self = this; - var pre = $.when(); - if (self.dataset.parent_view) - pre = self.dataset.parent_view.recursive_reload(); - return pre.then(function() { - return self.reload(); - }); - }, - is_dirty: function() { - return _.any(this.fields, function (value_) { - return value_._dirty_flag; - }); - }, - is_interactible_record: function() { - var id = this.datarecord.id; - if (!id) { - if (this.options.not_interactible_on_create) - return false; - } else if (typeof(id) === "string") { - if(data.BufferedDataSet.virtual_id_regex.test(id)) - return false; - } - return true; - }, - sidebar_eval_context: function () { - return $.when(this.build_eval_context()); - }, - /* - * 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. - */ - display_translation_alert: function(values) { - if (_t.database.multi_lang) { - var alert_fields = _.filter(this.translatable_fields, function(field) { - return _.has(values, field.name) - }); - if (alert_fields.length) { - var $content = $(QWeb.render('translation-alert', { - fields: alert_fields, - lang: _t.database.parameters.name - })); - // bind click event on "Update translations" links - $content.find('.oe_field_translate').click(function(ev) { - ev.preventDefault(); - _.find(alert_fields, {'name': ev.target.name}).on_translate(); - }); - // show notification - this.once('to_view_mode', this, function () { - this.notify_in_form($content); - }); - } - } - }, - /** - * Add a notification box inside the form. The notification automatically - * goes away (by default when switching to edit mode or changing the form - * content.) - * - * @param {jQuery} $content - * @param {Object} [options] - * @param {String} [options.type], one of: "success", "info", "warning", "danger" - * @param {String} [options.events], when to remove notification - */ - notify_in_form: function ($content, options) { - options = options || {}; - var type = options.type || 'info'; - var $box = $(QWeb.render('notification-box', {type: type})); - $box.append($content); - // create handler to remove notification box - var events = options.events || 'to_edit_mode view_content_has_changed'; - this.once(events, null, function() { - $box.remove(); - }); - // add content inside notification box on top of the sheet/form - var $target = this.$('.o_form_sheet_bg').length ? this.$('.o_form_sheet_bg') : this.$el; - $target.prepend($box); - }, - open_defaults_dialog: function () { - var self = this; - var display = function (field, value) { - var FieldSelection = core.form_widget_registry.get('selection'); - var FieldMany2One = core.form_widget_registry.get('many2one'); - if (!value) { return value; } - if (field instanceof FieldSelection) { - return _(field.get('values')).find(function (option) { - return option[0] === value; - })[1]; - } else if (field instanceof FieldMany2One) { - return field.get_displayed(); - } - return value; - }; - var fields = _.chain(this.fields) - .map(function (field) { - var value = field.get_value(); - // ignore fields which are empty, invisible, readonly, o2m - // or m2m - if (!value - || field.get('invisible') - || field.get("readonly") - || field.field.type === 'one2many' - || field.field.type === 'many2many' - || field.field.type === 'binary' - || field.password) { - return false; - } - - return { - name: field.name, - string: field.string, - value: value, - displayed: display(field, value), - }; - }) - .compact() - .sortBy(function (field) { return field.string; }) - .value(); - var conditions = _.chain(self.fields) - .filter(function (field) { return field.field.change_default; }) - .map(function (field) { - var value = field.get_value(); - return { - name: field.name, - string: field.string, - value: value, - displayed: display(field, value), - }; - }) - .value(); - var d = new Dialog(this, { - title: _t("Set Default"), - buttons: [ - {text: _t("Close"), close: true}, - {text: _t("Save default"), click: function () { - var $defaults = d.$el.find('#formview_default_fields'); - var field_to_set = $defaults.val(); - if (!field_to_set) { - $defaults.parent().addClass('o_form_invalid'); - return; - } - var condition = d.$el.find('#formview_default_conditions').val(), - all_users = d.$el.find('#formview_default_all').is(':checked'); - new data.DataSet(self, 'ir.values').call( - 'set_default', [ - self.model, - field_to_set, - self.fields[field_to_set].get_value(), - all_users, - true, - condition || false - ]).done(function () { d.close(); }); - }} - ] - }); - d.args = { - fields: fields, - conditions: conditions - }; - d.template = 'FormView.set_default'; - d.open(); - }, - register_field: function(field, name) { - this.fields[name] = field; - this.fields_order.push(name); - if (JSON.parse(field.node.attrs.default_focus || "0")) { - this.default_focus_field = field; - } - - field.on('focused', null, this.proxy('widgetFocused')) - .on('blurred', null, this.proxy('widgetBlurred')); - if (this.get_field_desc(name).translate) { - this.translatable_fields.push(field); - } - field.on('changed_value', this, function() { - if (field.is_syntax_valid()) { - this.trigger('field_changed:' + name); - } - if (field._inhibit_on_change_flag) { - return; - } - field._dirty_flag = true; - if (field.is_syntax_valid()) { - this.do_onchange(field); - this.on_form_changed(true); - this.do_notify_change(); - } - }); - }, - get_field_desc: function(field_name) { - return this.fields_view.fields[field_name]; - }, - get_field_value: function(field_name) { - return this.fields[field_name].get_value(); - }, - compute_domain: function(expression) { - var fields = this.fields; - if (!fields.id) { - fields = _.extend({id: {value: this.datarecord.id || false}}, fields); - } - return data.compute_domain(expression, fields); - }, - _build_view_fields_values: function() { - var a_dataset = this.dataset; - var fields_values = this.get_fields_values(); - var active_id = a_dataset.ids[a_dataset.index]; - _.extend(fields_values, { - active_id: active_id || false, - active_ids: active_id ? [active_id] : [], - active_model: a_dataset.model, - parent: {} - }); - if (a_dataset.parent_view) { - fields_values.parent = a_dataset.parent_view.get_fields_values(); - } - return fields_values; - }, - build_eval_context: function() { - var a_dataset = this.dataset; - return new data.CompoundContext(a_dataset.get_context(), this._build_view_fields_values()); - }, -}); - -core.view_registry.add('form', FormView); - -return FormView; - -}); - - -odoo.define('web.FormRenderingEngine', function (require) { -"use strict"; - -var common = require('web.form_common'); -var core = require('web.core'); -var utils = require('web.utils'); - -var _t = core._t; -var QWeb = core.qweb; - -/** - * Default rendering engine for the form view. - * - * It is necessary to set the view using set_view() before usage. - */ -return core.Class.extend({ - init: function(view) { - this.view = view; - }, - set_fields_view: function(fvg) { - this.fvg = fvg; - this.version = parseFloat(this.fvg.arch.attrs.version); - if (isNaN(this.version)) { - this.version = 7.0; - } - }, - set_tags_registry: function(tags_registry) { - this.tags_registry = tags_registry; - }, - set_fields_registry: function(fields_registry) { - this.fields_registry = fields_registry; - }, - set_widgets_registry: function(widgets_registry) { - this.widgets_registry = widgets_registry; - }, - // Backward compatibility tools, current default version: v7 - process_version: function() { - if (this.version < 7.0) { - this.$form.find('form:first').wrapInner('<group col="4"/>'); - this.$form.find('page').each(function() { - if (!$(this).parents('field').length) { - $(this).wrapInner('<group col="4"/>'); - } - }); - } - }, - get_arch_fragment: function() { - var doc = $.parseXML(utils.json_node_to_xml(this.fvg.arch)).documentElement; - // IE won't allow custom button@type and will revert it to spec default : 'submit' - $('button', doc).each(function() { - $(this).attr('data-button-type', $(this).attr('type')).attr('type', 'button'); - }); - // IE's html parser is also a css parser. How convenient... - $('board', doc).each(function() { - $(this).attr('layout', $(this).attr('style')); - }); - return $(utils.xml_to_str(doc)); - }, - render_to: function($target) { - var self = this; - this.$target = $target; - - this.$form = this.get_arch_fragment(); - - this.process_version(); - - this.fields_to_init = []; - this.tags_to_init = []; - this.widgets_to_init = []; - this.labels = {}; - this.process(this.$form); - - this.$form.contents().appendTo(this.$target); - this.handle_common_properties(this.$target, this.$form); - - this.to_replace = []; - - _.each(this.fields_to_init, function($elem) { - var name = $elem.attr("name"); - if (!self.fvg.fields[name]) { - throw new Error(_.str.sprintf(_t("Field '%s' specified in view could not be found."), name)); - } - var obj = self.fields_registry.get_any([$elem.attr('widget'), self.fvg.fields[name].type]); - if (!obj) { - throw new Error(_.str.sprintf(_t("Widget type '%s' is not implemented"), $elem.attr('widget') || self.fvg.fields[name].type )); - } - var w = new (obj)(self.view, utils.xml_to_json($elem[0])); - var $label = self.labels[$elem.attr("name")]; - if ($label) { - w.set_input_id($label.attr("for")); - } - self.alter_field(w); - self.view.register_field(w, $elem.attr("name")); - self.to_replace.push([w, $elem]); - }); - _.each(this.tags_to_init, function($elem) { - var tag_name = $elem[0].tagName.toLowerCase(); - var obj = self.tags_registry.get(tag_name); - var w = new (obj)(self.view, utils.xml_to_json($elem[0])); - self.to_replace.push([w, $elem]); - }); - _.each(this.widgets_to_init, function($elem) { - var widget_type = $elem.attr("type"); - var obj = self.widgets_registry.get(widget_type); - var w = new (obj)(self.view, utils.xml_to_json($elem[0])); - self.to_replace.push([w, $elem]); - }); - }, - init_fields: function() { - var defs = []; - _.each(this.to_replace, function(el) { - defs.push(el[0].replace(el[1])); - if (el[1].children().length) { - el[0].$el.append(el[1].children()); - } - }); - this.to_replace = []; - return $.when.apply($, defs); - }, - render_element: function(template /* dictionaries */) { - var dicts = [].slice.call(arguments).slice(1); - var dict = _.extend.apply(_, dicts); - dict['classnames'] = dict['class'] || ''; // class is a reserved word and might caused problem to Safari when used from QWeb - return $(QWeb.render(template, dict)); - }, - alter_field: function(field) { - }, - toggle_layout_debugging: function() { - if (!this.$target.has('.oe_layout_debug_cell:first').length) { - this.$target.find('[title]').removeAttr('title'); - this.$target.find('.oe_form_group_cell').each(function() { - var text = 'W:' + ($(this).attr('width') || '') + ' - C:' + $(this).attr('colspan'); - $(this).attr('title', text); - }); - } - this.$target.toggleClass('oe_layout_debugging'); - }, - process: function($tag) { - var self = this; - var tagname = $tag[0].nodeName.toLowerCase(); - if (this.tags_registry.contains(tagname)) { - this.tags_to_init.push($tag); - return (tagname === 'button') ? this.process_button($tag) : $tag; - } - var fn = self['process_' + tagname]; - if (fn) { - var args = [].slice.call(arguments); - args[0] = $tag; - return fn.apply(self, args); - } else { - // generic tag handling, just process children - $tag.children().each(function() { - self.process($(this)); - }); - self.handle_common_properties($tag, $tag); - $tag.removeAttr("modifiers"); - return $tag; - } - }, - process_button: function ($button) { - var self = this; - $button.children().each(function() { - self.process($(this)); - }); - return $button; - }, - process_widget: function($widget) { - this.widgets_to_init.push($widget); - return $widget; - }, - process_sheet: function($sheet) { - var $new_sheet = this.render_element('FormRenderingSheet', $sheet.getAttributes()); - this.handle_common_properties($new_sheet, $sheet); - var $dst = $new_sheet.find('.o_form_sheet'); - $sheet.contents().appendTo($dst); - $sheet.before($new_sheet).remove(); - this.process($new_sheet); - }, - process_form: function($form) { - if ($form.find('> sheet').length === 0) { - $form.addClass('o_form_nosheet'); - } - var $new_form = this.render_element('FormRenderingForm', $form.getAttributes()); - this.handle_common_properties($new_form, $form); - $form.contents().appendTo($new_form); - if ($form[0] === this.$form[0]) { - // If root element, replace it - this.$form = $new_form; - } else { - $form.before($new_form).remove(); - } - this.process($new_form); - }, - /* - * Used by direct <field> children of a <group> tag only - * This method will add the implicit <label...> for every field - * in the <group> - */ - preprocess_field: function($field) { - var self = this; - var name = $field.attr('name'), - field_colspan = parseInt($field.attr('colspan'), 10), - field_modifiers = JSON.parse($field.attr('modifiers') || '{}'); - - if ($field.attr('nolabel') === '1') - return; - $field.attr('nolabel', '1'); - var found = false; - this.$form.find('label[for="' + name + '"]').each(function(i ,el) { - $(el).parents().each(function(unused, tag) { - var name = tag.tagName.toLowerCase(); - if (name === "field" || name in self.tags_registry.map) - found = true; - }); - }); - if (found) - return; - - var $label = $('<label/>').attr({ - 'for' : name, - "modifiers": JSON.stringify({invisible: field_modifiers.invisible}), - "string": $field.attr('string'), - "help": $field.attr('help'), - "class": $field.attr('class'), - }); - $label.insertBefore($field); - if (field_colspan > 1) { - $field.attr('colspan', field_colspan - 1); - } - return $label; - }, - process_field: function($field) { - if ($field.parent().is('group')) { - // No implicit labels for normal fields, only for <group> direct children - var $label = this.preprocess_field($field); - if ($label) { - this.process($label); - } - } - this.fields_to_init.push($field); - return $field; - }, - process_group: function($group) { - var self = this; - - // Processes a group containing nested groups - // Renders a div for each nested group, into a div that is the outer group - // The nested groups's width is a percentage according to col and colspan attributes - var _process_outer_group = function(col) { - var $new_group = self.render_element('FormRenderingOuterGroup', $group.getAttributes()); - $group.children().each(function() { - var $child = $(this); - var tagName = $child[0].tagName.toLowerCase(); - var colspan = parseInt($child.attr('colspan') || 1, 10); - - if (tagName === 'newline') { - $new_group.append($('<br>')); // Skip to the next line - return; - } - // compute child's classname from col and colspan attributes - $child.addClass('o_group_col_' + Math.min(Math.floor(colspan/col*12), 12)); - $new_group.append($child); - - children.push($child[0]); - }); - return $new_group; - }; - // Processes a group containing no nested group - // Renders an HTML table - var _process_inner_group = function(col) { - var $new_group = self.render_element('FormRenderingInnerGroup', $group.getAttributes()); - $new_group.find('td').attr('colspan', col); // Group's title spans all column - var row_col = col; - var $row = null; - - $group.children().each(function() { - var $child = $(this); - var tagName = $child[0].tagName.toLowerCase(); - var colspan = parseInt($child.attr('colspan') || 1, 10); - - if (tagName === 'newline') { - $row = null; // Start with a new row - return; - } - row_col -= colspan; - if (!$row || row_col < 0) { // Append a new row to the group - $row = $('<tr>').appendTo($new_group); - row_col = col-colspan; - } - $('<td>').attr('colspan', colspan).append($child).appendTo($row); - - children.push($child[0]); - }); - - // Some kind of an hack - // Add width on table's cell to avoid automatic layout of table which - // makes first col to take full width available (typically, first col contains label) - $new_group.find('tr').each(function() { - var $tr = $(this); - var nonlabel_width = 100/(col - $tr.find('td > label:first-child').length); - $tr.find('td').each(function() { - var $td = $(this); - var $child = $td.children(':first'); - if ($child.is("label")) { - $td.addClass("o_td_label"); - } else { - $td.css("width", (nonlabel_width*parseInt($td.attr('colspan')))+"%"); - } - }); - - }); - return $new_group; - }; - - // Preprocess field children - $group.children('field').each(function() { - self.preprocess_field($(this)); - }); - - // Process group - var nested_groups = $group.children('group').length; // Detect possible nested groups - var children = []; - var col = parseInt($group.attr('col') || 2, 10); - var $new_group; - if (nested_groups) { - $new_group = _process_outer_group(col); - } else { - $new_group = _process_inner_group(col); - } - this.handle_common_properties($new_group, $group); - - // Process group's children - $group.before($new_group).remove(); - _.each(children, function(el) { - self.process($(el)); - }); - - return $new_group; - }, - process_notebook: function($notebook) { - var self = this; - - // Extract useful info from the notebook xml declaration - var pages = []; - $notebook.find('> page').each(function() { - var $page = $(this); - var page_attrs = $page.getAttributes(); - page_attrs.id = _.uniqueId('notebook_page_'); - page_attrs.contents = $page.html(); - page_attrs.ref = $page; // reference to the current page node in the xml declaration - pages.push(page_attrs); - }); - - // Render the notebook and replace $notebook with it - var $new_notebook = self.render_element('FormRenderingNotebook', {'pages': pages}); - $notebook.before($new_notebook).remove(); - - // Bind the invisibility changers and find the page to display - var pageid_to_display; - _.each(pages, function(page) { - var $tab = $new_notebook.find('a[href=#' + page.id + ']').parent(); - var $content = $new_notebook.find('#' + page.id); - - // Case: <page autofocus="autofocus">; - // We attach the autofocus attribute to the node because it can be useful during the - // page execution - self.attach_node_attr($tab, page.ref, 'autofocus'); - self.attach_node_attr($content, page.ref, 'autofocus'); - if (!pageid_to_display && page.autofocus) { - // If multiple autofocus, keep the first one - pageid_to_display = page.id; - } - - // Case: <page attrs="{'invisible': domain}">; - self.handle_common_properties($tab, page.ref, common.NotebookInvisibilityChanger); - self.handle_common_properties($content, page.ref, common.NotebookInvisibilityChanger); - }); - if (!pageid_to_display) { - pageid_to_display = $new_notebook.find('div[role="tabpanel"]:not(.o_form_invisible):first').attr('id'); - } - - // Display page. Note: we can't use bootstrap's show function because it is looking for - // document attached DOM, and the form view is only attached when everything is processed - $new_notebook.find('a[href=#' + pageid_to_display + ']').parent().addClass('active'); - $new_notebook.find('#' + pageid_to_display).addClass('active'); - - self.process($new_notebook.children()); - self.handle_common_properties($new_notebook, $notebook); - - return $new_notebook; - }, - process_separator: function($separator) { - var $new_separator = this.render_element('FormRenderingSeparator', $separator.getAttributes()); - $separator.before($new_separator).remove(); - this.handle_common_properties($new_separator, $separator); - return $new_separator; - }, - process_label: function($label) { - var name = $label.attr("for"), - field_orm = this.fvg.fields[name]; - var dict = { - string: $label.attr('string') || (field_orm || {}).string || '', - help: $label.attr('help') || (field_orm || {}).help || '', - _for: name ? _.uniqueId('o_field_input_') : undefined, - }; - var $new_label = this.render_element('FormRenderingLabel', dict); - $label.before($new_label).remove(); - this.handle_common_properties($new_label, $label); - if (name) { - this.labels[name] = $new_label; - } - return $new_label; - }, - handle_common_properties: function($new_element, $node, InvisibilityChanger) { - var str_modifiers = $node.attr("modifiers") || "{}"; - var modifiers = JSON.parse(str_modifiers); - var ic = null; - if (modifiers.invisible !== undefined) { - var InvisibilityChangerCls = InvisibilityChanger || common.InvisibilityChanger; - ic = new InvisibilityChangerCls(this.view, this.view, modifiers.invisible, $new_element); - } - $new_element.addClass($node.attr("class") || ""); - $new_element.attr('style', $node.attr('style')); - return {invisibility_changer: ic,}; - }, - /** - * Attach a node attribute to an html element to be able to use it after - * the processing of the page. - */ - attach_node_attr: function($new_element, $node, attr) { - $new_element.data(attr, $node.attr(attr)); - } -}); - -}); diff --git a/addons/web/static/src/js/views/form_widgets.js b/addons/web/static/src/js/views/form_widgets.js deleted file mode 100644 index b644a79ba22..00000000000 --- a/addons/web/static/src/js/views/form_widgets.js +++ /dev/null @@ -1,1803 +0,0 @@ -odoo.define('web.form_widgets', function (require) { -"use strict"; - -var ajax = require('web.ajax'); -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var data = require('web.data'); -var datepicker = require('web.datepicker'); -var dom_utils = require('web.dom_utils'); -var Priority = require('web.Priority'); -var ProgressBar = require('web.ProgressBar'); -var Dialog = require('web.Dialog'); -var DomainSelector = require("web.DomainSelector"); -var DomainSelectorDialog = require("web.DomainSelectorDialog"); -var common = require('web.form_common'); -var formats = require('web.formats'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); -var pyeval = require('web.pyeval'); -var session = require('web.session'); -var utils = require('web.utils'); - -var _t = core._t; -var QWeb = core.qweb; - -var WidgetButton = common.FormWidget.extend({ - template: 'WidgetButton', - init: function(field_manager, node) { - node.attrs.type = node.attrs['data-button-type']; - this._super(field_manager, node); - this.force_disabled = false; - this.string = (this.node.attrs.string || '').replace(/_/g, ''); - if (JSON.parse(this.node.attrs.default_focus || "0")) { - // TODO fme: provide enter key binding to widgets - this.view.default_focus_button = this; - } - if (this.node.attrs.icon) { - this.fa_icon = this.node.attrs.icon.indexOf('fa-') === 0; - } - }, - start: function() { - this._super.apply(this, arguments); - this.view.on('view_content_has_changed', this, this.check_disable); - this.check_disable(); - this.$el.click(this.on_click); - if (this.node.attrs.help || session.debug) { - this.do_attach_tooltip(); - } - this.setupFocus(this.$el); - }, - on_click: function() { - var self = this; - if (this.view.is_disabled) { - return; - } - this.force_disabled = true; - this.check_disable(); - this.view.disable_button(); - this.execute_action().always(function() { - self.view.enable_button(); - self.force_disabled = false; - self.check_disable(); - if (self.$el.hasClass('o_wow')) { - self.show_wow(); - } - }); - }, - execute_action: function() { - var self = this; - var exec_action = function() { - if (self.node.attrs.confirm) { - var def = $.Deferred(); - Dialog.confirm(self, self.node.attrs.confirm, { confirm_callback: self.on_confirmed }) - .on("closed", null, function() { def.resolve(); }); - return def.promise(); - } else { - return self.on_confirmed(); - } - }; - if (!this.node.attrs.special) { - return this.view.recursive_save().then(exec_action); - } else { - return exec_action(); - } - }, - on_confirmed: function() { - var self = this; - var context = this.build_context(); - return this.view.do_execute_action( - _.extend({}, this.node.attrs, {context: context}), - this.view.dataset, this.view.datarecord.id, function (reason) { - if (!_.isObject(reason)) { - self.view.recursive_reload(); - } - }).fail(function () { - self.view.recursive_reload(); - }); - }, - check_disable: function() { - var disabled = (this.force_disabled || !this.view.is_interactible_record()); - this.$el.prop('disabled', disabled); - this.$el.css('color', disabled ? 'grey' : ''); - }, - show_wow: function() { - var class_to_add = 'o_wow_thumbs'; - if (Math.random() > 0.9) { - var other_classes = ['o_wow_peace', 'o_wow_heart']; - class_to_add = other_classes[Math.floor(Math.random()*other_classes.length)]; - } - - var $body = $('body'); - $body.addClass(class_to_add); - setTimeout(function() { - $body.removeClass(class_to_add); - }, 1000); - } -}); - -var FieldChar = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: 'FieldChar', - events: { - 'change': 'store_dom_value', - }, - init: function (field_manager, node) { - this._super(field_manager, node); - this.password = this.node.attrs.password === 'True' || this.node.attrs.password === '1'; - }, - initialize_content: function() { - if(!this.get('effective_readonly') && !this.$input) { - this.$input = this.$el; - } - this.setupFocus(this.$el); - }, - destroy_content: function() { - this.$input = undefined; - }, - store_dom_value: function () { - if (this.$input && this.is_syntax_valid()) { - this.internal_set_value(this.parse_value(this.$input.val())); - } - }, - commit_value: function () { - this.store_dom_value(); - return this._super(); - }, - render_value: function() { - var show_value = this.format_value(this.get('value'), ''); - if (this.$input) { - this.$input.val(show_value); - } else { - if (this.password) { - show_value = new Array(show_value.length + 1).join('*'); - } - this.$el.text(show_value); - } - }, - is_syntax_valid: function() { - if (this.$input) { - try { - this.parse_value(this.$input.val(), ''); - } catch(e) { - return false; - } - } - return true; - }, - parse_value: function(val, def) { - return formats.parse_value(val, this, def); - }, - format_value: function(val, def) { - return formats.format_value(val, this, def); - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function() { - if (this.$input) { - return this.$input.focus(); - } - return false; - }, -}); - -var KanbanSelection = common.AbstractField.extend({ - template: "FormSelection", - events: { - 'click a': function(e) { - e.preventDefault(); - }, - 'mouseup a': function(e) { - e.stopPropagation(); - }, - 'click li': 'set_kanban_selection' - }, - start: function () { - // hook on form view content changed: recompute the states, because it may be related to the current stage - this.view.on('view_content_has_changed', this, function () { - this.render_value(); - }); - return this._super(); - }, - prepare_dropdown_selection: function() { - var self = this; - var _data = []; - var current_stage_id = self.view.datarecord.stage_id && self.view.datarecord.stage_id[0]; - var stage_data = { - id: current_stage_id, - legend_normal: self.view.datarecord.legend_normal || undefined, - legend_blocked : self.view.datarecord.legend_blocked || undefined, - legend_done: self.view.datarecord.legend_done || undefined, - }; - _.map(self.field.selection || [], function(selection_item) { - var value = { - 'name': selection_item[0], - 'tooltip': selection_item[1], - }; - if (selection_item[0] === 'normal') { - value.state_name = stage_data.legend_normal ? stage_data.legend_normal : selection_item[1]; - } else if (selection_item[0] === 'done') { - value.state_class = 'oe_kanban_status_green'; - value.state_name = stage_data.legend_done ? stage_data.legend_done : selection_item[1]; - } else { - value.state_class = 'oe_kanban_status_red'; - value.state_name = stage_data.legend_blocked ? stage_data.legend_blocked : selection_item[1]; - } - _data.push(value); - }); - return _data; - }, - render_value: function() { - this._super(); - this.states = this.prepare_dropdown_selection(); - var self = this; - - // Adapt "FormSelection" - var current_state = _.find(this.states, function(state) { - return state.name === self.get('value'); - }); - this.$('.oe_kanban_status') - .removeClass('oe_kanban_status_red oe_kanban_status_green') - .addClass(current_state.state_class); - - // Render "FormSelection.Items" and move it into "FormSelection" - var $items = $(QWeb.render('FormSelection.items', { - states: _.without(this.states, current_state) - })); - var $dropdown = this.$el.find('.dropdown-menu'); - $dropdown.children().remove(); // remove old items - $items.appendTo($dropdown); - }, - /* setting the value: in view mode, perform an asynchronous call and reload - the form view; in edit mode, use set_value to save the new value that will - be written when saving the record. */ - set_kanban_selection: function (ev) { - var self = this; - ev.preventDefault(); - var li = $(ev.target).closest('li'); - if (li.length) { - var value = String(li.data('value')); - if (this.view.get('actual_mode') === 'view') { - var write_values = {}; - write_values[self.name] = value; - return this.view.dataset._model.call( - 'write', [ - [this.view.datarecord.id], - write_values, - self.view.dataset.get_context() - ]).done(self.reload_record.bind(self)); - } - else { - return this.set_value(value); - } - } - }, - reload_record: function() { - this.view.reload(); - }, -}); - -var FieldPriority = common.AbstractField.extend({ - events: { - 'mouseup': function(e) { - e.stopPropagation(); - }, - }, - start: function() { - this.priority = new Priority(this, { - readonly: this.get('readonly'), - value: this.get('value'), - values: this.field.selection || [], - }); - - this.priority.on('update', this, function(update) { - /* setting the value: in view mode, perform an asynchronous call and reload - the form view; in edit mode, use set_value to save the new value that will - be written when saving the record. */ - var view = this.view; - if(view.get('actual_mode') === 'view') { - var write_values = {}; - write_values[this.name] = update.value; - view.dataset._model.call('write', [ - [view.datarecord.id], - write_values, - view.dataset.get_context() - ]).done(function() { - view.reload(); - }); - } else { - this.set_value(update.value); - } - }); - - this.on('change:readonly', this, function() { - this.priority.readonly = this.get('readonly'); - var $div = $('<div/>').insertAfter(this.$el); - this.priority.replace($div); - this.setElement(this.priority.$el); - }); - - var self = this; - return $.when(this._super(), this.priority.appendTo('<div>').then(function() { - self.priority.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.priority.$el); - })); - }, - is_false: function() { - return false; - }, - render_value: function() { - this.priority.set_value(this.get('value')); - }, -}); - -var FieldID = FieldChar.extend({ - process_modifiers: function () { - this._super(); - this.set({ readonly: true }); - }, -}); - -var FieldEmail = FieldChar.extend({ - template: 'FieldEmail', - prefix: 'mailto', - init: function() { - this._super.apply(this, arguments); - this.clickable = true; - }, - render_value: function() { - this._super(); - if (this.get("effective_readonly") && this.clickable) { - this.$el.attr('href', this.prefix + ':' + this.get('value')); - } - } -}); - -var FieldUrl = FieldEmail.extend({ - render_value: function() { - this._super(); - if(this.get("effective_readonly")) { - var tmp = this.get('value'); - var s = /(\w+):(.+)|^\.{0,2}\//.exec(tmp); - if (!s) { - tmp = "http://" + this.get('value'); - } - var text = this.get('value') ? this.node.attrs.text || tmp : ''; - this.$el.attr('href', tmp).text(text); - } - } -}); - -var FieldFloat = FieldChar.extend({ - init: function (field_manager, node) { - this._super(field_manager, node); - this.internal_set_value(0); - if (this.node.attrs.digits) { - this.digits = this.node.attrs.digits; - } else { - this.digits = this.field.digits; - } - }, - initialize_content: function() { - this._super(); - this.$el.addClass('o_form_field_number'); - }, - set_value: function(value_) { - if (value_ === false || value_ === undefined) { - value_ = 0; // As in GTK client, floats default to 0 - } - if (this.digits !== undefined && this.digits.length === 2) { - value_ = utils.round_decimals(value_, this.digits[1]); - } - this._super(value_); - } -}); - -/// The "Domain" field allows the user to construct a technical-prefix domain thanks to -/// a tree-like interface and see the selected records in real time. -/// In debug mode, an input is also there to be able to enter the prefix char domain -/// directly (or to build advanced domains the tree-like interface does not allow to). -var FieldDomain = common.AbstractField.extend(common.ReinitializeFieldMixin).extend({ - template: "FieldDomain", - events: { - "click .o_domain_show_selection_button": function (e) { - e.preventDefault(); - this._showSelection(); - }, - "click .o_form_field_domain_dialog_button": function (e) { - e.preventDefault(); - this.openDomainDialog(); - }, - }, - custom_events: { - "domain_changed": function (e) { - if (this.options.in_dialog) return; - this.set_value(this.domainSelector.getDomain(), true); - }, - "domain_selected": function (e) { - this.set_value(e.data.domain); - }, - }, - init: function () { - this._super.apply(this, arguments); - - this.valid = true; - this.debug = session.debug; - this.options = _.defaults(this.options || {}, { - in_dialog: false, - model: undefined, // this option is mandatory ! - fs_filters: {}, // Field selector filters (to only show a subset of available fields @see FieldSelector) - }); - }, - start: function() { - this.model = _get_model.call(this); // TODO get the model another way ? - this.field_manager.on("view_content_has_changed", this, function () { - var currentModel = this.model; - this.model = _get_model.call(this); - if (currentModel !== this.model) { - this.render_value(); - } - }); - - return this._super.apply(this, arguments); - - function _get_model() { - if (this.field_manager.fields[this.options.model]) { - return this.field_manager.get_field_value(this.options.model); - } - return this.options.model; - } - }, - initialize_content: function () { - this._super.apply(this, arguments); - this.$panel = this.$(".o_form_field_domain_panel"); - this.$showSelectionButton = this.$panel.find(".o_domain_show_selection_button"); - this.$recordsCountDisplay = this.$showSelectionButton.find(".o_domain_records_count"); - this.$errorMessage = this.$panel.find(".o_domain_error_message"); - this.$modelMissing = this.$(".o_domain_model_missing"); - }, - set_value: function (value, noDomainSelectorRender) { - this._noDomainSelectorRender = !!noDomainSelectorRender; - this._super.apply(this, arguments); - this._noDomainSelectorRender = false; - }, - render_value: function() { - this._super.apply(this, arguments); - - // If there is no set model, the field should only display the corresponding error message - this.$panel.toggleClass("o_hidden", !this.model); - this.$modelMissing.toggleClass("o_hidden", !!this.model); - if (!this.model) { - if (this.domainSelector) { - this.domainSelector.destroy(); - this.domainSelector = undefined; - } - return; - } - - var domain = pyeval.eval("domain", this.get("value") || "[]"); - - // Recreate domain widget with new domain value - if (!this._noDomainSelectorRender) { - if (this.domainSelector) { - this.domainSelector.destroy(); - } - this.domainSelector = new DomainSelector(this, this.model, domain, { - readonly: this.get("effective_readonly") || this.options.in_dialog, - fs_filters: this.options.fs_filters, - debugMode: session.debug, - }); - this.domainSelector.prependTo(this.$el); - } - - // Show number of selected records - new Model(this.model).call("search_count", [domain], { - context: this.build_context(), - }).then((function (data) { - this.valid = true; - return data; - }).bind(this), (function (error, e) { - e.preventDefault(); - this.valid = false; - }).bind(this)).always((function (data) { - this.$recordsCountDisplay.text(data || 0); - this.$showSelectionButton.toggleClass("hidden", !this.valid); - this.$errorMessage.toggleClass("hidden", this.valid); - }).bind(this)); - }, - is_syntax_valid: function() { - return this.field_manager.get("actual_mode") === "view" || this.valid; - }, - _showSelection: function() { - return new common.SelectCreateDialog(this, { - title: _t("Selected records"), - res_model: this.model, - domain: this.get("value") || "[]", - no_create: true, - readonly: true, - disable_multiple_selection: true, - }).open(); - }, - openDomainDialog: function () { - new DomainSelectorDialog(this, this.model, this.get("value") || "[]", { - readonly: this.get("effective_readonly"), - fs_filters: this.options.fs_filters, - debugMode: session.debug, - }).open(); - }, -}); - -var FieldDate = common.AbstractField.extend(common.ReinitializeFieldMixin, { - tagName: "span", - className: "o_form_field_date", - build_widget: function() { - return new datepicker.DateWidget(this); - }, - initialize_content: function() { - if (this.datewidget) { - this.datewidget.destroy(); - this.datewidget = undefined; - } - - if (!this.get("effective_readonly")) { - this.datewidget = this.build_widget(); - this.datewidget.on('datetime_changed', this, function() { - this.internal_set_value(this.datewidget.get_value()); - }); - - var self = this; - this.datewidget.appendTo('<div>').done(function() { - self.datewidget.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.datewidget.$el); - self.datewidget.$input.addClass('o_form_input'); - self.setupFocus(self.datewidget.$input); - }); - } - }, - render_value: function() { - if (this.get("effective_readonly")) { - this.$el.text(formats.format_value(this.get('value'), this, '')); - } else { - this.datewidget.set_value(this.get('value')); - } - }, - is_syntax_valid: function() { - return this.get("effective_readonly") || !this.datewidget || this.datewidget.is_valid(); - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function() { - if (!this.get("effective_readonly")) { - return this.datewidget.$input.focus(); - } - return false; - }, -}); - -var FieldDatetime = FieldDate.extend({ - build_widget: function() { - return new datepicker.DateTimeWidget(this); - }, -}); - -var FieldText = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: 'FieldText', - events: { - 'keyup': function (e) { - if (e.which === $.ui.keyCode.ENTER) { - e.stopPropagation(); - } - }, - 'keypress': function (e) { - if (e.which === $.ui.keyCode.ENTER) { - e.stopPropagation(); - } - }, - 'change': 'store_dom_value', - }, - initialize_content: function() { - if (!this.get("effective_readonly")) { - this.auto_sized = false; - this.setupFocus(this.$el); - } - }, - commit_value: function () { - if (!this.get("effective_readonly")) { - this.store_dom_value(); - } - return this._super(); - }, - store_dom_value: function () { - this.internal_set_value(formats.parse_value(this.$el.val(), this)); - }, - render_value: function() { - if (this.get("effective_readonly")) { - var txt = this.get("value") || ''; - this.$el.text(txt); - } else { - var show_value = formats.format_value(this.get('value'), this, ''); - this.$el.val(show_value); - dom_utils.autoresize(this.$el, {parent: this}); - } - }, - is_syntax_valid: function() { - if (!this.get("effective_readonly")) { - try { - formats.parse_value(this.$el.val(), this, ''); - } catch(e) { - return false; - } - } - return true; - }, - is_false: function() { - return this.get('value') === '' || this._super(); - }, - focus: function($el) { - if(!this.get("effective_readonly")) { - return this.$el.focus(); - } - return false; - }, - set_dimensions: function(height, width) { - this.$el.css({ - width: width, - minHeight: height, - }); - }, -}); - -var FieldBoolean = common.AbstractField.extend({ - template: 'FieldBoolean', - events: { - 'click': function() { - this.internal_set_value(this.$checkbox.prop('checked')); - } - }, - start: function() { - this.$checkbox = this.$('input'); - - this.$checkbox.prop('disabled', this.get("effective_readonly")); - this.on("change:effective_readonly", this, function() { - this.$checkbox.prop('disabled', this.get("effective_readonly")); - }); - - this.setupFocus(this.$checkbox); - - return this._super(); - }, - render_value: function() { - this.$checkbox.prop('checked', this.get('value')); - }, - focus: function() { - return this.$checkbox.focus(); - }, - set_dimensions: function(height, width) {}, // Checkboxes have a fixed height and width (even in list editable) - is_false: function() { - return false; - }, -}); - -/** - This widget is intended to be used on stat button boolean fields. - It is a read-only field that will display a simple string "<label of value>". -*/ -var FieldBooleanButton = common.AbstractField.extend({ - className: 'o_stat_info', - init: function() { - this._super.apply(this, arguments); - switch (this.options["terminology"]) { - case "active": - this.string_true = _t("Active"); - this.hover_true = _t("Deactivate"); - this.string_false = _t("Inactive"); - this.hover_false = _t("Activate"); - break; - case "archive": - this.string_true = _t("Active"); - this.hover_true = _t("Archive"); - this.string_false = _t("Archived"); - this.hover_false = _t("Restore"); - break; - case "close": - this.string_true = _t("Active"); - this.string_false = _t("Closed"); - this.hover_true = _t("Close"); - this.hover_false = _t("Open"); - break; - default: - var opt_terms = this.options["terminology"] || {}; - if (typeof opt_terms === 'string') { - opt_terms = {}; //unsupported terminology - } - this.string_true = _t(opt_terms.string_true) || _t("On"); - this.hover_true = _t(opt_terms.hover_true) || _t("Switch Off"); - this.string_false = _t(opt_terms.string_false) || _t("Off"); - this.hover_false = _t(opt_terms.hover_false) || _t("Switch On"); - } - }, - render_value: function() { - this._super(); - this.$el.html(QWeb.render("BooleanButton", {widget: this})); - }, - is_false: function() { - return false; - }, -}); - -// The progressbar field expects a float from 0 to 100. -var FieldProgressBar = common.AbstractField.extend(common.ReinitializeFieldMixin, { - initialize_content: function() { - if(this.progressbar) { - this.progressbar.destroy(); - } - - this.progressbar = new ProgressBar(this, { - readonly: this.get('effective_readonly'), - edit_on_click: true, - value: this.get('value') || 0, - }); - - var self = this; - this.progressbar.appendTo('<div>').done(function() { - self.progressbar.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.progressbar.$el); - - self.progressbar.on('update', self, function(update) { - self.set('value', update.changed_value); - }); - }); - }, - render_value: function() { - this.progressbar.set_value(this.get('value')); - }, - is_false: function() { - return false; - }, -}); - -// The PercentPie field expects a float from 0 to 100. -var FieldPercentPie = common.AbstractField.extend({ - template: 'FieldPercentPie', - start: function() { - this.$left_mask = this.$('.o_mask').first(); - this.$right_mask = this.$('.o_mask').last(); - this.$pie_value = this.$('.o_pie_value'); - return this._super(); - }, - render_value: function() { - var value = this.get('value') || 0, degValue = 360*value/100; - - this.$right_mask.toggleClass('o_full', degValue >= 180); - - var leftDeg = 'rotate(' + ((degValue < 180)? 180 : degValue) + 'deg)'; - var rightDeg = 'rotate(' + ((degValue < 180)? degValue : 0) + 'deg)'; - this.$left_mask.css({transform: leftDeg, msTransform: leftDeg, mozTransform: leftDeg, webkitTransform: leftDeg}); - this.$right_mask.css({transform: rightDeg, msTransform: rightDeg, mozTransform: rightDeg, webkitTransform: rightDeg}); - - this.$pie_value.html(Math.round(value) + '%'); - }, - is_false: function() { - return false; - } -}); - -var FieldSelection = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: 'FieldSelection', - events: { - 'change': 'store_dom_value', - }, - init: function(field_manager, node) { - this._super(field_manager, node); - this.set("value", false); - this.set("values", []); - this.records_orderer = new utils.DropMisordered(); - this.field_manager.on("view_content_has_changed", this, function() { - var domain = new data.CompoundDomain(this.build_domain()).eval(); - if (!_.isEqual(domain, this.get("domain"))) { - this.set("domain", domain); - } - }); - }, - initialize_field: function() { - common.ReinitializeFieldMixin.initialize_field.call(this); - this.on("change:domain", this, this.query_values); - this.set("domain", new data.CompoundDomain(this.build_domain()).eval()); - this.on("change:values", this, this.render_value); - }, - query_values: function() { - var self = this; - var def; - if (this.field.type === "many2one") { - var model = new Model(this.field.relation); - def = model.call("name_search", ['', this.get("domain")], {"context": this.build_context()}); - } else { - var values = _.reject(this.field.selection, function (v) { return v[0] === false && v[1] === ''; }); - def = $.when(values); - } - this.records_orderer.add(def).then(function(values) { - if (! _.isEqual(values, self.get("values"))) { - self.set("values", values); - } - }); - }, - initialize_content: function() { - // Flag indicating whether we're in an event chain containing a change - // event on the select, in order to know what to do on keyup[RETURN]: - // * If the user presses [RETURN] as part of changing the value of a - // selection, we should just let the value change and not let the - // event broadcast further (e.g. to validating the current state of - // the form in editable list view, which would lead to saving the - // current row or switching to the next one) - // * If the user presses [RETURN] with a select closed (side-effect: - // also if the user opened the select and pressed [RETURN] without - // changing the selected value), takes the action as validating the row - if(!this.get('effective_readonly')) { - var ischanging = false; - this.$el - .change(function () { ischanging = true; }) - .click(function () { ischanging = false; }) - .keyup(function (e) { - if (e.which !== 13 || !ischanging) { return; } - e.stopPropagation(); - ischanging = false; - }); - this.setupFocus(this.$el); - } - }, - commit_value: function () { - this.store_dom_value(); - return this._super(); - }, - store_dom_value: function () { - if (!this.get('effective_readonly')) { - this.internal_set_value(JSON.parse(this.$el.val())); - } - }, - set_value: function(value_) { - value_ = value_ === null ? false : value_; - value_ = value_ instanceof Array ? value_[0] : value_; - this._super(value_); - }, - render_value: function() { - var values = this.get("values"); - values = [[false, this.node.attrs.placeholder || '']].concat(values); - var found = _.find(values, function(el) { return el[0] === this.get("value"); }, this); - if (!found) { - found = [this.get("value"), _t('Unknown')]; - values = [found].concat(values); - } - if (!this.get("effective_readonly")) { - this.$el.empty(); - for(var i = 0 ; i < values.length ; i++) { - this.$el.append($('<option/>', { - value: JSON.stringify(values[i][0]), - html: values[i][1] - })) - } - this.$el.val(JSON.stringify(found[0])); - } else { - this.$el.text(found[1]); - } - }, - focus: function() { - if (!this.get("effective_readonly")) { - return this.$el.focus(); - } - return false; - }, -}); - -/** - This widget is intended to display a warning near a label of a 'timezone' field - indicating if the browser timezone is identical (or not) to the selected timezone. - This widget depends on a field given with the param 'tz_offset_field', which contains - the time difference between UTC time and local time, in minutes. -*/ -var TimezoneMismatch = FieldSelection.extend({ - initialize_content: function() { - this._super.apply(this, arguments); - this.tz_offset_field = (this.options && this.options.tz_offset_field) || this.tz_offset_field || 'tz_offset'; - this.set({"tz_offset": this.field_manager.get_field_value(this.tz_offset_field)}); - this.on("change:tz_offset", this, this.render_value); - }, - start: function() { - this._super.apply(this, arguments); - // trigger a render_value when tz_offset field change - this.field_manager.on("field_changed:" + this.tz_offset_field, this, function() { - this.set({"tz_offset": this.field_manager.get_field_value(this.tz_offset_field)}); - }); - }, - check_timezone: function() { - var user_offset = this.get('tz_offset'); - if (user_offset) { - var offset = -(new Date().getTimezoneOffset()); - var browser_offset = (offset < 0) ? "-" : "+"; - browser_offset += _.str.sprintf("%02d", Math.abs(offset / 60)); - browser_offset += _.str.sprintf("%02d", Math.abs(offset % 60)); - return (browser_offset !== user_offset); - } - return false; - }, - render_value: function() { - this._super.apply(this, arguments); - this.$label.next('.o_tz_warning').remove(); - if(this.check_timezone()){ - var options = _.extend({ - delay: { show: 501, hide: 0 }, - title: _t("Timezone Mismatch : The timezone of your browser doesn't match the selected one. The time in Odoo is displayed according to your field timezone."), - }); - $('<span/>').addClass('fa fa-exclamation-triangle o_tz_warning').insertAfter(this.$label).tooltip(options); - } - } -}); - -var LabelSelection = FieldSelection.extend({ - init: function(field_manager, node) { - this._super(field_manager, node); - this.classes = this.options && this.options.classes || {}; - }, - render_value: function() { - this._super.apply(this, arguments); - if (this.get("effective_readonly")) { - var bt_class = this.classes[this.get('value')] || 'default'; - this.$el.html($('<span/>', {html: this.$el.html()}).addClass('label label-' + bt_class)); - } - }, -}); - -var FieldRadio = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: 'FieldRadio', - events: { - 'click input': 'click_change_value' - }, - init: function(field_manager, node) { - /* Radio button widget: Attributes options: - * - "horizontal" to display in column - * - "no_radiolabel" don't display text values - */ - this._super(field_manager, node); - this.selection = _.clone(this.field.selection) || []; - this.domain = false; - this.uniqueId = _.uniqueId("radio"); - }, - initialize_content: function () { - this.field_manager.on("view_content_has_changed", this, this.get_selection); - this.get_selection(); - }, - click_change_value: function (event) { - var val = $(event.target).val(); - val = this.field.type == "selection" ? val : +val; - if (val !== this.get_value()) { - this.set_value(val); - } - }, - /** Get the selection and render it - * selection: [[identifier, value_to_display], ...] - * For selection fields: this is directly given by this.field.selection - * For many2one fields: perform a search on the relation of the many2one field - */ - get_selection: function() { - var self = this; - var selection = []; - var def = $.Deferred(); - if (self.field.type == "many2one") { - var domain = pyeval.eval('domain', this.build_domain()) || []; - if (! _.isEqual(self.domain, domain)) { - self.domain = domain; - var ds = new data.DataSetStatic(self, self.field.relation, self.build_context()); - ds.call('search', [self.domain]) - .then(function (records) { - ds.name_get(records).then(function (records) { - selection = records; - def.resolve(); - }); - }); - } else { - selection = self.selection; - def.resolve(); - } - } - else if (self.field.type == "selection") { - selection = self.field.selection || []; - def.resolve(); - } - return def.then(function () { - if (!_.isEqual(selection, self.selection)) { - self.selection = _.clone(selection); - self.renderElement(); - self.render_value(); - } - }); - }, - set_value: function (value_) { - if (this.field.type == "selection") { - value_ = _.find(this.field.selection, function (sel) { return sel[0] == value_;}); - } - else if (!this.selection.length) { - this.selection = [value_]; - } - - this._super(value_); - }, - get_value: function () { - var value = this.get('value'); - value = ((value instanceof Array)? value[0] : value); - return _.isUndefined(value) ? false : value; - }, - render_value: function () { - var self = this; - if(this.get('effective_readonly')) { - this.$el.html(this.get('value')? this.get('value')[1] : ""); - } else { - this.$("input").prop("checked", false).filter(function () {return this.value == self.get_value();}).prop("checked", true); - } - } -}); - -var FieldReference = common.AbstractField.extend(common.ReinitializeFieldMixin, { - className: 'o_row', - init: function(field_manager, node) { - this._super(field_manager, node); - this.reference_ready = true; - }, - destroy_content: function() { - if (this.fm) { - this.fm.destroy(); - this.fm = undefined; - } - }, - initialize_content: function() { - var self = this; - this.fm = new common.DefaultFieldManager(this); - this.fm.extend_field_desc({ - "selection": { - selection: this.field_manager.get_field_desc(this.name).selection, - type: "selection", - }, - "m2o": { - relation: null, - type: "many2one", - }, - }); - this.selection = new FieldSelection(this.fm, { attrs: { - name: 'selection', - modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), - }}); - this.selection.on("change:value", this, this.on_selection_changed); - this.selection.appendTo(this.$el); - this.selection - .on('focused', null, function () {self.trigger('focused');}) - .on('blurred', null, function () {self.trigger('blurred');}); - - var FieldMany2One = core.form_widget_registry.get('many2one'); - this.m2o = new FieldMany2One(this.fm, { attrs: { - name: 'Referenced Document', - modifiers: JSON.stringify({readonly: this.get('effective_readonly')}), - context: this.build_context().eval(), - }}); - this.m2o.on("change:value", this, this.data_changed); - this.m2o.appendTo(this.$el); - this.m2o - .on('focused', null, function () {self.trigger('focused');}) - .on('blurred', null, function () {self.trigger('blurred');}); - }, - on_selection_changed: function() { - if (this.reference_ready) { - this.internal_set_value([this.selection.get_value(), false]); - this.render_value(); - } - }, - data_changed: function() { - if (this.reference_ready) { - this.internal_set_value([this.selection.get_value(), this.m2o.get_value()]); - } - }, - set_value: function(val) { - if (val) { - val = val.split(','); - val[0] = val[0] || false; - val[1] = val[0] ? (val[1] ? parseInt(val[1], 10) : val[1]) : false; - } - this._super(val || [false, false]); - }, - get_value: function() { - return this.get('value')[0] && this.get('value')[1] ? (this.get('value')[0] + ',' + this.get('value')[1]) : false; - }, - render_value: function() { - this.reference_ready = false; - if (!this.get("effective_readonly")) { - this.selection.set_value(this.get('value')[0]); - } - this.m2o.field.relation = this.get('value')[0]; - this.m2o.set_value(this.get('value')[1]); - this.m2o.do_toggle(!!this.get('value')[0]); - this.reference_ready = true; - }, - is_false: function() { - return !this.get_value(); - }, -}); - -var FieldBinary = common.AbstractField.extend(common.ReinitializeFieldMixin, { - init: function(field_manager, node) { - var self = this; - this._super(field_manager, node); - this.binary_value = false; - this.useFileAPI = !!window.FileReader; - try { - this.max_file_size = utils.parse_human_size(this.options.max_file_size); - } catch(e) { - this.max_file_size = 0; - } - if (!this.useFileAPI) { - this.fileupload_id = _.uniqueId('o_fileupload'); - $(window).on(this.fileupload_id, function() { - var args = [].slice.call(arguments).slice(1); - self.on_file_uploaded.apply(self, args); - }); - } - }, - stop: function() { - if (!this.useFileAPI) { - $(window).off(this.fileupload_id); - } - this._super.apply(this, arguments); - }, - initialize_content: function() { - this.$inputFile = this.$('.o_form_input_file'); - this.$inputFile.change(this.on_file_change); - var self = this; - this.$('.o_select_file_button').click(function() { - self.$inputFile.click(); - }); - this.$('.o_clear_file_button').click(this.on_clear); - }, - on_file_change: function(e) { - var self = this; - var file_node = e.target; - if ((this.useFileAPI && file_node.files.length) || (!this.useFileAPI && $(file_node).val() !== '')) { - if (this.useFileAPI) { - var file = file_node.files[0]; - if (this.max_file_size && file.size > this.max_file_size) { - var msg = _t("The selected file exceed the maximum file size of %s."); - this.do_warn(_t("File upload"), _.str.sprintf(msg, utils.human_size(this.max_file_size))); - return false; - } - var filereader = new FileReader(); - filereader.readAsDataURL(file); - filereader.onloadend = function(upload) { - var data = upload.target.result; - data = data.split(',')[1]; - self.on_file_uploaded(file.size, file.name, file.type, data); - }; - } else { - this.$('form.o_form_binary_form input[name=session_id]').val(this.session.session_id); - this.$('form.o_form_binary_form').submit(); - } - this.$('.o_form_binary_progress').show(); - this.$('button').hide(); - } - }, - on_file_uploaded: function(size, name) { - if (size === false) { - this.do_warn(_t("File Upload"), _t("There was a problem while uploading your file")); - // TODO: use openerp web crashmanager - console.warn("Error while uploading file : ", name); - } else { - this.on_file_uploaded_and_valid.apply(this, arguments); - } - this.$('.o_form_binary_progress').hide(); - this.$('button').show(); - }, - on_file_uploaded_and_valid: function(size, name, content_type, file_base64) { - this.binary_value = true; - this.set_filename(name); - this.set_value(file_base64); - }, - on_save_as: function(ev) { - var value = this.get('value'); - if (!value) { - this.do_warn(_t("Save As..."), _t("The field is empty, there's nothing to save !")); - ev.stopPropagation(); - } else { - framework.blockUI(); - var c = crash_manager; - var filename_fieldname = this.node.attrs.filename; - var filename_field = this.view.fields && this.view.fields[filename_fieldname]; - this.session.get_file({ - 'url': '/web/content', - 'data': { - 'model': this.view.dataset.model, - 'id': this.view.datarecord.id, - 'field': this.name, - 'filename_field': filename_fieldname, - 'filename': filename_field ? filename_field.get('value') : null, - 'download': true, - 'data': utils.is_bin_size(value) ? null : value, - }, - 'complete': framework.unblockUI, - 'error': c.rpc_error.bind(c) - }); - ev.stopPropagation(); - } - }, - set_filename: function(value) { - var filename = this.node.attrs.filename; - if (filename) { - var field = this.field_manager.fields[filename]; - if (field) { - field.set_value(value); - field._dirty_flag = true; - } - } - }, - on_clear: function() { - this.binary_value = false; - this.set_filename(''); - this.set_value(false); // FIXME do not really remove the value - } -}); - -var FieldBinaryFile = FieldBinary.extend({ - template: 'FieldBinaryFile', - initialize_content: function() { - var self = this; - this._super(); - if (this.get("effective_readonly")) { - this.$el.click(function(ev) { - if (self.get('value')) { - self.on_save_as(ev); - } - return false; - }); - } else { - this.$input = this.$('.o_form_input').eq(0); - this.$input.on('click', function() { - self.$inputFile.click(); - }); - } - }, - render_value: function() { - var filename = this.view.datarecord[this.node.attrs.filename]; - if (this.get("effective_readonly")) { - this.do_toggle(!!this.get('value')); - if (this.get('value')) { - this.$el.empty().append($("<span/>").addClass('fa fa-download')); - if (filename) { - this.$el.append(" " + filename); - } - } - } else { - if(this.get('value')) { - this.$el.children().removeClass('o_hidden'); - this.$('.o_select_file_button').first().addClass('o_hidden'); - this.$input.val(filename || this.get('value')); - } else { - this.$el.children().addClass('o_hidden'); - this.$('.o_select_file_button').first().removeClass('o_hidden'); - } - } - } -}); - -var FieldBinaryImage = FieldBinary.extend({ - template: 'FieldBinaryImage', - placeholder: "/web/static/src/img/placeholder.png", - render_value: function() { - var url = this.placeholder; - if(this.get('value')) { - if(!utils.is_bin_size(this.get('value'))) { - url = 'data:image/png;base64,' + this.get('value'); - } else { - url = session.url('/web/image', { - model: this.view.dataset.model, - id: JSON.stringify(this.view.datarecord.id || null), - field: (this.options.preview_image)? this.options.preview_image : this.name, - unique: (this.view.datarecord.__last_update || '').replace(/[^0-9]/g, ''), - }); - } - } - - var $img = $(QWeb.render("FieldBinaryImage-img", {widget: this, url: url})); - - var self = this; - $img.click(function(e) { - if(self.view.get("actual_mode") == "view") { - var $button = $(".o_form_button_edit"); - $button.openerpBounce(); - e.stopPropagation(); - } - }); - this.$('> img').remove(); - if (self.options.size) { - $img.css("width", "" + self.options.size[0] + "px"); - $img.css("height", "" + self.options.size[1] + "px"); - } - this.$el.prepend($img); - $img.on('error', function() { - self.on_clear(); - $img.attr('src', self.placeholder); - self.do_warn(_t("Image"), _t("Could not display the selected image.")); - }); - }, - set_value: function(value_) { - var changed = value_ !== this.get_value(); - this._super.apply(this, arguments); - // By default, on binary images read, the server returns the binary size - // This is possible that two images have the exact same size - // Therefore we trigger the change in case the image value hasn't changed - // So the image is re-rendered correctly - if (!changed){ - this.trigger("change:value", this, { - oldValue: value_, - newValue: value_ - }); - } - }, - is_false: function() { - return false; - }, - set_dimensions: function(height, width) { - this.$el.css({ - maxWidth: width, - minHeight: height, - }); - }, -}); - -var FieldStatus = common.AbstractField.extend({ - template: "FieldStatus", - init: function(field_manager, node) { - this._super(field_manager, node); - this.options.clickable = this.options.clickable || (this.node.attrs || {}).clickable || false; - this.options.visible = this.options.visible || (this.node.attrs || {}).statusbar_visible || false; - this.set({value: false}); - this.selection = {'unfolded': [], 'folded': []}; - this.set("selection", {'unfolded': [], 'folded': []}); - this.selection_dm = new utils.DropMisordered(); - this.dataset = new data.DataSetStatic(this, this.field.relation, this.build_context()); - }, - start: function() { - this.field_manager.on("view_content_has_changed", this, this.calc_domain); - this.calc_domain(); - this.on("change:value", this, this.get_selection); - this.on("change:evaluated_selection_domain", this, this.get_selection); - this.on("change:selection", this, function() { - this.selection = this.get("selection"); - this.render_value(); - }); - this.get_selection(); - if (this.options.clickable) { - this.bind_stage_click(); - } - this._super(); - }, - bind_stage_click: function () { - // This function is overriden in the enterprise webclient - this.$el.on('click','li[data-id]',this.on_click_stage); - }, - set_value: function(value_) { - if (value_ instanceof Array) { - value_ = value_[0]; - } - this._super(value_); - }, - render_value: function() { - var self = this; - var content = QWeb.render("FieldStatus.content", { - 'widget': self, - 'value_folded': _.find(self.selection.folded, function(i){return i[0] === self.get('value');}) - }); - self.$el.html(content); - }, - calc_domain: function() { - var d = pyeval.eval('domain', this.build_domain()); - var domain = []; //if there is no domain defined, fetch all the records - - if (d.length) { - domain = ['|',['id', '=', this.get('value')]].concat(d); - } - - if (! _.isEqual(domain, this.get("evaluated_selection_domain"))) { - this.set("evaluated_selection_domain", domain); - } - }, - /** Get the selection and render it - * selection: [[identifier, value_to_display], ...] - * For selection fields: this is directly given by this.field.selection - * For many2one fields: perform a search on the relation of the many2one field - */ - get_selection: function() { - var self = this; - var selection_unfolded = []; - var selection_folded = []; - var fold_field = this.options.fold_field; - - var calculation = _.bind(function() { - if (this.field.type === "many2one") { - return self.get_distant_fields().then(function () { - return new data.DataSetSearch(self, self.field.relation, self.build_context(), self.get("evaluated_selection_domain")) - .read_slice(_.union(_.keys(self.distant_fields), ['id']), {}).then(function (records) { - var ids = _.pluck(records, 'id'); - return self.dataset.name_get(ids).then(function (records_name) { - _.each(records, function (record) { - var name = _.find(records_name, function (val) {return val[0] === record.id;})[1]; - if (fold_field && record[fold_field] && record.id !== self.get('value')) { - selection_folded.push([record.id, name]); - } else { - selection_unfolded.push([record.id, name]); - } - }); - }); - }); - }); - } else { - // For field type selection filter values according to - // statusbar_visible attribute of the field. For example: - // statusbar_visible="draft,open". - var select = this.field.selection; - for(var i=0; i < select.length; i++) { - var key = select[i][0]; - if(key === this.get('value') || !this.options.visible || this.options.visible.indexOf(key) !== -1) { - selection_unfolded.push(select[i]); - } - } - return $.when(); - } - }, this); - this.selection_dm.add(calculation()).then(function () { - var selection = {'unfolded': selection_unfolded, 'folded': selection_folded}; - if (! _.isEqual(selection, self.get("selection"))) { - self.set("selection", selection); - } - }); - }, - /* - * :deprecated: this feature will probably be removed with OpenERP v8 - */ - get_distant_fields: function() { - var self = this; - if (! this.options.fold_field) { - this.distant_fields = {}; - } - if (this.distant_fields) { - return $.when(this.distant_fields); - } - return new Model(self.field.relation).call("fields_get", [[this.options.fold_field]]).then(function(fields) { - self.distant_fields = fields; - return fields; - }); - }, - on_click_stage: _.debounce(function (ev) { - var self = this; - var $li = $(ev.currentTarget); - var ul = $li.closest('.oe_form_field_status'); - if (this.view.is_disabled) { - return; - } - var val; - if (ul.attr('disabled')) { - return; - } - if (this.field.type === "many2one") { - val = parseInt($li.data("id"), 10); - } else { - val = $li.data("id"); - } - if (val !== self.get('value')) { - if (!this.view.datarecord.id || - this.view.datarecord.id.toString().match(data.BufferedDataSet.virtual_id_regex)) { - // don't save, only set value for not-yet-saved many2ones - self.set_value(val); - } - else { - this.view.recursive_save().done(function() { - var change = {}; - change[self.name] = val; - ul.attr('disabled', true); - self.view.dataset.write(self.view.datarecord.id, change).done(function() { - self.view.reload(); - }).always(function() { - ul.removeAttr('disabled'); - }); - }); - } - } - }, 300, true), -}); - -var FieldMonetary = FieldFloat.extend({ - template: 'FieldMonetary', - init: function() { - this._super.apply(this, arguments); - this.set({"currency": false}); - var currency_field = (this.options && this.options.currency_field) || this.field.currency_field || 'currency_id'; - if (currency_field) { - this.field_manager.on("field_changed:" + currency_field, this, function() { - this.set({"currency": this.field_manager.get_field_value(currency_field)}); - }); - } - this.on("change:currency", this, this.get_currency_info); - this.get_currency_info(); - }, - start: function() { - var tmp = this._super(); - this.on("change:currency_info", this, this.update); - return tmp; - }, - initialize_content: function() { - if(!this.get('effective_readonly')) { - this.$input = this.$('input'); - this.add_symbol(); - } else { - this.$input = undefined; - } - - this._super(); - }, - add_symbol: function() { - var currency = this.get('currency_info'); - if(currency) { - var before = (currency.position === 'before'); - this.$el[(before)? 'prepend' : 'append']($('<span/>', {html: currency.symbol})); - } - }, - get_currency_info: function() { - var self = this; - if (this.get("currency") === false) { - this.set({"currency_info": null}); - return; - } - return self.set({"currency_info": session.get_currency(self.get("currency"))}); - }, - update: function() { - if (this.view.options.is_list_editable) { - return; - } else { - return this.reinitialize(); - } - }, - render_value: function() { - this._super(); - if(this.get('effective_readonly')) { - this.add_symbol(); - } - }, - get_digits_precision: function() { - return this.node.attrs.digits || this.field.digits || (this.get('currency_info') && this.get('currency_info').digits); - }, - parse_value: function(val, def) { - return formats.parse_value(val, {type: "float", digits: this.get_digits_precision()}, def); - }, - format_value: function(val, def) { - return formats.format_value(val, {type: "float", digits: this.get_digits_precision()}, def); - }, -}); - -/** - This widget is intended to be used on stat button numeric fields. It will display - the value many2many and one2many. It is a read-only field that will - display a simple string "<value of field> <label of the field>" -*/ -var StatInfo = common.AbstractField.extend({ - is_field_number: true, - init: function() { - this._super.apply(this, arguments); - this.internal_set_value(0); - }, - set_value: function(value_) { - if (value_ === false || value_ === undefined) { - value_ = 0; - } - this._super.apply(this, [value_]); - }, - render_value: function() { - var options = { - value: this.get("value") || 0, - }; - if (! this.node.attrs.nolabel) { - if(this.options.label_field && this.view.datarecord[this.options.label_field]) { - options.text = this.view.datarecord[this.options.label_field]; - } - else { - options.text = this.string; - } - } - this.$el.html(QWeb.render("StatInfo", options)); - this.$el.addClass('o_stat_info'); - }, -}); - -/** - This widget is intended to be used on boolean fields. It toggles a button - switching between a green bullet / gray bullet. -*/ -var FieldToggleBoolean = common.AbstractField.extend({ - template: "toggle_button", - events: { - 'click': 'set_toggle_button' - }, - render_value: function () { - var class_name = this.get_value() ? 'o_toggle_button_success' : 'text-muted'; - this.$('i').attr('class', ('fa fa-circle ' + class_name)); - }, - set_toggle_button: function () { - var self = this; - var toggle_value = !this.get_value(); - if (this.view.get('actual_mode') == 'view') { - var rec_values = {}; - rec_values[self.node.attrs.name] = toggle_value; - return this.view.dataset._model.call( - 'write', [ - [this.view.datarecord.id], - rec_values, - self.view.dataset.get_context() - ]).done(function () { self.reload_record(); }); - } - else { - this.set_value(toggle_value); - } - }, - reload_record: function () { - this.view.reload(); - }, - is_false: function() { - return false; - }, -}); - -/** - This widget is intended to be used on Text fields. It will provide Ace Editor for editing XML and Python. -*/ - -var AceEditor = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: "AceEditor", - willStart: function() { - if (!window.ace && !this.loadJS_def) { - this.loadJS_def = ajax.loadJS('/web/static/lib/ace/ace.odoo-custom.js').then(function () { - return $.when(ajax.loadJS('/web/static/lib/ace/mode-python.js'), - ajax.loadJS('/web/static/lib/ace/mode-xml.js') - ); - }); - } - return $.when(this._super(), this.loadJS_def); - }, - initialize_content: function () { - if (! this.get("effective_readonly")) { - var self = this; - - this.aceEditor = ace.edit(this.$('.ace-view-editor')[0]); - this.aceEditor.setOptions({"maxLines": Infinity}); - this.aceEditor.$blockScrolling = true; - - var scrollIntoViewIfNeeded = _.throttle(function () { - var node = self.aceEditor.renderer.textarea; - if (node.scrollIntoViewIfNeeded) { - node.scrollIntoViewIfNeeded(false); - } else { - var offsetParent = node.offsetParent; - while (offsetParent) { - var elY = 0; - var elH = node.offsetHeight+20; - var parent = node; - while (offsetParent && parent) { - elY += node.offsetTop; - // get if a parent have a scrollbar - parent = node.parentNode; - while (parent != offsetParent && - (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(window.getComputedStyle(parent).overflowY) === -1)) { - parent = parent.parentNode; - } - node = parent; - if (parent !== offsetParent) { - elY -= parent.offsetTop; - parent = null; - } - offsetParent = node.offsetParent; - } - - if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(window.getComputedStyle(node).overflowY) !== -1) && - (node.scrollTop + node.clientHeight) < (elY + elH)) { - node.scrollTop = (elY + elH) - node.clientHeight; - } - } - } - }); - var $moveTextAreaToCursor = this.aceEditor.renderer.$moveTextAreaToCursor; - self.aceEditor.renderer.$moveTextAreaToCursor = function() { - $moveTextAreaToCursor.call(this); - if (parseInt($(self.aceEditor.renderer.textarea).css('top'), 10) >= 0) { - scrollIntoViewIfNeeded(); - } - }; - - this.aceSession = this.aceEditor.getSession(); - this.aceSession.setUseWorker(false); - this.aceSession.setMode("ace/mode/"+(this.options.mode || 'xml')); - - this.aceEditor.on("blur", function() { - if (self.aceSession.getUndoManager().hasUndo()) { - self.set_value(self.aceSession.getValue()); - } - }); - } - }, - destroy_content: function() { - if (this.aceEditor) { - this.aceEditor.destroy(); - } - }, - render_value: function() { - if (! this.get("effective_readonly")) { - var value = formats.format_value(this.get('value'), this); - this.aceSession.setValue(value); - - } else { - var txt = this.get("value") || ''; - this.$(".oe_form_text_content").text(txt); - } - }, - focus: function() { - return this.aceEditor.focus(); - }, -}); - -/** - * Registry of form fields, called by :js:`instance.web.FormView`. - * - * All referenced classes must implement FieldInterface. Those represent the classes whose instances - * will substitute to the <field> tags as defined in OpenERP's views. - */ -core.form_widget_registry - .add('char', FieldChar) - .add('id', FieldID) - .add('email', FieldEmail) - .add('url', FieldUrl) - .add('text',FieldText) - .add('domain', FieldDomain) - .add('date', FieldDate) - .add('datetime', FieldDatetime) - .add('selection', FieldSelection) - .add('radio', FieldRadio) - .add('reference', FieldReference) - .add('boolean', FieldBoolean) - .add('boolean_button', FieldBooleanButton) - .add('toggle_button', FieldToggleBoolean) - .add('float', FieldFloat) - .add('percentpie', FieldPercentPie) - .add('integer', FieldFloat) - .add('float_time', FieldFloat) - .add('progressbar', FieldProgressBar) - .add('image', FieldBinaryImage) - .add('binary', FieldBinaryFile) - .add('statusbar', FieldStatus) - .add('monetary', FieldMonetary) - .add('priority', FieldPriority) - .add('kanban_state_selection', KanbanSelection) - .add('statinfo', StatInfo) - .add('timezone_mismatch', TimezoneMismatch) - .add('label_selection', LabelSelection) - .add('ace', AceEditor); - -/** - * Registry of widgets usable in the form view that can substitute to any possible - * tags defined in OpenERP's form views. - * - * Every referenced class should extend FormWidget. - */ -core.form_tag_registry.add('button', WidgetButton); - -return { - FieldBoolean: FieldBoolean, - FieldChar: FieldChar, - FieldEmail: FieldEmail, - FieldFloat: FieldFloat, - FieldRadio: FieldRadio, - FieldStatus: FieldStatus, - FieldMonetary: FieldMonetary, - WidgetButton: WidgetButton -}; - -}); 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 00000000000..040311a3919 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -0,0 +1,128 @@ +odoo.define('web.GraphController', function (require) { +"use strict"; +/*--------------------------------------------------------- + * Odoo Graph view + *---------------------------------------------------------*/ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); + +var qweb = core.qweb; + +var GraphController = AbstractController.extend({ + className: 'o_graph', + /** + * @override + * @param {Widget} parent + * @param {GraphModel} model + * @param {GraphRenderer} renderer + * @param {Object} params + * @param {string[]} params.measures + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.measures = params.measures; + }, + /** + * @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(); + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * 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) { + if ($node) { + var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; + this.$buttons = $(qweb.render('GraphView.buttons', context)); + this.$measureList = this.$buttons.find('.o_graph_measures_list'); + this.$buttons.find('button').tooltip(); + this.$buttons.click(this._onButtonClick.bind(this)); + this._updateButtons(); + this.$buttons.appendTo($node); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @todo remove this and directly calls update. Update should be overridden + * and modified to call _updateButtons + * + * @param {string} mode one of 'pie', 'line' or 'bar' + */ + _setMode: function (mode) { + this.update({mode: mode}); + this._updateButtons(); + }, + /** + * @todo same as _setMode + * + * @param {string} measure should be a valid (and aggregatable) field name + */ + _setMeasure: function (measure) { + var self = this; + this.update({measure: measure}).then(function () { + self._updateButtons(); + }); + }, + /** + * makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that) + */ + _updateButtons: function () { + 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.$measureList.find('li').each(function (index, li) { + $(li).toggleClass('selected', $(li).data('field') === state.measure); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Do what need to be done when a button from the control panel is clicked. + * + * @param {MouseEvent} event + */ + _onButtonClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_graph_button')) { + this._setMode($target.data('mode')); + } else if ($target.parents('.o_graph_measures_list').length) { + event.preventDefault(); + event.stopPropagation(); + var parent = $target.parent(); + var field = parent.data('field'); + this._setMeasure(field); + } + }, +}); + +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 00000000000..73578f5c3bc --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -0,0 +1,188 @@ +odoo.define('web.GraphModel', function (require) { +"use strict"; + +/** + * The graph model is responsible for fetching and processing data from the + * server. It basically just do a read_group and format/normalize data. + */ + +var core = require('web.core'); +var AbstractModel = require('web.AbstractModel'); + +var _t = core._t; + +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. + * + * @todo Adding the fields parameter looks wrong. If the renderer or the + * controller need the fields, they should get it via their init method. + * + * @returns {Object} + */ + get: function () { + return _.extend({}, this.chart, { + fields: this.fields + }); + }, + /** + * 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 {string[]} params.groupedBy + * @param {string} params.initialMode one of 'pie', 'bar', 'line + * @param {string} params.initialMeasure a valid field name + * @param {string[]} params.initialGroupBys a list of valid field names + * @param {Object} params.context + * @param {string[]} params.domain + * @returns {Deferred} The deferred does not return a handle, we don't need + * to keep track of various entities. + */ + load: function (params) { + this.initialGroupBys = params.initialGroupBys; + this.fields = params.fields; + this.modelName = params.modelName; + if (!params.groupedBy.length) { + var groupedBy = params.context.graph_groupbys || + (params.groupedBy.length && params.groupedBy) || + this.initialGroupBys; + params.groupedBy = groupedBy.slice(0); + } + this.chart = { + data: [], + groupedBy: params.groupedBy, + measure: params.initialMeasure, + mode: params.initialMode, + domain: params.domain, + context: params.context, + }; + 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 {string[]} [params.domain] + * @param {string[]} [params.groupBy] + * @param {string} [params.mode] one of 'bar', 'pie', 'line' + * @param {string} [params.measure] a valid field name + * @returns {Deferred} + */ + reload: function (handle, params) { + if ('domain' in params) { + this.chart.domain = params.domain; + } + if ('groupBy' in params) { + if (!params.groupBy.length) { + this.chart.groupedBy = this.initialGroupBys; + } else { + this.chart.groupedBy = params.groupBy; + } + } + if ('measure' in params) { + this.chart.measure = params.measure; + } + if ('mode' in params) { + this.chart.mode = params.mode; + return $.when(); + } + return this._loadGraph(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Fetch and process graph data. It is basically a read_group with correct + * fields. 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 + * + * @returns {Deferred} + */ + _loadGraph: function () { + var fields = _.map(this.chart.groupedBy, function (groupBy) { + return groupBy.split(':')[0]; + }); + if (this.chart.measure !== '__count__') { + fields = fields.concat(this.chart.measure); + } + return this.performModelRPC(this.modelName, 'read_group', [], { + context: this.chart.context, + domain: this.chart.domain, + fields: fields, + groupby: this.chart.groupedBy, + lazy: false, + }).then(this._processData.bind(this)); + }, + /** + * Since read_group is insane and returns its result on different keys + * depending of some input, we have to normalize the result. + * The final chart data is added to this.chart object. + * + * @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 + * + * @param {any} raw_data result from the read_group + */ + _processData: function (raw_data) { + var self = this; + var is_count = this.chart.measure === '__count__'; + var data_pt, labels; + + this.chart.data = []; + for (var i = 0; i < raw_data.length; i++) { + data_pt = raw_data[i]; + labels = _.map(this.chart.groupedBy, function (field) { + return self._sanitizeValue(data_pt[field], field); + }); + this.chart.data.push({ + value: is_count ? data_pt.__count : data_pt[this.chart.measure], + labels: labels + }); + } + }, + /** + * Helper function (for _processData), turns various values in a usable + * string form, that we can display in the interface. + * + * @param {any} value some value received by the read_group rpc + * @param {string} field the name of the corresponding field + * @returns {string} + */ + _sanitizeValue: function (value, field) { + if (value === false) return _t("Undefined"); + if (value instanceof Array) return value[1]; + if (field && this.fields[field] && (this.fields[field].type === 'selection')) { + var selected = _.where(this.fields[field].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 00000000000..e5fe00cc473 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -0,0 +1,334 @@ +odoo.define('web.GraphRenderer', function (require) { +"use strict"; + +/** + * The graph renderer turns the data from the graph model into a nice looking + * svg chart. This code uses the nvd3 library. + * + * Note that we use a custom build for the nvd3, with only the model we actually + * use. + */ + +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var formats = require('web.formats'); + +var _t = core._t; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'line']; + +// hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); + +return AbstractRenderer.extend({ + className: "o_graph_svg_container", + /** + * @override + * @param {Widget} parent + * @param {Object} state + * @param {Object} params + * @param {boolean} params.stacked + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + this.stacked = params.stacked; + this.$el.css({minWidth: '100px', minHeight: '100px'}); + }, + /** + * @override + */ + destroy: function () { + nv.utils.offWindowResize(this.to_remove); + this._super(); + }, + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Render the chart. + * + * Note that This method is synchronous, but the actual rendering is done + * asynchronously (in a setTimeout). The reason for that is that nvd3/d3 + * needs to be in the DOM to correctly render itself. So, we trick Odoo by + * returning immediately, then wait a tiny interval before actually + * displaying the data. + * + * @returns {Deferred} The _super deferred is actually resolved immediately + */ + _render: function () { + if (this.to_remove) { + nv.utils.offWindowResize(this.to_remove); + } + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.$el.empty(); + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } else if (!this.state.data.length) { + this.$el.empty(); + this.$el.append(qweb.render('GraphView.error', { + title: _t("No data to display"), + description: _t("No data available for this chart. " + + "Try to add some records, or make sure that " + + "there is no active filter in the search bar."), + })); + } else { + var self = this; + setTimeout(function () { + self.$el.empty(); + var chart = self['_render' + _.str.capitalize(self.state.mode) + 'Chart'](); + if (chart) { + self.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + chart.tooltip.chartContainer(self.el); + } + }, 0); + } + return this._super.apply(this, arguments); + }, + + /** + * Helper function to set up data properly for the multiBarChart model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderBarChart: function () { + // prepare data for bar chart + var self = this; + var data, values; + var measure = this.state.fields[this.state.measure].string; + + // zero groupbys + if (this.state.groupedBy.length === 0) { + data = [{ + values: [{ + x: measure, + y: this.state.data[0].value}], + key: measure + }]; + } + // one groupby + if (this.state.groupedBy.length === 1) { + values = this.state.data.map(function (datapt) { + return {x: datapt.labels, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + } + if (this.state.groupedBy.length > 1) { + var xlabels = [], + series = [], + label, serie, value; + values = {}; + for (var i = 0; i < this.state.data.length; i++) { + label = this.state.data[i].labels[0]; + serie = this.state.data[i].labels[1]; + value = this.state.data[i].value; + if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { + xlabels.push(label); + } + series.push(this.state.data[i].labels[1]); + if (!(serie in values)) {values[serie] = {};} + values[serie][label] = this.state.data[i].value; + } + series = _.uniq(series); + data = []; + var current_serie, j; + for (i = 0; i < series.length; i++) { + current_serie = {values: [], key: series[i]}; + for (j = 0; j < xlabels.length; j++) { + current_serie.values.push({ + x: xlabels[j], + y: values[series[i]][xlabels[j]] || 0, + }); + } + data.push(current_serie); + } + } + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(0); + + var chart = nv.models.multiBarChart(); + chart.options({ + margin: {left: 120, bottom: 60}, + delay: 250, + transition: 10, + showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showXAxis: true, + showYAxis: true, + rightAlignYAxis: false, + stacked: this.stacked, + reduceXTicks: false, + rotateLabels: -20, + showControls: (this.state.groupedBy.length > 1) + }); + chart.yAxis.tickFormat(function (d) { + var measure_field = self.state.fields[self.measure]; + return formats.format_value(d, { + type: 'float', + digits: measure_field && measure_field.digits || [69, 2], + }); + }); + + chart(svg); + return chart; + }, + /** + * Helper function to set up data properly for the pieChart model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderPieChart: function () { + var data = []; + var all_negative = true; + var some_negative = false; + var all_zero = true; + + this.state.data.forEach(function (datapt) { + all_negative = all_negative && (datapt.value < 0); + some_negative = some_negative || (datapt.value < 0); + all_zero = all_zero && (datapt.value === 0); + }); + if (some_negative && !all_negative) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot mix positive and negative numbers. " + + "Try to change your domain to only display positive results"), + })); + return; + } + if (all_zero) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot display all zero numbers.. " + + "Try to change your domain to display positive results"), + })); + return; + } + if (this.state.groupedBy.length) { + data = this.state.data.map(function (datapt) { + return {x:datapt.labels.join("/"), y: datapt.value}; + }); + } + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(100); + + var legend_right = config.device.size_class > config.device.SIZES.XS; + + var chart = nv.models.pieChart().labelType('percent'); + chart.options({ + delay: 250, + showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH, + legendPosition: legend_right ? 'right' : 'top', + transition: 100, + color: d3.scale.category10().range(), + }); + + chart(svg); + return chart; + }, + /** + * Helper function to set up data properly for the line model in + * nvd3. + * + * @returns {nvd3 chart} + */ + _renderLineChart: function () { + if (this.state.data.length < 2) { + this.$el.append(qweb.render('GraphView.error', { + title: _t("Not enough data points"), + description: "You need at least two data points to display a line chart." + })); + return; + } + var self = this; + var data = []; + var tickValues; + var tickFormat; + var measure = this.state.fields[this.state.measure].string; + + if (this.state.groupedBy.length === 1) { + var values = this.state.data.map(function (datapt, index) { + return {x: index, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + tickValues = this.state.data.map(function (d, i) { return i;}); + tickFormat = function (d) {return self.state.data[d].labels;}; + } + if (this.state.groupedBy.length > 1) { + data = []; + var data_dict = {}; + var tick = 0; + var tickLabels = []; + var serie, tickLabel; + var identity = function (p) {return p;}; + tickValues = []; + for (var i = 0; i < this.state.data.length; i++) { + if (this.state.data[i].labels[0] !== tickLabel) { + tickLabel = this.state.data[i].labels[0]; + tickValues.push(tick); + tickLabels.push(tickLabel); + tick++; + } + serie = this.state.data[i].labels[1]; + if (!data_dict[serie]) { + data_dict[serie] = { + values: [], + key: serie, + }; + } + data_dict[serie].values.push({ + x: tick, y: this.state.data[i].value, + }); + data = _.map(data_dict, identity); + } + tickFormat = function (d) {return tickLabels[d];}; + } + + var svg = d3.select(this.$el[0]).append('svg'); + svg.datum(data); + + svg.transition().duration(0); + + var chart = nv.models.lineChart(); + chart.options({ + margin: {left: 120, bottom: 60}, + useInteractiveGuideline: true, + showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showXAxis: true, + showYAxis: true, + }); + chart.xAxis.tickValues(tickValues) + .tickFormat(tickFormat); + chart.yAxis.tickFormat(function (d) { + return formats.format_value(d, { + type : 'float', + digits : self.state.fields[self.state.measure] && self.state.fields[self.state.measure].digits || [69, 2], + }); + }); + + chart(svg); + return chart; + }, +}); + +}); 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 00000000000..3422a0922d3 --- /dev/null +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -0,0 +1,69 @@ +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 GraphView = AbstractView.extend({ + display_name: _lt('Graph'), + icon: 'fa-bar-chart', + config: { + Model: GraphModel, + Controller: Controller, + Renderer: GraphRenderer, + }, + /** + * @override + */ + init: function (arch, fields) { + this._super.apply(this, arguments); + + var initialMeasure = '__count__'; + var initialGroupBys = []; + fields.__count__ = {string: _t("Count"), type: "integer"}; + arch.children.forEach(function (field) { + var name = field.attrs.name; + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + if (field.attrs.type === 'measure') { + initialMeasure = name; + } else { + initialGroupBys.push(name); + } + }); + + var measures = {__count__: {string: _t("Count"), type: "integer"}}; + _.each(fields, function (field, name) { + if (name !== 'id' && field.store === true) { + if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') { + measures[name] = field; + } + } + }); + + this.controllerParams.measures = measures; + this.rendererParams.stacked = arch.attrs.stacked !== "False"; + + this.loadParams.initialMode = arch.attrs.type || 'bar'; + this.loadParams.initialMeasure = initialMeasure; + this.loadParams.initialGroupBys = initialGroupBys; + this.loadParams.fields = fields; + }, +}); + +return GraphView; + +}); diff --git a/addons/web/static/src/js/views/graph_view.js b/addons/web/static/src/js/views/graph_view.js deleted file mode 100644 index 308c1359248..00000000000 --- a/addons/web/static/src/js/views/graph_view.js +++ /dev/null @@ -1,144 +0,0 @@ -odoo.define('web.GraphView', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Graph view - *---------------------------------------------------------*/ - -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var GraphWidget = require('web.GraphWidget'); -var View = require('web.View'); - -var _lt = core._lt; -var _t = core._t; -var QWeb = core.qweb; - -var GraphView = View.extend({ - className: 'o_graph', - display_name: _lt('Graph'), - icon: 'fa-bar-chart', - require_fields: true, - - init: function () { - this._super.apply(this, arguments); - - this.measures = []; - this.active_measure = '__count__'; - this.initial_groupbys = []; - this.widget = undefined; - }, - willStart: function () { - var self = this; - var fields_def = data_manager.load_fields(this.dataset).then(this.prepare_fields.bind(this)); - this.fields_view.arch.children.forEach(function (field) { - var name = field.attrs.name; - if (field.attrs.interval) { - name += ':' + field.attrs.interval; - } - if (field.attrs.type === 'measure') { - self.active_measure = name; - } else { - self.initial_groupbys.push(name); - } - }); - return $.when(this._super(), fields_def); - }, - /** - * 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 - */ - render_buttons: function ($node) { - if ($node) { - var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; - this.$buttons = $(QWeb.render('GraphView.buttons', context)); - this.$measure_list = this.$buttons.find('.o_graph_measures_list'); - this.update_measure(); - this.$buttons.find('button').tooltip(); - this.$buttons.click(this.on_button_click.bind(this)); - - this.$buttons.find('.o_graph_button[data-mode="' + this.widget.mode + '"]').addClass('active'); - - this.$buttons.appendTo($node); - } - }, - update_measure: function () { - var self = this; - this.$measure_list.find('li').each(function (index, li) { - $(li).toggleClass('selected', $(li).data('field') === self.active_measure); - }); - }, - do_show: function () { - this.do_push_state({}); - return this._super(); - }, - prepare_fields: function (fields) { - var self = this; - this.fields = fields; - _.each(fields, function (field, name) { - if ((name !== 'id') && (field.store === true)) { - if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') { - self.measures[name] = field; - } - } - }); - this.measures.__count__ = {string: _t("Count"), type: "integer"}; - }, - do_search: function (domain, context, group_by) { - if (!this.widget) { - this.initial_groupbys = context.graph_groupbys || (group_by.length ? group_by : this.initial_groupbys); - this.widget = new GraphWidget(this, this.model, { - measure: context.graph_measure || this.active_measure, - mode: context.graph_mode || this.active_mode, - domain: domain, - groupbys: this.initial_groupbys, - context: context, - fields: this.fields, - stacked: this.fields_view.arch.attrs.stacked !== "False" - }); - // append widget - this.widget.appendTo(this.$el); - } else { - var groupbys = group_by.length ? group_by : this.initial_groupbys.slice(0); - this.widget.update_data(domain, groupbys); - } - }, - get_context: function () { - return !this.widget ? {} : { - graph_mode: this.widget.mode, - graph_measure: this.widget.measure, - graph_groupbys: this.widget.groupbys - }; - }, - on_button_click: function (event) { - var $target = $(event.target); - if ($target.hasClass('o_graph_button')) { - this.widget.set_mode($target.data('mode')); - this.$buttons.find('.o_graph_button.active').removeClass('active'); - $target.addClass('active'); - } - else if ($target.parents('.o_graph_measures_list').length) { - var parent = $target.parent(); - var field = parent.data('field'); - this.active_measure = field; - event.preventDefault(); - event.stopPropagation(); - this.update_measure(); - this.widget.set_measure(this.active_measure); - } - }, - destroy: function () { - if (this.$buttons) { - this.$buttons.find('button').off(); // remove jquery's tooltip() handlers - } - return this._super.apply(this, arguments); - }, -}); - -core.view_registry.add('graph', GraphView); - -return GraphView; - -}); diff --git a/addons/web/static/src/js/views/graph_widget.js b/addons/web/static/src/js/views/graph_widget.js deleted file mode 100644 index f778c7201ec..00000000000 --- a/addons/web/static/src/js/views/graph_widget.js +++ /dev/null @@ -1,340 +0,0 @@ -odoo.define('web.GraphWidget', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var Model = require('web.DataModel'); -var formats = require('web.formats'); -var Widget = require('web.Widget'); - -var _t = core._t; -var QWeb = core.qweb; - -// hide top legend when too many item for device size -var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); - -return Widget.extend({ - className: "o_graph_svg_container", - init: function (parent, model, options) { - this._super(parent); - this.context = options.context; - this.fields = options.fields; - this.fields.__count__ = {string: _t("Count"), type: "integer"}; - this.model = new Model(model, {group_by_no_leaf: true}); - - this.domain = options.domain || []; - this.groupbys = options.groupbys || []; - this.mode = options.mode || "bar"; - this.measure = options.measure || "__count__"; - this.stacked = options.stacked; - }, - start: function () { - return this.load_data().then(this.proxy('display_graph')); - }, - update_data: function (domain, groupbys) { - this.domain = domain; - this.groupbys = groupbys; - return this.load_data().then(this.proxy('display_graph')); - }, - set_mode: function (mode) { - this.mode = mode; - this.display_graph(); - }, - set_measure: function (measure) { - this.measure = measure; - return this.load_data().then(this.proxy('display_graph')); - }, - load_data: function () { - var fields = this.groupbys.slice(0); - if (this.measure !== '__count__'.slice(0)) - fields = fields.concat(this.measure); - return this.model - .query(fields) - .filter(this.domain) - .context(this.context) - .lazy(false) - .group_by(this.groupbys.slice(0,2)) - .then(this.proxy('prepare_data')); - }, - prepare_data: function () { - var raw_data = arguments[0], - is_count = this.measure === '__count__'; - var data_pt, j, values, value; - - this.data = []; - for (var i = 0; i < raw_data.length; i++) { - data_pt = raw_data[i].attributes; - values = []; - if (this.groupbys.length === 1) data_pt.value = [data_pt.value]; - for (j = 0; j < data_pt.value.length; j++) { - values[j] = this.sanitize_value(data_pt.value[j], data_pt.grouped_on[j]); - } - value = is_count ? data_pt.length : data_pt.aggregates[this.measure]; - this.data.push({ - labels: values, - value: value - }); - } - }, - sanitize_value: function (value, field) { - if (value === false) return _t("Undefined"); - if (value instanceof Array) return value[1]; - if (field && this.fields[field] && (this.fields[field].type === 'selection')) { - var selected = _.where(this.fields[field].selection, {0: value})[0]; - return selected ? selected[1] : value; - } - return value; - }, - display_graph: function () { - if (this.to_remove) { - nv.utils.offWindowResize(this.to_remove); - } - this.$el.empty(); - if (!this.data.length) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("No data to display"), - description: _t("No data available for this chart. " + - "Try to add some records, or make sure that " + - "there is no active filter in the search bar."), - })); - } else { - var chart = this['display_' + this.mode](); - if (chart) { - chart.tooltip.chartContainer(this.$el[0]); - } - } - }, - display_bar: function () { - // prepare data for bar chart - var data, values, - measure = this.fields[this.measure].string, - self = this; - - // zero groupbys - if (this.groupbys.length === 0) { - data = [{ - values: [{ - x: measure, - y: this.data[0].value}], - key: measure - }]; - } - // one groupby - if (this.groupbys.length === 1) { - values = this.data.map(function (datapt) { - return {x: datapt.labels, y: datapt.value}; - }); - data = [ - { - values: values, - key: measure, - } - ]; - } - if (this.groupbys.length > 1) { - var xlabels = [], - series = [], - label, serie, value; - values = {}; - for (var i = 0; i < this.data.length; i++) { - label = this.data[i].labels[0]; - serie = this.data[i].labels[1]; - value = this.data[i].value; - if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { - xlabels.push(label); - } - series.push(this.data[i].labels[1]); - if (!(serie in values)) {values[serie] = {};} - values[serie][label] = this.data[i].value; - } - series = _.uniq(series); - data = []; - var current_serie, j; - for (i = 0; i < series.length; i++) { - current_serie = {values: [], key: series[i]}; - for (j = 0; j < xlabels.length; j++) { - current_serie.values.push({ - x: xlabels[j], - y: values[series[i]][xlabels[j]] || 0, - }); - } - data.push(current_serie); - } - } - var svg = d3.select(this.$el[0]).append('svg'); - svg.datum(data); - - svg.transition().duration(0); - - var chart = nv.models.multiBarChart(); - chart.options({ - margin: {left: 120, bottom: 60}, - delay: 250, - transition: 10, - showLegend: _.size(data) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - rightAlignYAxis: false, - stacked: this.stacked, - reduceXTicks: false, - rotateLabels: -20, - showControls: (this.groupbys.length > 1) - }); - chart.yAxis.tickFormat(function(d) { - return formats.format_value(d, { - type : 'float', - digits : self.fields[self.measure] && self.fields[self.measure].digits || [69, 2], - }); - }); - - chart(svg); - this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - - return chart; - }, - display_pie: function () { - var data = [], - all_negative = true, - some_negative = false, - all_zero = true; - - this.data.forEach(function (datapt) { - all_negative = all_negative && (datapt.value < 0); - some_negative = some_negative || (datapt.value < 0); - all_zero = all_zero && (datapt.value === 0); - }); - if (some_negative && !all_negative) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("Invalid data"), - description: _t("Pie chart cannot mix positive and negative numbers. " + - "Try to change your domain to only display positive results"), - })); - return; - } - if (all_zero) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("Invalid data"), - description: _t("Pie chart cannot display all zero numbers.. " + - "Try to change your domain to display positive results"), - })); - return; - } - if (this.groupbys.length) { - data = this.data.map(function (datapt) { - return {x:datapt.labels.join("/"), y: datapt.value}; - }); - } - var svg = d3.select(this.$el[0]).append('svg'); - svg.datum(data); - - svg.transition().duration(100); - - var legend_right = config.device.size_class > config.device.SIZES.XS; - - var chart = nv.models.pieChart().labelType('percent'); - chart.options({ - delay: 250, - showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH, - legendPosition: legend_right ? 'right' : 'top', - transition: 100, - color: d3.scale.category10().range(), - }); - - chart(svg); - this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - - return chart; - }, - display_line: function () { - if (this.data.length < 2) { - this.$el.append(QWeb.render('GraphView.error', { - title: _t("Not enough data points"), - description: "You need at least two data points to display a line chart." - })); - return; - } - var self = this, - data = [], - tickValues, - tickFormat, - measure = this.fields[this.measure].string; - if (this.groupbys.length === 1) { - var values = this.data.map(function (datapt, index) { - return {x: index, y: datapt.value}; - }); - data = [ - { - values: values, - key: measure, - } - ]; - tickValues = this.data.map(function (d, i) { return i;}); - tickFormat = function (d) {return self.data[d].labels;}; - } - if (this.groupbys.length > 1) { - data = []; - var data_dict = {}, - tick = 0, - tickLabels = [], - serie, tickLabel, - identity = function (p) {return p;}; - tickValues = []; - for (var i = 0; i < this.data.length; i++) { - if (this.data[i].labels[0] !== tickLabel) { - tickLabel = this.data[i].labels[0]; - tickValues.push(tick); - tickLabels.push(tickLabel); - tick++; - } - serie = this.data[i].labels[1]; - if (!data_dict[serie]) { - data_dict[serie] = { - values: [], - key: serie, - }; - } - data_dict[serie].values.push({ - x: tick, y: this.data[i].value, - }); - data = _.map(data_dict, identity); - } - tickFormat = function (d) {return tickLabels[d];}; - } - - var svg = d3.select(this.$el[0]).append('svg'); - svg.datum(data); - - svg.transition().duration(0); - - var chart = nv.models.lineChart(); - chart.options({ - margin: {left: 120, bottom: 60}, - useInteractiveGuideline: true, - showLegend: _.size(data) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - }); - chart.xAxis.tickValues(tickValues) - .tickFormat(tickFormat); - chart.yAxis.tickFormat(function(d) { - return formats.format_value(d, { - type : 'float', - digits : self.fields[self.measure] && self.fields[self.measure].digits || [69, 2], - }); - }); - - chart(svg); - this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); - - return chart; - }, - destroy: function () { - nv.utils.offWindowResize(this.to_remove); - return this._super(); - } -}); - -}); 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 00000000000..1e010e1ab6d --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_column.js @@ -0,0 +1,268 @@ +odoo.define('web.KanbanColumn', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var view_dialogs = require('web.view_dialogs'); +var quick_create = require('web.kanban_quick_create'); +var KanbanRecord = require('web.KanbanRecord'); +var Widget = require('web.Widget'); + +var _t = core._t; +var QWeb = core.qweb; +var RecordQuickCreate = quick_create.RecordQuickCreate; + +var KanbanColumn = Widget.extend({ + template: "KanbanView.Group", + + events: { + 'click .o_column_edit': 'edit_column', + 'click .o_column_delete': 'delete_column', + 'click .o_column_archive': 'archive_records', + 'click .o_column_unarchive': 'unarchive_records', + 'click .o_kanban_quick_add': 'add_quick_create', + 'click .o_kanban_load_more': 'load_more', + 'click .o_kanban_toggle_fold': 'toggle_fold', + }, + + custom_events: { + 'kanban_record_delete': 'delete_record', + 'cancel_quick_create': 'cancel_quick_create', + 'quick_create_add_record': 'quick_create_add_record', + }, + + init: function (parent, data, options, record_options) { + this._super(parent); + this.db_id = data.id; + this.data_records = data.data; + + var value = data.value; + this.id = data.res_id || value; + var field = data.fields[data.groupedBy[0]]; // fixme: grouped by field might not be in the fvg + if (field && field.type === "selection") { + value = _.find(field.selection, function (s) { return s[0] === data.value; })[1]; // fixme: same process done in list_renderer + } + // todo: handle group_by_m2o (nameget) + this.title = value || _t('Undefined'); + this.folded = !data.isOpen; + this.has_active_field = _.contains(data.fieldNames, 'active'); + this.size = data.count; + this.values = data.values; + this.fields = data.fields; + this.records = []; + + this.quick_create = options.quick_create; + this.grouped_by_m2o = options.grouped_by_m2o; + this.editable = options.editable; + this.deletable = options.deletable; + this.draggable = record_options.draggable; + this.records_editable = options.records_editable; + this.records_deletable = options.records_deletable; + this.relation = options.relation; + this.offset = 0; + this.remaining = this.size - this.data_records.length; + + this.record_options = _.extend(_.clone(record_options), { + group_info: this.values, + }); + + if (data.options && data.options.group_by_tooltip) { + var self = this; + this.tooltip_info = _.map(data.options.group_by_tooltip, function (key, value) { + return (self.values && self.values[value] && "<div>" +key + "<br>" + self.values[value] + "</div>") || ''; + }).join(''); + } else { + this.tooltip_info = ""; + } + }, + + start: function () { + var self = this; + this.$header = this.$('.o_kanban_header'); + + for (var i = 0; i < this.data_records.length; i++) { + this.add_record(this.data_records[i], {no_update: true}); + } + this.$header.tooltip(); + + if (config.device.size_class > config.device.SIZES.XS && this.draggable !== false) { + // deactivate sortable in mobile mode. It does not work anyway, + // and it breaks horizontal scrolling in kanban views. Someday, we + // should find a way to use the touch events to make sortable work. + this.$el.sortable({ + connectWith: '.o_kanban_group', + revert: 0, + delay: 0, + items: '> .o_kanban_record:not(.o_updating)', + helper: 'clone', + cursor: 'move', + over: function () { + self.$el.addClass('o_kanban_hover'); + self.update_column(); + }, + out: function () { + self.$el.removeClass('o_kanban_hover'); + }, + 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 + ui.item.addClass('o_updating'); + if (index >= 0) { + if ($.contains(self.$el[0], record.$el[0])) { + // resequencing records + self.trigger_up('kanban_column_resequence', {ids: self.get_ids()}); + } + } else { + // adding record to this column + self.trigger_up('kanban_column_add_record', {record: record, ids: self.get_ids()}); + } + } + }); + } + this.$el.click(function (event) { + if (self.folded) { + self.toggle_fold(event); + } + }); + this.update_column(); + }, + + is_empty: function () { + return !this.records.length; + }, + + add_record: function (data, options) { + var record = new KanbanRecord(this, data, this.record_options); + this.records.push(record); + if (options.position === 'before') { + record.insertAfter(this.quick_create_widget ? this.quick_create_widget.$el : this.$header); + } else { + var $load_more = this.$('.o_kanban_load_more'); + if ($load_more.length) { + record.insertBefore($load_more); + } else { + record.appendTo(this.$el); + } + } + if (!options.no_update) { + this.update_column(); + } + }, + + get_ids: function () { + var ids = []; + this.$('.o_kanban_record').each(function (index, r) { + ids.push($(r).data('record').id); + }); + return ids; + }, + + update_column: function () { + var title = this.folded ? this.title + ' (' + this.size + ')' : this.title; + this.$header.find('.o_column_title').text(title); + this.$header.find('.o-kanban-count').text(this.records.length); + + this.$el.toggleClass('o_column_folded', this.folded); + var tooltip = this.size + _t(' records'); + tooltip = '<p>' + tooltip + '</p>' + this.tooltip_info; + this.$header.tooltip({html: true}).attr('data-original-title', tooltip); + if (!this.remaining) { + this.$('.o_kanban_load_more').remove(); + } else { + this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this})); + } + }, + + archive_records: function (event) { + event.preventDefault(); + this.trigger_up('kanban_column_archive_records', {archive: true}); + }, + + unarchive_records: function (event) { + event.preventDefault(); + this.trigger_up('kanban_column_archive_records', {archive: false}); + }, + + toggle_fold: function (event) { + event.preventDefault(); + this.trigger_up('column_toggle_fold'); + }, + + delete_column: 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(); + }, + + edit_column: function (event) { + event.preventDefault(); + var dialog = new view_dialogs.FormViewDialog(this, { + res_model: this.relation, + res_id: this.id, + title: _t("Edit Column"), + }).open(); + + dialog.on('saved', null, this.trigger_up.bind(this, 'reload')); + }, + + delete_record: function (event) { + var self = this; + event.data.parent_id = this.db_id; + event.data.after = function cleanup() { + var index = self.records.indexOf(event.data.record); + self.records.splice(index, 1); + self.update_column(); + }; + }, + + add_quick_create: function () { + if (this.quick_create_widget) { + return; + } + var self = this; + var width = this.records.length ? this.records[0].$el.innerWidth() : this.$el.width() - 8; + this.quick_create_widget = new RecordQuickCreate(this, width); + this.quick_create_widget.insertAfter(this.$header); + this.quick_create_widget.$el.focusout(function () { + var hasFocus = (self.quick_create_widget.$(':focus').length > 0); + if (! hasFocus && self.quick_create_widget) { + self.cancel_quick_create(); + } + }); + }, + + cancel_quick_create: function () { + this.quick_create_widget.destroy(); + this.quick_create_widget = undefined; + }, + + quick_create_add_record: function (event) { + this.trigger_up('quick_create_record', event.data); + }, + + load_more: function (event) { + event.preventDefault(); + this.trigger_up('kanban_load_more'); + }, + +}); + +return KanbanColumn; + +}); 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 00000000000..df944c2bd03 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_controller.js @@ -0,0 +1,358 @@ +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 core = require('web.core'); +var data = require('web.data'); +var form_common = require('web.view_dialogs'); + +var _t = core._t; +var qweb = core.qweb; + +var KanbanController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + quick_create_add_column: '_onAddColumn', + quick_create_record: '_onQuickCreateRecord', + resequence_columns: '_onResequenceColumn', + kanban_do_action: '_onOpenAction', + kanban_record_delete: '_onRecordDelete', + kanban_record_update: '_onUpdateRecord', + kanban_column_delete: '_onDeleteColumn', + kanban_column_add_record: '_onAddRecordToColumn', + kanban_column_resequence: '_onColumnResequence', + kanban_column_archive_records: '_onArchiveRecords', + kanban_load_more: '_onLoadMore', + column_toggle_fold: '_onToggleColumn', + }), + /** + * @override + * @param {Object} params + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.on_create = params.on_create; + this.hasButtons = params.hasButtons; + + // true iff grouped by an m2o field and group_create action enabled + this.create_column_enabled = false; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {jQueryElement} $node + */ + renderButtons: function ($node) { + var self = this; + if (this.hasButtons && this.is_action_enabled('create')) { + this.$buttons = $(qweb.render("KanbanView.buttons", {})); + this.$buttons.on('click', 'button.o-kanban-button-new', function () { + var data = self.model.get(self.handle, {raw: true}); + if (data.groupedBy.length > 0 && data.count > 0 && self.on_create === 'quick_create') { + // Activate the quick create in the first column + self.renderer.add_quick_create(); + } else if (self.on_create && self.on_create !== 'quick_create') { + // Execute the given action + self.do_action(self.on_create, { + on_close: self.reload.bind(self), + additional_context: data.context, + }); + } else { + // Open the form view + self.trigger_up('switch_view', { + view_type: 'form', + res_id: undefined + }); + } + }); + this._updateButtons(); + this.$buttons.appendTo($node); + } + }, + /** + * Override update method to recompute create_column_enabled. + * + * @returns {Deferred} + */ + update: function () { + var state = this.model.get(this.handle, {raw: true}); + var group_by_field = state.fields[state.groupedBy[0]]; + var grouped_by_m2o = group_by_field && (group_by_field.type === 'many2one'); + var groupCreate = this.is_action_enabled('group_create'); + this.create_column_enabled = grouped_by_m2o && groupCreate; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override method comes from field manager mixin + * @param {string} id local id from the basic record data + */ + _confirmSave: function (id) { + this.renderer.update_record(this.model.get(id)); + }, + /** + * @todo this is dead code right now. resurrect this? + * + * @returns {boolean} + */ + _hasContent: function () { + return this._super.apply(this, arguments) || this.create_column_enabled; + }, + /** + * 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. + * + * @param {string} column_id + * @param {string[]} ids + * @returns {Deferred} + */ + _resequenceRecords: function (column_id, ids) { + return this.model.resequence(this.modelName, ids, column_id); + }, + /** + * In grouped mode, set 'Create' button as btn-default if there is no column + * (except if we can't create new columns) + * + * @override from abstract controller + */ + _updateButtons: function () { + if (this.$buttons) { + var data = this.model.get(this.handle, {raw: true}); + var create_muted = data.count === 0 && this.create_column_enabled; + this.$buttons.find('.o-kanban-button-new') + .toggleClass('btn-primary', !create_muted) + .toggleClass('btn-default', create_muted); + } + }, + /** + * @override + * @param {Object} state + * @returns {Deferred} + */ + _update: function (state) { + var hasNoContent = state.count === 0 && !this.create_column_enabled; + this.$el.toggleClass('o_kanban_nocontent', hasNoContent); + this._toggleNoContentHelper(hasNoContent); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // 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 + * + * @param {OdooEvent} event + */ + _onAddColumn: function (event) { + var self = this; + this.model.createGroup(event.data.value, this.handle).then(function () { + self.update({}, {reload: false}); + self.trigger_up('scrollTo', {selector: '.o_column_quick_create'}); + }); + }, + /** + * @param {OdooEvent} event + */ + _onAddRecordToColumn: function (event) { + var self = this; + var record = event.data.record; + var column = event.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, event.data.ids) + .then(function () { + _.each(column_db_ids, function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + }); + }).fail(this.reload.bind(this)); + }, + /** + * The interface allows in some case the user to archive a column. This is + * what this handler is for. + * + * @param {OdooEvent} event + */ + _onArchiveRecords: function (event) { + var self = this; + var active_value = !event.data.archive; + var column = event.target; + var record_ids = []; + _.each(column.records, function (kanban_record) { + if (kanban_record.record.active.value !== active_value) { + record_ids.push(kanban_record.db_id); + } + }); + if (record_ids.length) { + this.model + .toggleActive(record_ids, active_value, column.db_id) + .then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + } + }, + /** + * @param {OdooEvent} event + */ + _onColumnResequence: function (event) { + this._resequenceRecords(event.target.db_id, event.data.ids); + }, + /** + * @param {OdooEvent} event + */ + _onDeleteColumn: function (event) { + var self = this; + var column = event.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, this.handle) + .done(function () { + if (column.is_empty()) { + self.renderer.remove_widget(column); + self._updateButtons(); + } else { + self.reload(); + } + }); + }, + /** + * This method comes from the field manager mixin. Since there is no + * onchange for kanban, we force a save for every change + * + * @override + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + event.data.force_save = true; + this._super.apply(this, arguments); + }, + /** + * @param {OdooRevent} event + */ + _onLoadMore: function (event) { + var self = this; + var column = event.target; + this.model.load_more(column.db_id).then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + }, + /** + * @param {OdooEvent} event + */ + _onOpenAction: function (event) { + var self = this; + var record = event.target; + if (event.data.context) { + event.data.context = new data.CompoundContext(event.data.context) + .set_eval_context({ + active_id: event.target.id, + active_ids: [event.target.id], + active_model: this.modelName, + }); + } + this.trigger_up('execute_action', { + action_data: event.data, + model: this.modelName, + record_id: event.target.id, + on_close: function () { + self.model.reload(record.db_id).then(function (db_id) { + var data = self.model.get(db_id); + record.update(data); + }); + }, + }); + }, + /** + * @param {OdooEvent} event + */ + _onQuickCreateRecord: function (event) { + var self = this; + var column = event.target; + var data = this.model.get(column.db_id); + function add_record (records) { + return self.model + .addRecordToGroup(data.id, records[0]) + .then(function (db_id) { + column.add_record(self.model.get(db_id), {position: 'before'}); + }); + } + data.context['default_' + data.groupedBy[0]] = column.id; + this.model + .nameCreate(data.model, event.data.value, data.context) + .then(add_record) + .fail(function (event) { + event.preventDefault(); + var popup = new form_common.SelectCreatePopup(this); + popup.select_element( + data.model, + { + title: _t("Create: "), + initial_view: "form", + disable_multiple_selection: true, + }, + [], + { default_name: event.data.value } + ); + popup.on("elements_selected", null, add_record); + }); + }, + /** + * @param {OdooEvent} event + */ + _onRecordDelete: function (event) { + this._deleteRecords([event.data.id]); + }, + /** + * @param {OdooEvent} event + */ + _onResequenceColumn: function (event) { + var grouped_by = this.model.get(this.handle).groupedBy[0]; + var model = this.fields_view.fields[grouped_by].relation; + this.model.resequence(model, event.data.ids, this.handle); + }, + /** + * @param {OdooEvent} event + */ + _onToggleColumn: function (event) { + var self = this; + var column = event.target; + this.model.toggleGroup(column.db_id).then(function (db_id) { + var data = self.model.get(db_id); + self.renderer.update_column(db_id, data); + }); + }, + /** + * @param {OdooEvent} event + */ + _onUpdateRecord: function (event) { + var record = event.target; + this.alive(this.model.save(record.db_id, event.data)) + .then(record.update.bind(record)); + }, +}); + +return KanbanController; + +}); 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 00000000000..8970343c3a9 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -0,0 +1,126 @@ +odoo.define('web.KanbanModel', function (require) { +"use strict"; + +var BasicModel = require('web.BasicModel'); + +return BasicModel.extend({ + load: function (params) { + this.defaultGroupedBy = params.groupBy; + params.groupedBy = params.groupedBy.length ? params.groupedBy : this.defaultGroupedBy; + return this._super(params); + }, + 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); + }, + addRecordToGroup: function (group_id, res_id) { + var group = this.localData[group_id]; + var new_record = this._makeDataPoint({ + res_id: res_id, + modelName: group.model, + fields: group.fields, + fieldNames: group.fieldNames, + fieldAttrs: group.fieldAttrs, + }); + group.data.unshift(new_record); + group.count++; + return this._fetchRecord(new_record) + // .then(this._fetch_relational_data.bind(this)) + .then(function (result) { + return result.id; + }); + }, + moveRecord: function (record_id, group_id, parent_id) { + var self = this; + var parent = this.localData[parent_id]; + var new_group = this.localData[group_id]; + 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 { + changes[groupedFieldName] = new_group.value; + } + return this.notifyChanges(record_id, changes).then(function () { + return self.save(record_id); + }).then(function (result) { + // Remove record from its current group + var old_group; + for (var i = 0; i < parent.count; i++) { + old_group = self.localData[parent.data[i]]; + var index = _.indexOf(old_group.data, record_id); + if (index >= 0) { + old_group.data.splice(index, 1); + old_group.count--; + break; + } + } + // Add record to its new group + new_group.data.push(result.id); + new_group.count++; + return [old_group.id, new_group.id]; + }); + }, + resequence: function (modelName, res_ids, parent_id) { + if ((res_ids.length <= 1)) { + return $.when(parent_id); // there is nothing to sort + } + var self = this; + var data = self.localData[parent_id]; + var def; + if (data.fields.sequence) { + def = this.performRPC('/web/dataset/resequence', { + model: modelName, + ids: res_ids, + // fixme: correctly handle context + }); + } + return $.when(def).then(function () { + data.data = _.sortBy(data.data, function (d) { + return _.indexOf(res_ids, d.res_id); + }); + return parent_id; + }); + }, + 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 $.Deferred().reject(); // only supported when grouped on m2o + } + return this.performModelRPC(groupByField.relation, 'name_create', [name], { + context: parent.context, // todo: combine with view context + }).then(function (result) { + var newGroup = self._makeDataPoint({ + modelName: parent.model, + context: parent.context, + domain: parent.domain.concat([[groupBy,"=",result[0]]]), + fields: parent.fields, + fieldNames: parent.fieldNames, + groupedBy: parent.groupedBy, + isOpen: true, + limit: parent.limit, + openGroupByDefault: true, + orderedBy: parent.orderedBy, + value: result, + }); + + // newGroup.is_open = true; + parent.data.push(newGroup.id); + parent.count++; + return newGroup.id; + }); + }, +}); + +}); diff --git a/addons/web/static/src/js/views/kanban/kanban_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_quick_create.js new file mode 100644 index 00000000000..961ad1bfac3 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_quick_create.js @@ -0,0 +1,140 @@ +odoo.define('web.kanban_quick_create', function (require) { +"use strict"; + +var Widget = require('web.Widget'); + +var RecordQuickCreate = Widget.extend({ + template: "KanbanView.QuickCreate", + + events: { + 'click .o_kanban_cancel': function () { + this.cancel(); + }, + 'click .o_kanban_add': function () { + this.add_record(); + }, + 'keypress input': function (event) { + if (event.keyCode === 13) { + this.add_record(); + } + }, + 'keydown': function (event) { + if (event.keyCode === 27) { + this.cancel(); + } + }, + 'mousedown .o_kanban_add': 'suppress', + 'mousedown .o_kanban_cancel': 'suppress', + }, + + init: function (parent, width) { + this._super.apply(this, arguments); + this.width = width; + }, + + start: function () { + this.$el.css({width: this.width}); + this.$input = this.$('input'); + this.$input.focus(); + return this._super.apply(this, arguments); + }, + + cancel: function () { + this.trigger_up('cancel_quick_create'); + }, + + add_record: function () { + var value = this.$input.val(); + this.$input.val(''); + if (/^\s*$/.test(value)) { return; } + this.trigger_up('quick_create_add_record', {value: value}); + this.$input.focus(); + }, + suppress: function (e) { + e.preventDefault(); + }, +}); + +var ColumnQuickCreate = Widget.extend({ + template: 'KanbanView.ColumnQuickCreate', + + events: { + 'click': 'toggle', + 'click .o_kanban_add': function (event) { + event.stopPropagation(); + this.add_column(); + }, + 'click .o_kanban_cancel': function () { + this.folded = true; + this.update(); + }, + 'click .o_kanban_quick_create': function (event) { + event.stopPropagation(); + }, + 'keypress input': function (event) { + if (event.keyCode === 13) { + this.add_column(); + } + }, + 'keydown': function (event) { + if (event.keyCode === 27) { + this.folded = true; + this.update(); + } + }, + 'focusout': function () { + var hasFocus = this.$(':focus').length > 0; + if (hasFocus) { return; } + + this.folded = true; + this.$input.val(''); + this.update(); + }, + 'mousedown .o_kanban_add': 'suppress', + 'mousedown .o_kanban_cancel': 'suppress', + }, + + init: function () { + this._super.apply(this, arguments); + this.folded = true; + }, + + start: function () { + this.$header = this.$('.o_column_header'); + this.$quick_create = this.$('.o_kanban_quick_create'); + this.$input = this.$('input'); + return this._super.apply(this, arguments); + }, + + toggle: function () { + this.folded = !this.folded; + this.update(); + if (!this.folded) { + this.$input.focus(); + this.trigger_up('scrollTo', {selector: '.o_column_quick_create'}); + } + }, + + update: function () { + this.$header.toggle(this.folded); + this.$quick_create.toggle(!this.folded); + }, + + add_column: function () { + var name = this.$input.val(); + this.$input.val(''); + if (/^\s*$/.test(name)) { return; } + this.trigger_up('quick_create_add_column', {value: name}); + this.$input.focus(); + }, + suppress: function (e) { + e.preventDefault(); + }, +}); + +return { + RecordQuickCreate: RecordQuickCreate, + ColumnQuickCreate: ColumnQuickCreate, +}; + +}); 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 00000000000..b6713fdfc39 --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -0,0 +1,403 @@ +odoo.define('web.KanbanRecord', function (require) { +"use strict"; + +var core = require('web.core'); +var Domain = require('web.Domain'); +var field_registry = require('web.field_registry'); +var field_utils = require('web.field_utils'); +var formats = require('web.formats'); +var framework = require('web.framework'); +var session = require('web.session'); +var time = require('web.time'); +var utils = require('web.utils'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var KanbanRecord = Widget.extend({ + template: 'KanbanView.record', + + events: { + 'click .oe_kanban_action': 'on_kanban_action_clicked', + 'click .o_kanban_manage_toggle_button': 'toggle_manage_pane', + }, + + custom_events: { + 'kanban_update_record': 'update_record', + }, + + init: function (parent, state, options) { + this._super(parent); + + this.fields = state.fields; + this.fieldAttrs = state.fieldAttrs; + this.recordData = state.data; + this.options = options; + this.editable = options.editable; + this.deletable = options.deletable; + this.draggable = options.draggable; + this.read_only_mode = options.read_only_mode; + this.model = options.model; + this.group_info = options.group_info; + this.qweb = options.qweb; + this.sub_widgets = {}; + + this.init_content(state); + }, + + renderElement: function () { + this._super(); + this.setup_color(); + this.setup_color_picker(); + this.$el.addClass('o_kanban_record'); + this.$el.data('record', this); + if (this.$el.hasClass('oe_kanban_global_click') || this.$el.hasClass('oe_kanban_global_click_edit')) { + this.$el.on('click', this.proxy('on_global_click')); + } + }, + + start: function () { + this.add_widgets(); + this.render_m2m_tags(); + this.attach_tooltip(); + return this._super.apply(this, arguments); + }, + + init_content: function (state) { + var self = this; + this.state = state; + this.id = state.res_id; + this.db_id = state.id; + this.values = {}; + _.each(state.data, function (v, k) { + self.values[k] = { + value: v + }; + }); + this.record = this.transform_record(state.data); + var qweb_context = { + record: this.record, + widget: this, + read_only_mode: this.read_only_mode, + user_context: session.user_context, + formats: formats, + }; + for (var p in this) { + if (_.str.startsWith(p, 'kanban_')) { + qweb_context[p] = _.bind(this[p], this); + } + } + this.qweb_context = qweb_context; + this.content = this.qweb.render('kanban-box', qweb_context); + }, + + add_widgets: function () { + var self = this; + this.$("field").each(function () { + var $field = $(this); + var field_name = $field.attr("name"); + var field_widget = $field.attr("widget"); + if (field_widget) { + // a widget is specified for that field + var widget = self.sub_widgets[field_name]; + if (!widget) { + // the widget doesn't exist yet, so instanciate it + var Widget = field_registry.getAny(['kanban.' + field_widget, field_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 attrs = Object.create(null); + _.each(self.state.fieldAttrs[field_name], function (value, key) { + if (_.str.startsWith(key, 't-att-')) { + key = key.slice(6); + value = $field.attr(key); + } + attrs[key] = value; + }); + self.state.fieldAttrs[field_name] = attrs; + + widget = new Widget(self, field_name, self.state, self.options); + self.sub_widgets[field_name] = widget; + self._set_field_display(widget, field_name); + widget.replace($field); + } else { + // a widget already exists for that field, so reset it with the new state + widget.reset(self.state); + $field.replaceWith(widget.$el); + } + } else { + // 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 = self.state.fields[field_name]; + var recordData = self.state.data; + var value = recordData[field_name]; + var options = { data: recordData }; + var formatted_value = field_utils.format_field(value, field, options); + $field.replaceWith(formatted_value); + } + }); + }, + + _set_field_display: function (widget, field_name) { + // attribute display + if (this.fieldAttrs[field_name].display === 'right') { + widget.$el.addClass('pull-right'); + } else if (this.fieldAttrs[field_name].display === 'full') { + widget.$el.addClass('o_text_block'); + } + + // attribute bold + if (this.fieldAttrs[field_name].bold) { + widget.$el.addClass('o_text_bold'); + } + }, + + render_m2m_tags: function () { + var self = this; + _.each(this.recordData, function (values, field_name) { + if (self.fields[field_name].type !== 'many2many') { return; } + var rel_ids = self.record[field_name].raw_value; + var $m2m_tags = self.$('.o_form_field_many2manytags[name=' + field_name + ']'); + _.each(rel_ids, function (id) { + var m2m = _.findWhere(values.data, {res_id: id}).data; + if (typeof m2m.color !== 'undefined' && m2m.color !== 10) { // 10th color is invisible + $('<span>') + .addClass('o_tag o_tag_color_' + m2m.color) + .attr('title', _.str.escapeHTML(m2m.name)) + .appendTo($m2m_tags); + } + }); + }); + // We use boostrap tooltips for better and faster display + this.$('span.o_tag').tooltip({delay: {'show': 50}}); + }, + + transform_record: function (record) { + var self = this; + var new_record = {}; + _.each(this.state.fieldNames, function (name) { + var value = record[name]; + var r = _.clone(self.fields[name] || {}); + + if ((r.type === 'date' || r.type === 'datetime') && value) { + r.raw_value = time.auto_str_to_date(value); + } else if (r.type === 'one2many' || r.type === 'many2one' || r.type === 'many2many') { + r.raw_value = value ? value.res_ids : []; + } else { + r.raw_value = value; + } + + if (r.type) { + var formatter = field_utils['format_' + r.type]; + r.value = formatter(value, self.fields[name], record, self.state); + } else { + r.value = value; + } + + new_record[name] = r; + }); + return new_record; + }, + + update: function (record) { + // 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.sub_widgets, '$el'), 'detach'); + this.init_content(record); + this.renderElement(); + this.add_widgets(); + this.render_m2m_tags(); + this.attach_tooltip(); + }, + + attach_tooltip: function () { + var self = this; + this.$('[tooltip]').each(function () { + var $el = $(this); + var tooltip = $el.attr('tooltip'); + if (tooltip) { + $el.tooltip({ + 'html': true, + 'title': self.qweb.render(tooltip, self.qweb_context) + }); + } + }); + }, + + kanban_image: function (model, field, id, cache, options) { + options = options || {}; + var url; + if (this.record[field] && this.record[field].value && !utils.is_bin_size(this.record[field].value)) { + url = 'data:image/png;base64,' + this.record[field].value; + } else if (this.record[field] && ! this.record[field].value) { + url = "/web/static/src/img/placeholder.png"; + } else { + if (_.isArray(id)) { id = id[0]; } + if (!id) { id = undefined; } + if (options.preview_image) + field = options.preview_image; + var unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, ''); + url = session.url('/web/image', {model: model, field: field, id: id, unique: unique}); + if (cache !== undefined) { + // Set the cache duration in seconds. + url += '&cache=' + parseInt(cache, 10); + } + } + return url; + }, + + kanban_getcolor: function (variable) { + if (typeof(variable) === 'number') { + return Math.round(variable) % 10; + } + if (typeof(variable) === 'string') { + var index = 0; + for (var i=0; i<variable.length; i++) { + index += variable.charCodeAt(i); + } + return index % 10; + } + return 0; + }, + + kanban_color: function (variable) { + var color = this.kanban_getcolor(variable); + return 'oe_kanban_color_' + color; + }, + + on_global_click: function (ev) { + if ($(ev.target).parents('.o_dropdown_kanban').length) { + return; + } + var trigger = true; + var elem = ev.target; + var ischild = true; + var children = []; + while (elem) { + var events = $._data(elem, 'events'); + if (elem === ev.currentTarget) { + ischild = false; + } + var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== "tooltip"); + if (ischild) { + children.push(elem); + if (test_event) { + // do not trigger global click if one child has a click event registered + 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.on_card_clicked(ev); + } + }, + + /* actions when user click on the block with a specific class + * open on normal view : oe_kanban_global_click + * open on form/edit view : oe_kanban_global_click_edit + */ + on_card_clicked: function () { + if (this.$el.hasClass('oe_kanban_global_click_edit') && this.$el.data('routing')) { + framework.redirect(this.$el.data('routing') + "/" + this.id); + } else if (this.$el.hasClass('oe_kanban_global_click_edit')) { + this.trigger_up('edit_record', {id: this.db_id}); + } else { + this.trigger_up('open_record', {id: this.db_id}); + } + }, + + toggle_manage_pane: function (event){ + event.preventDefault(); + this.$('.o_kanban_card_content').toggleClass('o_visible o_invisible'); + this.$('.o_kanban_card_manage_pane').toggleClass('o_visible o_invisible'); + this.$('.o_kanban_manage_button_section').toggleClass(this.kanban_color((this.values['color'] && this.values['color'].value) || 0)); + }, + + on_kanban_action_clicked: function (ev) { + ev.preventDefault(); + + var $action = $(ev.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': + this.trigger_up('kanban_do_action', $action.data()); + break; + default: + this.do_warn("Kanban: no action for type : " + type); + } + }, + + kanban_compute_domain: function (d) { + return new Domain(d).compute(this.values); + }, + + update_record: function (event) { + this.trigger_up('kanban_record_update', event.data); + }, + + /* + * If an attribute `color` is set on the kanban record, + * this will add the corresponding color class. + */ + setup_color: function () { + var color_field = this.$el.attr('color'); + if (color_field && color_field in this.fields) { + this.$el.addClass(this.kanban_color(this.values[color_field].value)); + } + }, + + setup_color_picker: function () { + var self = this; + var $colorpicker = this.$('ul.oe_kanban_colorpicker'); + if (!$colorpicker.length) { + return; + } + $colorpicker.html(QWeb.render('KanbanColorPicker', { + widget: this + })); + $colorpicker.on('click', 'a', function (ev) { + ev.preventDefault(); + var color_field = $colorpicker.data('field') || 'color'; + var data = {}; + data[color_field] = $(this).data('color'); + self.trigger_up('kanban_record_update', data); + }); + }, + +}); + +return KanbanRecord; + +}); 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 00000000000..4259e35d88a --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js @@ -0,0 +1,255 @@ +odoo.define('web.KanbanRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var core = require('web.core'); +var field_registry = require('web.field_registry'); +var KanbanColumn = require('web.KanbanColumn'); +var KanbanRecord = require('web.KanbanRecord'); +var quick_create = require('web.kanban_quick_create'); +var QWeb = require('web.QWeb'); +var session = require('web.session'); +var utils = require('web.utils'); + +var ColumnQuickCreate = quick_create.ColumnQuickCreate; + +var qweb = core.qweb; + +function find_in_node(node, predicate) { + if (predicate(node)) { + return node; + } + if (!node.children) { + return undefined; + } + for (var i = 0; i < node.children.length; i++) { + if (find_in_node(node.children[i], predicate)) { + return node.children[i]; + } + } +} + +function qweb_add_if(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 transform_qweb_template(node, fields) { + // Process modifiers + if (node.tag && node.attrs.modifiers) { + var modifiers = JSON.parse(node.attrs.modifiers || "{}"); + if (modifiers.invisible) { + qweb_add_if(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible))); + } + } + switch (node.tag) { + case 'field': + var ftype = fields[node.attrs.name].type; + ftype = node.attrs.widget ? node.attrs.widget : ftype; + if (ftype === 'many2many') { + node.tag = 'div'; + node.attrs['class'] = (node.attrs['class'] || '') + ' oe_form_field o_form_field_many2manytags o_kanban_tags'; + } else if (field_registry.contains(ftype)) { + // do nothing, the kanban record will handle it + } else { + node.tag = 'span'; + var child_node = { + attrs: {}, + children: [], + tag: qweb.prefix, + }; + child_node.attrs[qweb.prefix + '-esc'] = 'record.' + node.attrs.name + '.value'; + node.children.push(child_node); + + // set class according to field attribute + var child_classes = []; + if (node.attrs.display === 'right') { + child_classes.push('pull-right'); + } else if (node.attrs.display === 'full') { + child_classes.push('o_text_block'); + } + if (node.attrs.bold) { + child_classes.push('o_text_bold'); + } + node.attrs['class'] = child_classes.join(' '); + } + break; + 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++) { + transform_qweb_template(node.children[i], fields); + } + } +} + +return BasicRenderer.extend({ + className: "o_kanban_view", + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.qweb = new QWeb(session.debug, {_s: session.origin}); + var templates = find_in_node(this.arch, function (n) { return n.tag === 'templates';}); + transform_qweb_template(templates, state.fields); + this.qweb.add_template(utils.json_node_to_xml(templates)); + + this.record_options = _.extend({}, params.record_options, { qweb: this.qweb }); + this.column_options = _.extend({}, params.column_options, { qweb: this.qweb }); + }, + update: function (state, fields) { + this.fields = fields; + return this._super(state); + }, + update_record: function (record_state) { + var is_grouped = !!this.state.groupedBy.length; + var record; + + if (is_grouped) { + // 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: record_state.id}); + }); + } else { + record = _.findWhere(this.widgets, {db_id: record_state.id}); + } + + if (record) { + record.update(record_state); + } + }, + update_column: function (column_db_id, data) { + var column = _.findWhere(this.widgets, {db_id: column_db_id}); + this.widgets.splice(_.indexOf(this.widgets, column), 1); // remove column from widgets' list + var new_column = new KanbanColumn(this, data, this.column_options, this.record_options); + this.widgets.push(new_column); + return new_column.insertAfter(column.$el).then(column.destroy.bind(column)); + }, + _renderView: function () { + var is_grouped = !!this.state.groupedBy.length; + this.$el.toggleClass('o_kanban_grouped', is_grouped); + this.$el.toggleClass('o_kanban_ungrouped', !is_grouped); + this.$el.empty(); + + var fragment = document.createDocumentFragment(); + if (is_grouped) { + this._renderGrouped(fragment); + } else { + this._render_ungrouped(fragment); + } + this.$el.append(fragment); + return this._super.apply(this, arguments); + }, + _render_ungrouped: function (fragment) { + var self = this; + _.each(this.state.data, function (record) { + var kanban_record = new KanbanRecord(self, record, self.record_options); + self.widgets.push(kanban_record); + kanban_record.appendTo(fragment); + }); + + // add empty invisible divs to make sure that all kanban records are left aligned + for (var i = 0, ghost_div; i < 6; i++) { + ghost_div = $("<div>").addClass("o_kanban_record o_kanban_ghost"); + ghost_div.appendTo(fragment); + } + }, + _renderGrouped: function (fragment) { + var self = this; + var group_by_field_attrs = this.state.fields[this.state.groupedBy[0]]; + // Deactivate the drag'n'drop if the groupedBy field: + // - is a date or datetime since we group by month or + // - is readonly + var draggable = true; + if (group_by_field_attrs) { + if (group_by_field_attrs.type === "date" || group_by_field_attrs.type === "datetime") { + draggable = false; + } else if (group_by_field_attrs.readonly !== undefined) { + draggable = !(group_by_field_attrs.readonly); + } + } + var grouped_by_m2o = group_by_field_attrs && (group_by_field_attrs.type === 'many2one'); + var grouped_by_field = grouped_by_m2o && group_by_field_attrs.relation; + this.column_options = _.extend(this.column_options, { + draggable: draggable, + grouped_by_m2o: grouped_by_m2o, + relation: grouped_by_field, + }); + + // Render columns + _.each(this.state.data, function (group) { + var column = new KanbanColumn(self, group, self.column_options, self.record_options); + if (!group.value) { + column.prependTo(fragment); // display the 'Undefined' group first + self.widgets.unshift(column); + } else { + column.appendTo(fragment); + self.widgets.push(column); + } + }); + + // Enable column sorting + this.$el.sortable({ + axis: 'x', + items: '> .o_kanban_group', + handle: '.o_kanban_header', + cursor: 'move', + revert: 150, + delay: 100, + tolerance: 'pointer', + forcePlaceholderSize: true, + stop: function () { + var ids = []; + self.$('.o_kanban_group').each(function (index, u) { + ids.push($(u).data('id')); + }); + self.trigger_up('resequence_columns', {ids: ids}); + }, + }); + + // Column quick create + if (this.column_options.group_creatable && grouped_by_m2o) { + this.column_quick_create = new ColumnQuickCreate(this); + this.column_quick_create.appendTo(fragment); + } + }, + add_quick_create: function () { + this.widgets[0].add_quick_create(); + }, + remove_widget: function (widget) { + this.widgets.splice(this.widgets.indexOf(widget), 1); + widget.destroy(); + } +}); + +}); 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 00000000000..5ba06b08dad --- /dev/null +++ b/addons/web/static/src/js/views/kanban/kanban_view.js @@ -0,0 +1,83 @@ +odoo.define('web.KanbanView', function (require) { +"use strict"; + +var BasicView = require('web.BasicView'); +var core = require('web.core'); +var KanbanModel = require('web.KanbanModel'); +var KanbanRenderer = require('web.KanbanRenderer'); +var KanbanController = require('web.KanbanController'); + +var _lt = core._lt; + +var KanbanView = BasicView.extend({ + accesskey: "k", + className: "o_kanban", + display_name: _lt("Kanban"), + icon: 'fa-th-large', + mobile_friendly: true, + config: { + Model: KanbanModel, + Controller: KanbanController, + Renderer: KanbanRenderer, + }, + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var activeActions = this.controllerParams.activeActions; + activeActions = _.extend(activeActions, { + group_edit: arch.attrs.group_edit ? JSON.parse(arch.attrs.group_edit) : true, + group_delete: arch.attrs.group_delete ? JSON.parse(arch.attrs.group_delete) : true, + }); + + this.rendererParams.column_options = { + editable: activeActions.group_edit, + deletable: activeActions.group_delete, + group_creatable: true, + quick_create: params.isQuickCreateEnabled || this._isQuickCreateEnabled(arch), + }; + this.rendererParams.record_options = { + editable: activeActions.edit, + deletable: activeActions.delete, + model: this.model, // required by includes of KanbanRecord + read_only_mode: params.readOnlyMode, + }; + + this.controllerParams.on_create = arch.attrs.on_create; + + this.controllerParams.readOnlyMode = false; + this.controllerParams.hasButtons = true; + + this.loadParams.limit = this.loadParams.limit || 40; + this.loadParams.openGroupByDefault = true; + this.loadParams.type = 'list'; + + this.loadParams.groupBy = arch.attrs.default_group_by ? [arch.attrs.default_group_by] : (params.groupBy || []); + }, + _isQuickCreateEnabled: function (arch) { + if (!this.controllerParams.activeActions.create) { + return false; + } + if (arch.attrs.quick_create !== undefined) { + return JSON.parse(arch.attrs.quick_create); + } + return true; + } + + +}); + +return KanbanView; + +}); + + // config: _.extend({}, AbstractView.prototype.config, { + // openGroupByDefault: true, + // }), + // FIXME: adapt those options + // defaults: _.extend(BasicController.prototype.defaults, { + // quick_creatable: true, + // creatable: true, + // read_only_mode: false, + // confirm_on_delete: true, + // }), + 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 00000000000..881a1eb24ec --- /dev/null +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -0,0 +1,353 @@ +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 pyeval = require('web.pyeval'); +var Sidebar = require('web.Sidebar'); + +var _t = core._t; +var qweb = core.qweb; + +var ListController = BasicController.extend({ + custom_events: _.extend({}, BasicController.prototype.custom_events, { + list_button_clicked: '_onListButtonClicked', + change_mode: '_onChangeMode', + selection_changed: '_onSelectionChanged', + toggle_column_order: '_onToggleColumnOrder', + toggle_group: '_onToggleGroup', + }), + /** + * @constructor + * @override + * @param {Object} params + * @param {Object} params + * @param {boolean} params.editable + * @param {boolean} params.hasSidebar + * @param {boolean} params.hasToolbar + * @param {boolean} params.noLeaf + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + this.hasSidebar = params.hasSidebar; + this.hasToolbar = params.hasToolbar; + this.editable = params.editable; + this.noLeaf = params.noLeaf; + this.selectedRecords = []; // there is no selected record by default + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Calculate the active domain of the list view. This should be done only + * if the header checkbox has been checked. This is done by evaluating the + * search results, and then adding the dataset domain (i.e. action domain). + * + * @todo This is done only for the sidebar. The full mechanism is wrong, + * this method should be private, most of the code in the sidebar should be + * moved to the controller, and we should not use the getParent method... + * + * @returns {Deferred<array[]>} a deferred that resolve to the active domain + */ + getActiveDomain: function () { + // TODO: this method should be synchronous... + var self = this; + if (this.$('thead .o_list_record_selector input').prop('checked')) { + var searchView = this.getParent().searchview; // fixme + var searchData = searchView.build_search_data(); + var userContext = this.getSession().user_context; + var results = pyeval.eval_domains_and_contexts({ + domains: searchData.domains, + contexts: [userContext].concat(searchData.contexts), + group_by_seq: searchData.groupbys || [] + }); + return $.when(self.dataset.domain.concat(results.domain || [])); + } else { + return $.Deferred().resolve(); + } + }, + /** + * Returns the list of currently selected res_ids (with the check boxes on + * the left) + * + * @todo This should be private. Need to change sidebar code + * + * @returns {number[]} list of res_ids + */ + getSelectedIds: function () { + var self = this; + return _.map(this.selectedRecords, function (db_id) { + return self.model.get(db_id, {raw: true}).res_id; + }); + }, + /** + * Display and bind all buttons in the control panel + * + * @override + * @param {jQuery Node} $node + */ + renderButtons: function ($node) { + if (!this.noLeaf && this.hasButtons) { + this.$buttons = $(qweb.render("ListView.buttons", {'widget': this})); + this.$buttons.find('.o_list_button_add') + .click(this._onCreateRecord.bind(this)); + this.$buttons.find('.o_list_button_save') + .click(this._onSave.bind(this)); + this.$buttons.find('.o_list_button_discard') + .click(this._onDiscard.bind(this)); + this.$buttons.appendTo($node); + } + }, + /** + * Render the sidebar (the 'action' menu in the control panel, right of the + * main buttons) + * + * @param {jQuery Node} $node + */ + renderSidebar: function ($node) { + if (this.hasSidebar && !this.sidebar) { + this.sidebar = new Sidebar(this, { + editable: this.is_action_enabled('edit') + }); + if (this.hasToolbar) { + this.sidebar.add_toolbar(this.fields_view.toolbar); + } + var other = [{ + label: _t("Export"), + callback: this._onExportData.bind(this) + }]; + if (this.archiveEnabled) { + other.push({ + label: _t("Archive"), + callback: this._onToggleArchiveState.bind(this, true) + }); + other.push({ + label: _t("Unarchive"), + callback: this._onToggleArchiveState.bind(this, false) + }); + } + if (this.is_action_enabled('delete')) { + other.push({ + label: _t('Delete'), + callback: this._onDeleteSelectedRecords.bind(this) + }); + } + this.sidebar.add_items('other', other); + this.sidebar.appendTo($node); + + this._toggleSidebar(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _archive: function (ids, archive) { + if (ids.length === 0) { + return $.when(); + } + return this.model + .toggleActive(ids, !archive, this.handle) + .then(this.update_state.bind(this)); + }, + /** + * This function is the hook called by the field manager mixin to confirm + * that a change just occured (after the eventual onchange have been + * applied). + * + * @param {string} id a basicmodel id, which is a record in the list view + * @param {string[]} fields the changed fields + */ + _confirmChange: function (id, fields) { + var state = this.model.get(this.handle); + this.renderer.confirmChange(state, id, fields); + }, + /** + * 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. + */ + _confirmSave: function (id) { + var state = this.model.get(this.handle); + this.renderer.confirmSave(state, id); + }, + /** + * Display the sidebar (the 'action' menu in the control panel) if we have + * some selected records. + */ + _toggleSidebar: function () { + if (this.sidebar) { + this.sidebar.do_toggle(this.selectedRecords.length > 0); + } + }, + /** + * @override + * @param {Object} state + * @returns {Deferred} + */ + _update: function (state) { + this._toggleNoContentHelper(state.count === 0); + this._toggleSidebar(); + return this._super.apply(this, arguments); + }, + /** + * This helper simply makes sure that the control panel buttons matches the + * current mode. + * + * @param {string} mode either 'readonly' or 'edit' + */ + _updateButtons: function (mode) { + if (this.$buttons) { + this.$buttons.toggleClass('o-editing', mode === 'edit'); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles a click on a button by performing its action. + * + * @param {OdooEvent} event + */ + _onListButtonClicked: function (event) { + this.trigger_up('execute_action', { + action_data: event.data.attrs, + model: this.modelName, + record_id: event.data.record.res_id, + }); + }, + /** + * This event is triggered when a list renderer goes from mode = readonly to + * edit, and vice versa. In that case, we need to make sure that the buttons + * displayed in the control panel are correct. + * + * @param {OdooEvent} event + */ + _onChangeMode: function (event) { + this._updateButtons(event.data.mode); + }, + /** + * 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. + * + * @param {MouseEvent} event + */ + _onCreateRecord: function (event) { + // 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 + event.stopPropagation(); + + var self = this; + if (this.editable) { + this.model.addDefaultRecord(this.handle).then(function (recordID) { + self._toggleNoContentHelper(false); + var state = self.model.get(self.handle); + self.renderer.updateState(state); + self.renderer.editRecord(recordID); + }); + } else { + this.trigger_up('switch_view', {view_type: 'form', res_id: undefined}); + } + }, + /** + * Called when the 'delete' action is clicked on in the side bar. + */ + _onDeleteSelectedRecords: function () { + this._deleteRecords(this.selectedRecords); + }, + /** + * Handler called when the user clicked on the 'Discard' button. + */ + _onDiscard: function () { + this.model.discardChanges(this.handle); + this.update(this.handle, {reload: false}); + }, + /** + * Opens the Export Dialog + */ + _onExportData: function () { + new DataExport(this, this.dataset).open(); + }, + /** + * changes of the list editable are automatically saved when unselecting the + * row, which is done when clicking on 'Save' (anywhere outside the row + * actually), so this function should only switch back to readonly mode + * + * @param {MouseEvent} event + */ + _onSave: function (event) { + // 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 + // event.stopPropagation(); + this._updateButtons('readonly'); + }, + /** + * When the current selection changes (by clicking on the checkboxes on the + * left), we need to display (or hide) the 'sidebar'. + * + * @param {OdooEvent} event + */ + _onSelectionChanged: function (event) { + this.selectedRecords = event.data.selection; + this._toggleSidebar(); + }, + /** + * Called when clicking on 'Archive' or 'Unarchive' in the sidebar. + * + * @param {boolean} archive + */ + _onToggleArchiveState: function (archive) { + this._archive(this.selectedRecords, archive); + }, + /** + * 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. + * + * @param {OdooEvent} event + */ + _onToggleColumnOrder: function (event) { + var data = this.model.get(this.handle); + if (!data.groupedBy) { + this.pager.updateState({current_min: 1}); + } + this.model + .setSort(data.id, event.data.name) + .reload(data.id) + .then(this.update.bind(this)); + }, + /** + * 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. + * + * @param {OdooEvent} event + */ + _onToggleGroup: function (event) { + this.model + .toggleGroup(event.data.group.id) + .then(this.update.bind(this, {}, {reload: false})); + }, +}); + +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 00000000000..85efc1b6d52 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -0,0 +1,649 @@ +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 + * behavior 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 Domain = require('web.Domain'); +var ListRenderer = require('web.ListRenderer'); +var field_registry = require('web.field_registry'); + +var _t = core._t; + +ListRenderer.include({ + custom_events: { + field_changed: '_onFieldChanged', + move_down: '_onMoveDown', + move_up: '_onMoveUp', + move_left: '_onMoveLeft', + move_right: '_onMoveRight', + move_next: '_onMoveNext', + move_next_line: '_onMoveNextLine', + }, + events: _.extend({}, ListRenderer.prototype.events, { + 'click tbody td': '_onCellClick', + 'click tbody tr:not(.o_data_row)': '_onEmptyRowClick', + 'click tfoot': '_onFooterClick', + 'click tr .o_list_record_delete': '_onTrashIconClick', + 'click .o_form_field_x2many_list_row_add a': '_onAddRecord', + }), + /** + * @override + * @param {Object} params + * @param {string} params.mode either 'readonly' or 'edit' + * @param {boolean} params.addCreateLine + * @param {boolean} params.addTrashIcon + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + + this.mode = params.mode; + + // if addCreateLine is true, the renderer will add a 'Add an item' link + // at the bottom of the list view + this.addCreateLine = params.addCreateLine; + + // 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; + + this.currentRow = null; + this.currentCol = null; + }, + /** + * @override + * @returns {Deferred} + */ + start: function () { + if (this.mode === 'edit') { + this.$el.css({height: '100%'}); + core.bus.on('click', this, this._onWindowClicked.bind(this)); + } + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * This method is called by the controller, when a change has been confirmed + * by the model. In that situation, we need to properly update the cell + * widgets. (it is necessary, because the value could have been changed by + * an onchange). + * + * Note that it could happen that the changedRecordID is not found in the + * current state. This is possible if a change in a record caused an + * onchange which erased the current record. In that case, we simply + * rerender the list. + * + * @param {Object} state a record resource (the new full state) + * @param {string} changedRecordID the local id for the changed record + * @param {string[]} changedFields list of modified fields + */ + confirmChange: function (state, changedRecordID, changedFields) { + this.state = state; + var rowIndex = _.findIndex(this.state.data, {id: changedRecordID}); + if (rowIndex > -1) { + this._updateRow(rowIndex, changedFields); + } else { + this._render(); + } + }, + /** + * This method is called by the controller when the user has clicked 'save', + * and the model as actually saved the current changes. We then need to + * set the correct values for each cell. The difference with confirmChange + * is that the edited line is now in readonly mode, so we can just set its + * new value. + * + * @param {Object} state the new full state for the list + * @param {string} savedRecordID the id for the saved record + */ + confirmSave: function (state, savedRecordID) { + this.state = state; + var index = _.findIndex(this.state.data, {id: savedRecordID}); + for (var j = 0; j < this.columns.length; j++) { + this._setCellValue(index, j, false); + } + }, + /** + * Edit a given record in the list + * + * @param {string} recordID + */ + editRecord: function (recordID) { + var index = _.findIndex(this.state.data, function (record) { + return record.id === recordID; + }); + this._selectCell(index, 0); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Find the td in a row corresponding the a field with a given index. The + * main problem is that in some cases, we have a 'selector' checkbox, so the + * correct td is sometimes at position i, sometimes at i+1 + * + * @param {jQueryElement} $row the $tr for the row we are interested in + * @param {number} index the index of the field/column + * @returns {jQueryElement} the $td for the index-th field + */ + _findTd: function ($row, index) { + return $row.find('td').eq(index + (this.hasSelectors ? 1 : 0)); + }, + /** + * 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; + }, + /** + * Determine if a given cell is readonly. A cell is considered readonly if + * the field is readonly, or the widget is statically readonly (like the + * sequence widget) of if there are a 'readonly' attr, with a matching + * domain. + * + * @param {Object} record a basic model record + * @param {Object} field a field description + * @param {Object} node a node object (from the arch) + * @returns {boolean} + */ + _isReadonly: function (record, field, node) { + if (field.readonly || (node.Widget && node.Widget.prototype.readonly)) { + return true; + } + if ('readonly' in node.modifiers) { + var fieldValues = this._getFieldValues(record); + return new Domain(node.modifiers.readonly).compute(fieldValues); + } + return false; + }, + /** + * A cell is editable if its field isn't invisible and if it isn't readonly. + * + * @param {Object} record a basic model record + * @param {Object} field a field description + * @param {Object} node a node object (from the arch) + * @returns {boolean} + */ + _isEditable: function (record, field, node) { + return !this._isInvisible(record, node) && !this._isReadonly(record, field, node); + }, + /** + * The renderer needs to support reordering lines. This is only active in + * edit mode. The hasHandle attribute is used when there is a sequence + * widget. + * + * @override + * @returns {jQueryElement} + */ + _renderBody: function () { + var $body = this._super(); + if (this.mode === 'edit' && this.hasHandle) { + $body.sortable({ + axis: 'y', + items: '> tr.o_data_row', + helper: 'clone', + handle: '.o_row_handle', + }); + } + return $body; + }, + /** + * Add all the necessary styling classes to displayed cells. Also, we need + * to add the current index to each cell data attribute, so we can get it + * back when we need to select a cell. + * + * @override + * @param {Object} record + * @param {Object} node + * @param {integer} index + * @returns {jQueryElement} + */ + _renderBodyCell: function (record, node, index) { + if (this.mode === 'readonly') { + return this._super.apply(this, arguments); + } + var $cell = this._super(record, node); + if (node.tag === 'field') { + var field = this.state.fields[node.attrs.name]; + if (field.required) { + $cell.addClass('o_required_field'); + } + if (this._isReadonly(record, field, node)) { + $cell.addClass('o_readonly'); + } + } + return $cell.data('index', index); + }, + /** + * Helper method, to instantiate and render a field widget. It is only + * called by _selectCell, when editing a row. It kind of looks like the + * _renderBodyCell method of ListRenderer, but it has to properly tag the + * widget and to do other subtly different tasks, so it is awkward to try to + * merge them. + * + * @private + * @param {jQueryElement} $row + * @param {Object} node + * @param {integer} rowIndex + * @param {integer} colIndex + * @returns {AbstractField} the instance of the desired widget + */ + _renderFieldWidget: function ($row, node, rowIndex, colIndex) { + if (node.tag === 'button') { + return; + } + var record = this.state.data[rowIndex]; + var field = this.state.fields[node.attrs.name]; + if (!this._isEditable(record, field, node)) { + return; + } + var $td = this._findTd($row, colIndex); + // force the width of the cell to its current value so that it won't + // change if its content changes (to prevent the list view from + // flickering). + $td.width($td.width()); + var widget_keys = ['list.' + field.type, field.type]; + if (node.attrs.widget) { + widget_keys.unshift(node.attrs.widget); + widget_keys.unshift('list.' + node.attrs.widget); + } + var Widget = field_registry.getAny(widget_keys); + var widget = new Widget(this, node.attrs.name, record, { + mode: 'edit' + }); + if (widget.replace_element) { + $td.empty(); + } + $td.addClass('o_edit_mode'); + widget.appendTo($td); + widget.__rowIndex = rowIndex; + widget.__colIndex = colIndex; + this.widgets.push(widget); + return widget; + }, + /** + * Editable rows are extended with their index, and possibly a trash icon on + * their right, to allow deleting the corresponding record. + * + * @override + * @param {any} record + * @param {any} index + * @returns {jQueryElement} + */ + _renderRow: function (record, index) { + var $row = this._super.apply(this, arguments); + if (this.mode === 'edit') { + $row.data('index', index); + } + if (this.addTrashIcon) { + var $icon = $('<span>').addClass('fa fa-trash-o').attr('name', 'delete'); + var $td = $('<td>').addClass('o_list_record_delete').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. + * + * @override + * @returns {jQueryElement} + */ + _renderRows: function () { + var $rows = this._super(); + if (this.addCreateLine) { + var $a = $('<a href="#">').text(_t("Add an item")); + var $td = $('<td>') + .attr('colspan', this._getNumberOfCols()) + .addClass('o_form_field_x2many_list_row_add') + .append($a); + var $tr = $('<tr>').append($td); + $rows.push($tr); + } + return $rows; + }, + /** + * 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 terget 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} colIndex + */ + _selectCell: function (rowIndex, colIndex) { + var self = this; + + // do nothing if user tries to select current cell + if (rowIndex === this.currentRow && colIndex === this.currentCol) { + return; + } + + // make sure the colIndex is on an editable field. otherwise, find + // next editable field, or do nothing if no such field exists + var data = this.state.data; + var fields = this.state.fields; + var col, field, isEditable, record; + for (var i = 0; i < this.columns.length; i++) { + col = this.columns[colIndex]; + record = data[rowIndex]; + field = fields[col.attrs.name]; + isEditable = this._isEditable(record, field, col); + if (isEditable) { + break; + } + colIndex = (colIndex + 1) % this.columns.length; + } + if (!isEditable) { + return; + } + + // if we are just changing active cell in the same row, activate the + // corresponding widget and return + if (rowIndex === this.currentRow && colIndex !== this.currentCol) { + var w = _.findWhere(this.widgets, { + __rowIndex: rowIndex, + __colIndex: colIndex + }); + this.currentCol = colIndex; + w.activate(); + return; + } + + // if this is a different column, then we need to prepare for it by + // unselecting the current row. This will trigger the save. + if (rowIndex !== this.currentRow) { + this._unselectRow(); + } + + // instantiate column widgets + var $row = this.$('tbody tr').eq(rowIndex); + $row.addClass('o_selected_row'); + + _.each(this.columns, function (node, index) { + var widget = self._renderFieldWidget($row, node, rowIndex, index); + if (widget && index === colIndex) { + widget.activate(); + } + }); + + this.currentRow = rowIndex; + this.currentCol = colIndex; + this.trigger_up('change_mode', {mode: 'edit'}); + }, + /** + * Set the value of a cell. This method can be called when the value of a + * cell was modified, for example afte an onchange. + * + * @param {integer} rowIndex + * @param {integer} colIndex + * @param {boolean} keepDirtyFlag + * @param {boolean} forceDirtyFlag + */ + _setCellValue: function (rowIndex, colIndex, keepDirtyFlag, forceDirtyFlag) { + var record = this.state.data[rowIndex]; + var node = this.columns[colIndex]; + var $td = this._findTd(this.$('tbody tr').eq(rowIndex), colIndex); + var $new_td = this._renderBodyCell(record, node, colIndex); + if (keepDirtyFlag && $td.hasClass('o_field_dirty') || forceDirtyFlag) { + $new_td.addClass('o_field_dirty'); + } + $td.replaceWith($new_td); + }, + _updateRow: function (index, changed_fields) { + var self = this; + var record = this.state.data[index]; + _.each(changed_fields, function (field_name) { + var widget = _.findWhere(self.widgets, {name: field_name}); + if (widget && widget.__rowIndex === index) { + widget.reset(record); + widget.$el.parent().addClass('o_field_dirty'); + } else { + var column_index = _.findIndex(self.columns, function (column) { + return column.attrs.name === field_name; + }); + self._setCellValue(index, column_index, true, true); + } + }); + }, + /** + * 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. + */ + _unselectRow: function () { + var self = this; + if (this.currentRow === null) { + return; + } + // trigger the save (if the record isn't dirty, the datamodel won't save + // anything) + this.trigger_up('field_changed', { + dataPointID: this.state.data[this.currentRow].id, + changes: {}, + force_save: true, + }); + this.trigger_up('change_mode', {mode: 'readonly'}); + var widgets = _.where(this.widgets, {__rowIndex: this.currentRow}); + _.each(widgets, function (widget) { + self._setCellValue(self.currentRow, widget.__colIndex, true); + widget.destroy(); + }); + self.widgets = _.without(self.widgets, widgets); + var $row = this.$('tbody tr').eq(this.currentRow); + $row.removeClass('o_selected_row'); + $row.find('td').removeClass('o_edit_mode'); + this.currentRow = null; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * This method is called when we click on the 'Add an Item' button in a sub + * list such as a one2many in a form view. + * + * @param {MouseEvent} event + */ + _onAddRecord: function (event) { + // we don't want the browser to navigate to a the # url + event.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 + event.stopPropagation(); + + this.trigger_up('add_record'); + }, + /** + * When the user clicks on a cell, we simply select it. + * + * @param {MouseEvent} event + */ + _onCellClick: function (event) { + var $td = $(event.currentTarget); + var rowIndex = $td.parent().data('index'); + var colIndex = $td.data('index'); + if (colIndex !== undefined && rowIndex !== undefined) { + this._selectCell(rowIndex, colIndex); + event.stopPropagation(); + } + // FIXME: this seems like dead code + // if (this.state.groupedBy.length) { + // var id = $td.parent().data('id'); + // if (id) { + // this.trigger_up('record_selected', {id:id}); + // } + // } + }, + /** + * We need to manually unselect row, because noone else would do it + */ + _onEmptyRowClick: function (event) { + this._unselectRow(); + }, + /** + * We react to field_changed events by adding the .o_field_dirty css class, + * which changes its style to a visible orange outline. It allows the user + * to have a visual feedback on which fields changed, and on which fields + * have been saved. + * + * @param {OdooEvent} event + */ + _onFieldChanged: function (event) { + var $td = event.target.$el.parent(); + $td.addClass('o_field_dirty'); + }, + /** + * 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(); + }, + /** + * Move the cursor on the cell just down the current cell. + */ + _onMoveDown: function () { + if (this.currentRow < this.state.data.length - 1) { + this._selectCell(this.currentRow + 1, this.currentCol); + } + }, + /** + * Move the cursor on the left, if possible + */ + _onMoveLeft: function () { + if (this.currentCol > 0) { + this._selectCell(this.currentRow, this.currentCol - 1); + } + }, + /** + * Move the cursor on the next available cell, so either the next available + * cell on the right, or the first on the next line. This method is called + * when the user press the TAB key. + */ + _onMoveNext: function () { + if (this.currentCol < this.columns.length - 1) { + this._selectCell(this.currentRow, this.currentCol + 1); + } else if (this.currentRow < this.state.data.length - 1) { + this._selectCell(this.currentRow + 1, 0); + } + }, + /** + * Move the cursor one the beginning of the next line, if possible. This is + * called when the user press the ENTER key. + */ + _onMoveNextLine: function () { + if (this.currentRow < this.state.data.length - 1) { + this._selectCell(this.currentRow + 1, 0); + } + }, + /** + * Move the cursor one cell right, if possible + */ + _onMoveRight: function () { + if (this.currentCol < this.columns.length - 1) { + this._selectCell(this.currentRow, this.currentCol + 1); + } + }, + /** + * Move the cursor one line up + */ + _onMoveUp: function () { + if (this.currentRow > 0) { + this._selectCell(this.currentRow - 1, this.currentCol); + } + }, + /** + * Triggers a delete event. I don't know why we stop the propagation of the + * event. + * + * @param {MouseEvent} event + */ + _onTrashIconClick: function (event) { + event.stopPropagation(); + var id = $(event.target).closest('tr').data('id'); + this.trigger_up('list_record_delete', {id: id}); + }, + /** + * 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 if this renderer is not in the dom. + if (!document.contains(this.el)) { + return; + } + + // there is currenctly no selected row + if (this.currentRow === null) { + return; + } + + // ignore clicks in autocomplete dropdowns + if ($(event.target).parents('.ui-autocomplete').length) { + return; + } + + // ignore clicks in modals, except if the list is in a modal, and the + // click is performed in that modal + var $list_modal = this.$el.closest('.modal'); + if ($list_modal.length) { + var $click_modal = $(event.target).parents('.modal'); + if ($click_modal.prop('id') === $list_modal.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; + } + + this._unselectRow(); + }, + +}); + +}); 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 00000000000..e3ec4637658 --- /dev/null +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -0,0 +1,583 @@ +odoo.define('web.ListRenderer', function (require) { +"use strict"; + +var BasicRenderer = require('web.BasicRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var Domain = require('web.Domain'); +var field_registry = require('web.field_registry'); +var field_utils = require('web.field_utils'); +var session = require('web.session'); +var utils = require('web.utils'); + +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 = { + 'monetary': 'o_list_number', + 'text': 'o_list_text', + 'float': 'o_list_number', +}; + +var ListRenderer = BasicRenderer.extend({ + className: 'table-responsive', + events: { + 'click tbody tr': '_onRowClicked', + 'click tbody .o_list_record_selector': '_onSelectRecord', + 'click thead th.o_column_sortable': '_onSortColumn', + 'click .o_group_header': '_onToggleGroup', + 'click thead .o_list_record_selector input': '_onToggleSelection', + }, + /** + * @constructor + * @param {Widget} parent + * @param {any} state + * @param {Object} params + * @param {boolean} params.hasSelectors + */ + init: function (parent, state, params) { + this._super.apply(this, arguments); + var self = this; + this.hasHandle = false; + this.columns = _.reject(this.arch.children, function (c) { + if (c.attrs.invisible === '1') { + return true; + } + var modifiers = JSON.parse(c.attrs.modifiers || '{}'); + if (modifiers.tree_invisible) { + return true; + } + if (c.attrs.widget === 'handle') { + self.hasHandle = true; + } + var keys = ['list.' + c.attrs.widget, c.attrs.widget]; + c.Widget = field_registry.getAny(keys); + c.modifiers = modifiers; + return false; + }); + this.rowDecorations = _.chain(this.arch.attrs) + .pick(function (value, key) { + return DECORATIONS.indexOf(key) >= 0; + }).mapObject(function (value) { + return py.parse(py.tokenize(value)); + }).value(); + this.hasSelectors = params.hasSelectors; + this.selection = []; + }, + + //-------------------------------------------------------------------------- + // 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. + */ + _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; + } + if (data.length === 0) { + return; + } + + _.each(this.columns, function (column) { + var attrs = column.attrs; + var field = self.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 aggregate_value = (func === 'max') ? -Infinity : (func === 'min') ? Infinity : 0; + _.each(data, function (d) { + count += 1; + var value = (d.type === 'record') ? d.data[attrs.name] : d.aggregateValues[attrs.name]; + if (func === 'avg') { + aggregate_value += value; + } else if (func === 'sum') { + aggregate_value += value; + } else if (func === 'max') { + aggregate_value = Math.max(aggregate_value, value); + } else if (func === 'min') { + aggregate_value = Math.min(aggregate_value, value); + } + }); + if (func === 'avg') { + aggregate_value = aggregate_value / count; + } + column.aggregate = { + help: attrs[func], + value: aggregate_value, + }; + } + }); + }, + /** + * Each line can be decorated according to a few simple rules. The arch + * description of the list may have one of the decoration-X attribute with + * a domain as value. Then, for each record, we check if the domain matches + * the record, and 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. + * + * @param {Object} record a basic model record + * @returns {string[]} a list of css classes + */ + _computeDecorationClassNames: function (record) { + var context = _.extend({}, record.data, { + uid: session.uid, + current_date: moment().format('YYYY-MM-DD') + // TODO: time, datetime, relativedelta + }); + return _.chain(this.rowDecorations) + .pick(function (expr) { + return py.PY_isTrue(py.evaluate(expr, context)); + }).map(function (expr, decoration) { + return decoration.replace('decoration', 'text'); + }).value(); + }, + /** + * When a list view is grouped, we need to display the name of each group in + * the 'title' row. This is the purpose of this method. + * + * @param {any} value + * @param {Object} field a field description + * @returns {string} + */ + _formatValue: function (value, field) { + if (field && field.type === 'selection') { + var choice = _.find(field.selection, function (c) { + return c[0] === value; + }); + return choice[1]; + } + return value || _t('Undefined'); + }, + /** + * 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'. + * + * @returns {number} + */ + _getNumberOfCols: function () { + var n = this.columns.length; + return this.hasSelectors ? n+1 : n; + }, + /** + * Determine if a given cell is invisible. A cell is considered invisible + * if there is an 'invisible' attr, with a matching domain. + * + * @param {Object} record a basic model record + * @param {Object} node a node object (from the arch) + * @returns {boolean} + */ + _isInvisible: function (record, node) { + if ('invisible' in node.modifiers) { + var fieldValues = this._getFieldValues(record); + return new Domain(node.modifiers.invisible).compute(fieldValues); + } + return false; + }, + /** + * Render a list of <td>, with aggregates if available. It can be displayed + * in the footer, or for each open groups. + * + * @param {any} aggregate_values + * @returns {jQueryElement[]} a list of <td> with the aggregate values + */ + _renderAggregateCells: function (aggregate_values) { + var self = this; + return _.map(this.columns, function (column) { + var $cell = $('<td>'); + if (column.attrs.name in aggregate_values) { + var field = self.state.fields[column.attrs.name]; + var value = aggregate_values[column.attrs.name].value; + var help = aggregate_values[column.attrs.name].help; + var formattedValue = field_utils['format_' + field.type](value, field, {}); + $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. + * + * @returns {jQueryElement} a jquery element <tbody> + */ + _renderBody: function () { + var $rows = this._renderRows(); + while ($rows.length < 4) { + $rows.push(this._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. + * + * @param {Object} record + * @param {Object} node + * @returns {jQueryElement} a <td> element + */ + _renderBodyCell: function (record, node) { + var self = this; + var $td = $('<td>'); + if (this._isInvisible(record, node)) { + return $td; + } + if (node.tag === 'button') { + var $button = $('<button type="button">').addClass('o_icon_button'); + $button.append($('<i>').addClass('fa').addClass(node.attrs.icon)) + .prop('title', node.attrs.string) + .click(function (e) { + e.stopPropagation(); + self.trigger_up('list_button_clicked', { + attrs: node.attrs, + record: record, + }); + }); + $td.append($button); + $td.addClass('o_list_button').click(function (e) { + // prevent opening or editing the record on cell click + e.stopPropagation(); + }); + return $td; + } + var field = this.state.fields[node.attrs.name]; + var value = record.data[node.attrs.name]; + if (node.Widget) { + var widget = new node.Widget(this, node.attrs.name, record, { + mode: 'readonly', + }); + widget.appendTo($td); + $td.addClass('o_' + node.attrs.widget + '_cell'); + return $td; + } + $td.addClass(FIELD_CLASSES[field.type]); + var formatted_value = field_utils.format_field(value, field, { data: record.data }); + return $td.html(formatted_value); + }, + /** + * Render a complete empty row. This is used to fill in the blanks when we + * have less than 4 lines to display. + * + * @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. + * + * @param {boolean} isGrouped if the view is grouped, we have to add an + * extra <td> + * @returns {jQueryElement} a <tfoot> element + */ + _renderFooter: function (isGrouped) { + var aggregates = {}; + _.each(this.columns, function (column) { + if ('aggregate' in column) { + aggregates[column.attrs.name] = column.aggregate; + } + }); + var $cells = this._renderAggregateCells(aggregates); + if (isGrouped) { + $cells.unshift($('<td>')); + } + if (this.hasSelectors) { + $cells.unshift($('<td>')); + } + return $('<tfoot>').append($('<tr>').append($cells)); + }, + /** + * Render the row that represent a group + * + * @param {Object} group + * @returns {jQueryElement} a <tr> element + */ + _renderGroupRow: function (group) { + var aggregate_values = _.mapObject(group.aggregateValues, function (value) { + return { value: value }; + }); + var $cells = this._renderAggregateCells(aggregate_values); + if (this.hasSelectors) { + $cells.unshift($('<td>')); + } + var field = this.state.fields[group.groupedBy[0]]; + var name = this._formatValue(group.value, field); + var $th = $('<th>') + .addClass('o_group_name') + .text(name + ' (' + group.count + ')'); + if (group.count > 0) { + var $arrow = $('<span style="padding-right: 5px;">') + .addClass('fa') + .toggleClass('fa-caret-right', !group.isOpen) + .toggleClass('fa-caret-down', group.isOpen); + $th.prepend($arrow); + } + return $('<tr>') + .addClass('o_group_header') + .data('group', group) + .append($th) + .append($cells); + }, + /** + * 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. + * + * @returns {jQueryElement[]} a list of <tbody> + */ + _renderGroups: function () { + var self = this; + var result = []; + var $tbody = $('<tbody>'); + _.each(this.state.data, function (group) { + if (!$tbody) { + $tbody = $('<tbody>'); + } + $tbody.append(self._renderGroupRow(group)); + if (group.data.length) { + result.push($tbody); + var $records = _.map(group.data, function (record) { + return self._renderRow(record).prepend($('<td>')); + }); + result.push($('<tbody>').append($records)); + $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 + * + * @param {boolean} isGrouped + * @returns {jQueryElement} a <thead> element + */ + _renderHeader: function (isGrouped) { + var $tr = $('<tr>') + .append(_.map(this.columns, this._renderHeaderCell.bind(this))); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('th')); + } + if (isGrouped) { + $tr.prepend($('<th>').html(' ')); + } + return $('<thead>').append($tr); + }, + /** + * Render a single <th> with the informations for a column. If it is not a + * field, the th will be empty. Otherwise, it will contains all relevant + * information for the field. + * + * @param {Object} node + * @returns {jQueryElement} a <th> element + */ + _renderHeaderCell: function (node) { + var order = this.state.orderedBy; + var isNodeSorted = order[0] && order[0].name === node.attrs.name; + var field = this.state.fields[node.attrs.name]; + var $th = $('<th>'); + if (!field) { + return $th; + } + var description; + if (node.Widget) { + description = node.Widget.prototype.description; + } + if (description === undefined) { + description = node.attrs.string || field.string; + } + $th + .text(description) + .data('name', node.attrs.name) + .toggleClass('o-sort-down', isNodeSorted ? !order[0].asc : false) + .toggleClass('o-sort-up', isNodeSorted ? order[0].asc : false) + .addClass(field.sortable && 'o_column_sortable'); + + if (field.type === 'float' || field.type === 'monetary') { + $th.css({textAlign: 'right'}); + } + + if (config.debug) { + var fieldDescr = { + field: field, + name: node.attrs.name, + string: description || node.attrs.name, + record: this.state, + attrs: node.attrs, + }; + this._addFieldTooltip(fieldDescr, $th); + } + return $th; + }, + /** + * Render a row, corresponding to a record. + * + * @param {Object} record + * @returns {jQueryElement} a <tr> element + */ + _renderRow: function (record) { + var decorations = this._computeDecorationClassNames(record); + var $cells = _.map(this.columns, this._renderBodyCell.bind(this, record)); + var $tr = $('<tr class="o_data_row">') + .data('id', record.id) + .addClass(decorations.length && decorations.join(' ')) + .append($cells); + if (this.hasSelectors) { + $tr.prepend(this._renderSelector('td')); + } + return $tr; + }, + /** + * Render all rows. This method should only called when the view is not + * grouped. + * + * @returns {jQueryElement[]} a list of <tr> + */ + _renderRows: function () { + return _.map(this.state.data, this._renderRow.bind(this)); + }, + _renderSelector: function (tag) { + var $content = $('<div class="o_checkbox"><input type="checkbox"><span/></div>'); + return $('<' + tag + ' width="1">') + .addClass('o_list_record_selector') + .append($content); + }, + /** + * @override + * returns {Deferred} + */ + _renderView: function () { + var self = this; + var $table = $('<table>').addClass('o_list_view table table-condensed table-striped'); + this.$el.empty().append($table); + var is_grouped = !!this.state.groupedBy.length; + this._computeAggregates(); + $table.toggleClass('o_list_view_grouped', is_grouped); + $table.toggleClass('o_list_view_ungrouped', !is_grouped); + if (is_grouped) { + $table + .append(this._renderHeader(true)) + .append(this._renderGroups()) + .append(this._renderFooter(true)); + } else { + $table + .append(this._renderHeader()) + .append(this._renderBody()) + .append(this._renderFooter()); + } + if (this.selection.length) { + var $checked_rows = this.$('tr').filter(function (index, el) { + return _.contains(self.selection, $(el).data('id')); + }); + $checked_rows.find('.o_list_record_selector input').prop('checked', true); + } + return this._super(); + }, + _updateSelection: function () { + var $selectedRows = this.$('tbody .o_list_record_selector input:checked') + .closest('tr'); + this.selection = _.map($selectedRows, function (row) { + return $(row).data('id'); + }); + this.trigger_up('selection_changed', { selection: this.selection }); + this._computeAggregates(); + this.$('tfoot').replaceWith(this._renderFooter(!!this.state.groupedBy.length)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @param {MouseEvent} event + */ + _onRowClicked: function (event) { + var id = $(event.currentTarget).data('id'); + if (id) { + this.trigger_up('open_record', {id:id, target: event.target}); + } + }, + /** + * @param {MouseEvent} event + */ + _onSelectRecord: function (event) { + event.stopPropagation(); + this._updateSelection(); + if (!$(event.currentTarget).find('input').prop('checked')) { + this.$('thead .o_list_record_selector input').prop('checked', false); + } + }, + /** + * @param {MouseEvent} event + */ + _onSortColumn: function (event) { + var name = $(event.currentTarget).data('name'); + this.trigger_up('toggle_column_order', {id: this.state.id, name: name}); + }, + /** + * @param {MouseEvent} event + */ + _onToggleGroup: function (event) { + var group = $(event.currentTarget).data('group'); + if (group.count) { + this.trigger_up('toggle_group', {group: group}); + } + }, + _onToggleSelection: function (event) { + var checked = $(event.currentTarget).prop('checked') || false; + this.$('tbody .o_list_record_selector input').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 00000000000..a616e9704df --- /dev/null +++ b/addons/web/static/src/js/views/list/list_view.js @@ -0,0 +1,58 @@ +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 ListRenderer = require('web.ListRenderer'); +var ListController = require('web.ListController'); + +var _lt = core._lt; + +var ListView = BasicView.extend({ + accesskey: "l", + display_name: _lt('List'), + icon: 'fa-list-ul', + config: _.extend({}, BasicView.prototype.config, { + Renderer: ListRenderer, + Controller: ListController, + }), + /** + * @override + * + * @param {Object} arch + * @param {Object} fields + * @param {Object} params + * @param {boolean} params.sidebar + * @param {boolean} [params.hasSelectors=true] + */ + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + this.controllerParams.editable = arch.attrs.editable; + this.controllerParams.hasSidebar = params.sidebar; + this.controllerParams.noContentHelp = params.action && params.action.help; + this.controllerParams.activeActions.delete = true; + this.controllerParams.noLeaf = !!this.loadParams.context.group_by_no_leaf; + + this.rendererParams.arch = arch; + this.rendererParams.hasSelectors = + 'hasSelectors' in params ? params.hasSelectors : true; + this.rendererParams.mode = + arch.attrs.editable && !params.readonly ? "edit" : "readonly"; + + this.loadParams.limit = this.loadParams.limit || 80; + this.loadParams.type = 'list'; + }, +}); + +return ListView; + +}); diff --git a/addons/web/static/src/js/views/list_common.js b/addons/web/static/src/js/views/list_common.js deleted file mode 100644 index 0d96b251ec2..00000000000 --- a/addons/web/static/src/js/views/list_common.js +++ /dev/null @@ -1,405 +0,0 @@ -odoo.define('web.list_common', function (require) { -"use strict"; - -var core = require('web.core'); - -var _t = core._t; -var Class = core.Class; - -/** - * @mixin Events - */ -var Events = /** @lends Events# */{ - /** - * @param {String} event event to listen to on the current object, null for all events - * @param {Function} handler event handler to bind to the relevant event - * @returns this - */ - bind: function (event, handler) { - var calls = this['_callbacks'] || (this._callbacks = {}); - - if (event in calls) { - calls[event].push(handler); - } else { - calls[event] = [handler]; - } - return this; - }, - /** - * @param {String} event event to unbind on the current object - * @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event) - * @returns this - */ - unbind: function (event, handler) { - var calls = this._callbacks || {}; - if (!(event in calls)) { return this; } - if (!handler) { - delete calls[event]; - } else { - var handlers = calls[event]; - handlers.splice( - _(handlers).indexOf(handler), - 1); - } - return this; - }, - /** - * @param {String} event - * @returns this - */ - trigger: function (event) { - var calls; - if (!(calls = this._callbacks)) { return this; } - var callbacks = (calls[event] || []).concat(calls[null] || []); - for(var i=0, length=callbacks.length; i<length; ++i) { - callbacks[i].apply(this, arguments); - } - return this; - } -}; -var Record = Class.extend(/** @lends Record# */{ - /** - * @constructs Record - * @extends instance.web.Class - * - * @mixes Events - * @param {Object} [data] - */ - init: function (data) { - this.attributes = data || {}; - }, - /** - * @param {String} key - * @returns {Object} - */ - get: function (key) { - return this.attributes[key]; - }, - /** - * @param key - * @param value - * @param {Object} [options] - * @param {Boolean} [options.silent=false] - * @returns {Record} - */ - set: function (key, value, options) { - options = options || {}; - var old_value = this.attributes[key]; - if (old_value === value) { - return this; - } - this.attributes[key] = value; - if (!options.silent) { - this.trigger('change:' + key, this, value, old_value); - this.trigger('change', this, key, value, old_value); - } - return this; - }, - /** - * Converts the current record to the format expected by form views: - * - * .. code-block:: javascript - * - * data: { - * $fieldname: { - * value: $value - * } - * } - * - * - * @returns {Object} record displayable in a form view - */ - toForm: function () { - var form_data = {}, attrs = this.attributes; - for(var k in attrs) { - form_data[k] = {value: attrs[k]}; - } - - return {data: form_data}; - }, - /** - * Converts the current record to a format expected by context evaluations - * (identical to record.attributes, except m2o fields are their integer - * value rather than a pair) - */ - toContext: function () { - var output = {}, attrs = this.attributes; - for(var k in attrs) { - var val = attrs[k]; - if (typeof val !== 'object') { - output[k] = val; - } else if (val instanceof Array) { - output[k] = val[0]; - } else { - throw new Error(_.str.sprintf(_t("Can't convert value %s to context"), val)); - } - } - return output; - } -}); -Record.include(Events); -var Collection = Class.extend(/** @lends Collection# */{ - /** - * Smarter collections, with events, very strongly inspired by Backbone's. - * - * Using a "dumb" array of records makes synchronization between the - * various serious - * - * @constructs Collection - * @extends instance.web.Class - * - * @mixes Events - * @param {Array} [records] records to initialize the collection with - * @param {Object} [options] - */ - init: function (records, options) { - options = options || {}; - _.bindAll(this, '_onRecordEvent'); - this.length = 0; - this.records = []; - this._byId = {}; - this._proxies = {}; - this._key = options.key; - this._parent = options.parent; - - if (records) { - this.add(records); - } - }, - /** - * @param {Object|Array} record - * @param {Object} [options] - * @param {Number} [options.at] - * @param {Boolean} [options.silent=false] - * @returns this - */ - add: function (record, options) { - options = options || {}; - var records = record instanceof Array ? record : [record]; - - for(var i=0, length=records.length; i<length; ++i) { - var instance_ = (records[i] instanceof Record) ? records[i] : new Record(records[i]); - instance_.bind(null, this._onRecordEvent); - this._byId[instance_.get('id')] = instance_; - if (options.at === undefined || options.at === null) { - this.records.push(instance_); - if (!options.silent) { - this.trigger('add', this, instance_, this.records.length-1); - } - } else { - var insertion_index = options.at + i; - this.records.splice(insertion_index, 0, instance_); - if (!options.silent) { - this.trigger('add', this, instance_, insertion_index); - } - } - this.length++; - } - return this; - }, - - /** - * Get a record by its index in the collection, can also take a group if - * the collection is not degenerate - * - * @param {Number} index - * @param {String} [group] - * @returns {Record|undefined} - */ - at: function (index, group) { - if (group) { - var groups = group.split('.'); - return this._proxies[groups[0]].at(index, groups.join('.')); - } - return this.records[index]; - }, - /** - * Get a record by its database id - * - * @param {Number} id - * @returns {Record|undefined} - */ - get: function (id) { - if (!_(this._proxies).isEmpty()) { - var record = null; - _(this._proxies).detect(function (proxy) { - record = proxy.get(id); - return record; - }); - return record; - } - return this._byId[id]; - }, - /** - * Builds a proxy (insert/retrieve) to a subtree of the collection, by - * the subtree's group - * - * @param {String} section group path section - * @returns {Collection} - */ - proxy: function (section) { - this._proxies[section] = new Collection(null, { - parent: this, - key: section - }).bind(null, this._onRecordEvent); - return this._proxies[section]; - }, - /** - * @param {Array} [records] - * @returns this - */ - reset: function (records, options) { - options = options || {}; - _(this._proxies).each(function (proxy) { - proxy.reset(); - }); - this._proxies = {}; - _(this.records).invoke('unbind', null, this._onRecordEvent); - this.length = 0; - this.records = []; - this._byId = {}; - if (records) { - this.add(records); - } - if (!options.silent) { - this.trigger('reset', this); - } - return this; - }, - /** - * Removes the provided record from the collection - * - * @param {Record} record - * @param {Boolean} [options.silent=false] - * @returns this - */ - remove: function (record, options) { - var index = this.indexOf(record); - if (index === -1) { - _(this._proxies).each(function (proxy) { - proxy.remove(record); - }); - return this; - } - - record.unbind(null, this._onRecordEvent); - this.records.splice(index, 1); - delete this._byId[record.get('id')]; - this.length--; - if (!options || !options.silent) { - this.trigger('remove', record, this); - } - return this; - }, - - _onRecordEvent: function (event) { - switch(event) { - // don't propagate reset events - case 'reset': return; - case 'change:id': - var record = arguments[1]; - var new_value = arguments[2]; - var old_value = arguments[3]; - // [change:id, record, new_value, old_value] - if (this._byId[old_value] === record) { - delete this._byId[old_value]; - this._byId[new_value] = record; - } - break; - } - this.trigger.apply(this, arguments); - }, - - // underscore-type methods - find: function (callback) { - var record; - for(var section in this._proxies) { - if (!this._proxies.hasOwnProperty(section)) { - continue; - } - if ((record = this._proxies[section].find(callback))) { - return record; - } - } - for(var i=0; i<this.length; ++i) { - record = this.records[i]; - if (callback(record)) { - return record; - } - } - }, - each: function (callback) { - for(var section in this._proxies) { - if (this._proxies.hasOwnProperty(section)) { - this._proxies[section].each(callback); - } - } - for(var i=0; i<this.length; ++i) { - callback(this.records[i]); - } - }, - map: function (callback) { - var results = []; - this.each(function (record) { - results.push(callback(record)); - }); - return results; - }, - pluck: function (fieldname) { - return this.map(function (record) { - return record.get(fieldname); - }); - }, - indexOf: function (record) { - return _(this.records).indexOf(record); - }, - succ: function (record, options) { - options = options || {wraparound: false}; - var result; - for(var section in this._proxies) { - if (!this._proxies.hasOwnProperty(section)) { - continue; - } - if ((result = this._proxies[section].succ(record, options))) { - return result; - } - } - var index = this.indexOf(record); - if (index === -1) { return null; } - var next_index = index + 1; - if (options.wraparound && (next_index === this.length)) { - return this.at(0); - } - return this.at(next_index); - }, - pred: function (record, options) { - options = options || {wraparound: false}; - - var result; - for (var section in this._proxies) { - if (!this._proxies.hasOwnProperty(section)) { - continue; - } - if ((result = this._proxies[section].pred(record, options))) { - return result; - } - } - - var index = this.indexOf(record); - if (index === -1) { return null; } - var next_index = index - 1; - if (options.wraparound && (next_index === -1)) { - return this.at(this.length - 1); - } - return this.at(next_index); - } -}); -Collection.include(Events); - -return { - Events: Events, - Record: Record, - Collection: Collection -}; - -}); diff --git a/addons/web/static/src/js/views/list_view.js b/addons/web/static/src/js/views/list_view.js deleted file mode 100644 index 83e1b7bf900..00000000000 --- a/addons/web/static/src/js/views/list_view.js +++ /dev/null @@ -1,2075 +0,0 @@ - -odoo.define('web.ListView', function (require) { -"use strict"; - -var core = require('web.core'); -var data = require('web.data'); -var data_manager = require('web.data_manager'); -var DataExport = require('web.DataExport'); -var formats = require('web.formats'); -var common = require('web.list_common'); -var Model = require('web.DataModel'); -var Pager = require('web.Pager'); -var pyeval = require('web.pyeval'); -var session = require('web.session'); -var Sidebar = require('web.Sidebar'); -var utils = require('web.utils'); -var View = require('web.View'); - -var Class = core.Class; -var _t = core._t; -var _lt = core._lt; -var QWeb = core.qweb; -var list_widget_registry = core.list_widget_registry; - -// Allowed decoration on the list's rows: bold, italic and bootstrap semantics classes -var row_decoration = [ - 'decoration-bf', - 'decoration-it', - 'decoration-danger', - 'decoration-info', - 'decoration-muted', - 'decoration-primary', - 'decoration-success', - 'decoration-warning' -]; - -var ListView = View.extend({ - _template: 'ListView', - accesskey: "l", - require_fields: true, - defaults: _.extend({}, View.prototype.defaults, { - // records can be selected one by one - selectable: true, - // list rows can be deleted - deletable: false, - // whether the column headers should be displayed - header: true, - // display addition button, with that label - addable: _lt("Create"), - // whether the list view can be sorted, note that once a view has been - // sorted it can not be reordered anymore - sortable: true, - // whether the view rows can be reordered (via vertical drag & drop) - reorderable: true, - action_buttons: true, - //whether the editable property of the view has to be disabled - disable_editable_mode: false, - }), - display_name: _lt('List'), - events: { - 'click thead th.o_column_sortable[data-id]': 'sort_by_column', - 'click .oe_view_nocontent': function() { - if (this.$buttons) { - this.$buttons.width(this.$buttons.width() + 1).openerpBounce(); - } - }, - }, - icon: 'fa-list-ul', - - /** - * Core class for list-type displays. - * - * As a view, needs a number of view-related parameters to be correctly - * instantiated, provides options and overridable methods for behavioral - * customization. - * - * See constructor parameters and method documentations for information on - * the default behaviors and possible options for the list view. - * - * @constructs ListView - * @extends View - * - * @param parent parent object - * @param {DataSet} dataset the dataset the view should work with - * @param {String} view_id the listview's identifier, if any - * @param {Object} options A set of options used to configure the view - * @param {Boolean} [options.selectable=true] determines whether view rows are selectable (e.g. via a checkbox) - * @param {Boolean} [options.header=true] should the list's header be displayed - * @param {Boolean} [options.deletable=true] are the list rows deletable - * @param {void|String} [options.addable="New"] should the new-record button be displayed, and what should its label be. Use ``null`` to hide the button. - * @param {Boolean} [options.sortable=true] is it possible to sort the table by clicking on column headers - * @param {Boolean} [options.reorderable=true] is it possible to reorder list rows - */ - init: function() { - var self = this; - this._super.apply(this, arguments); - this.options = _.defaults(this.options, { - GroupsType: ListView.Groups, - ListType: ListView.List, - }); - - this.previous_colspan = null; - this.decoration = null; - - this.columns = []; - - this.records = new common.Collection(); - - this.set_groups(new (this.options.GroupsType)(this)); - - if (this.dataset instanceof data.DataSetStatic) { - this.groups.datagroup = new StaticDataGroup(this.dataset); - } else { - this.groups.datagroup = new DataGroup( - this, this.model, - this.dataset.get_domain(), - this.dataset.get_context()); - this.groups.datagroup.sort = this.dataset._sort; - } - - this.records.bind('change', function (event, record, key) { - if (!_(self.aggregate_columns).chain() - .pluck('name').contains(key).value()) { - return; - } - self.compute_aggregates(); - }); - - this.no_leaf = false; - this.grouped = false; - - if (!this.options.$pager || !this.options.$pager.length) { - this.options.$pager = false; - } - - this.options.deletable = this.options.deletable && this.is_action_enabled('delete'); - this.name = "" + this.fields_view.arch.attrs.string; - - // the view's number of records per page (|| section) - this._limit = (this.options.limit || - this.defaults.limit || - (this.getParent().action || {}).limit || - parseInt(this.fields_view.arch.attrs.limit, 10) || - 80); - // the index of the first displayed record (starting from 1) - this.current_min = 1; - - // Sort - var default_order = this.fields_view.arch.attrs.default_order; - var unsorted = !this.dataset._sort.length; - if (unsorted && default_order && !this.grouped) { - this.dataset.set_sort(default_order.split(',')); - } - }, - willStart: function() { - var self = this; - // Retrieve the decoration defined on the model's list view - this.decoration = _.pick(this.fields_view.arch.attrs, function(value, key) { - return row_decoration.indexOf(key) >= 0; - }); - this.decoration = _.mapObject(this.decoration, function(value) { - return py.parse(py.tokenize(value)); - }); - var fields_def = data_manager.load_fields(this.dataset).then(function(fields_get) { - self.fields_get = fields_get; - }); - return $.when(this._super(), fields_def); - }, - /** - * Set a custom Group construct as the root of the List View. - * - * @param {instance.web.ListView.Groups} groups - */ - set_groups: function (groups) { - var self = this; - if (this.groups) { - $(this.groups).unbind("selected deleted action row_link"); - delete this.groups; - } - - this.groups = groups; - $(this.groups).bind({ - 'selected': function (e, ids, records, deselected) { - self.do_select(ids, records, deselected); - }, - 'deleted': function (e, ids) { - self.do_delete(ids); - }, - 'action': function (e, action_name, id, callback) { - self.do_button_action(action_name, id, callback); - }, - 'row_link': function (e, id, dataset, view) { - self.do_activate_record(dataset.index, id, dataset, view); - } - }); - }, - /** - * Computes and returns the classnames for the provided record (from the - * ``@decoration`` attribute) - * - * @param {Record} record record for the current row - * @returns {String} classnames - */ - compute_decoration_classnames: function (record) { - var classnames= ''; - var context = _.extend({}, record.attributes, { - uid: session.uid, - current_date: moment().format('YYYY-MM-DD') - // TODO: time, datetime, relativedelta - }); - - _.each(this.decoration, function(expr, decoration) { - if (py.PY_isTrue(py.evaluate(expr, context))) { - classnames += ' ' + decoration.replace('decoration', 'text'); - } - }); - return classnames; - }, - /** - * Renders the table itself and inserts its content - * Computes and displays the aggregates - * Hooks up the selection of records event - * Adds adequate classname on the sorted column - */ - load_list: function() { - var self = this; - - // Render the table and append its content - this.$el.html(QWeb.render(this._template, this)); - this.$el.addClass(this.fields_view.arch.attrs['class']); - if (this.grouped) { - this.$('.o_list_view').addClass('o_list_view_grouped'); - } - this.$('.o_list_view').append(this.groups.elements); - - // Compute the aggregates and display them in the list's footer - this.compute_aggregates(); - - // Head hook - // Selecting records - this.$('thead .o_list_record_selector input').click(function() { - self.$('tbody .o_list_record_selector input').prop('checked', $(this).prop('checked') || false); - var selection = self.groups.get_selection(); - $(self.groups).trigger('selected', [selection.ids, selection.records]); - }); - - // Sort - if (this.dataset._sort.length) { - if (this.dataset._sort[0].indexOf('-') === -1) { - this.$('th[data-id=' + this.dataset._sort[0] + ']').addClass("o-sort-down"); - } else { - this.$('th[data-id=' + this.dataset._sort[0].split('-')[1] + ']').addClass("o-sort-up"); - } - } - - this.trigger('list_view_loaded', data, this.grouped); - return $.when(); - }, - /** - * Render the buttons according to the ListView.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 ListView inserts them into this.options.$buttons - * if it exists - */ - render_buttons: function($node) { - if (!this.$buttons) { - this.$buttons = $(QWeb.render("ListView.buttons", {'widget': this})); - this.$buttons.on('click', '.o_list_button_add', this.proxy('do_add_record')); - this.$buttons.appendTo($node); - } - }, - /** - * Instantiate and render the sidebar. - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - * $node may be undefined, in which case the ListView inserts the sidebar in - * this.options.$sidebar or in a div of its template - **/ - render_sidebar: function($node) { - if (!this.sidebar && this.options.sidebar) { - this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); - if (this.fields_view.toolbar) { - this.sidebar.add_toolbar(this.fields_view.toolbar); - } - this.sidebar.add_items('other', _.compact([ - { label: _t("Export"), callback: this.on_sidebar_export }, - this.fields_view.fields.active && {label: _t("Archive"), callback: this.do_archive_selected}, - this.fields_view.fields.active && {label: _t("Restore"), callback: this.do_unarchive_selected}, - this.is_action_enabled('delete') && { label: _t('Delete'), callback: this.do_delete_selected } - ])); - - $node = $node || this.options.$sidebar; - this.sidebar.appendTo($node); - - // Hide the sidebar by default (it will be shown as soon as a record is selected) - this.sidebar.do_hide(); - } - }, - /** - * Instantiate and render the pager and add listeners on it. - * Set this.pager - * @param {jQuery} [$node] a jQuery node where the pager should be inserted - * $node may be undefined, in which case the ListView inserts the pager into this.options.$pager - */ - render_pager: function($node, options) { - if (!this.pager && this.options.pager) { - this.pager = new Pager(this, this.dataset.size(), 1, this._limit, options); - this.pager.appendTo($node || this.options.$pager); - - this.pager.on('pager_changed', this, function (new_state) { - var self = this; - var limit_changed = (this._limit !== new_state.limit); - - this._limit = new_state.limit; - this.current_min = new_state.current_min; - this.reload_content().then(function() { - // Reset the scroll position to the top on page changed only - if (!limit_changed) { - self.set_scrollTop(0); - self.trigger_up('scrollTo', {offset: 0}); - } - }); - }); - } - }, - /** - * Updates the pager based on the provided dataset's information - * - * Horrifying side-effect: sets the dataset's data on this.dataset? - * - * @param {instance.web.DataSet} [dataset] - * @param {int} [current_min] the min pager value - */ - update_pager: function (dataset, current_min) { - this.dataset.ids = dataset.ids; - // Not exactly clean - if (dataset._length !== undefined) { - this.dataset._length = dataset._length; - } - if (this.pager && !this.grouped) { - var new_state = { size: this.dataset.size(), limit: this._limit }; - if (current_min) { - new_state.current_min = current_min; - } - this.pager.update_state(new_state); - } - }, - sort_by_column: function (e) { - e.stopPropagation(); - var $column = $(e.currentTarget); - var col_name = $column.data('id'); - var field = this.fields_view.fields[col_name]; - // test whether the field is sortable - if (field && !field.sortable) { - return false; - } - this.dataset.sort(col_name); - if($column.hasClass("o-sort-down") || $column.hasClass("o-sort-up")) { - $column.toggleClass("o-sort-up o-sort-down"); - } else { - $column.addClass("o-sort-down"); - } - $column.siblings('.o_column_sortable').removeClass("o-sort-up o-sort-down"); - - return this.reload_content(); - }, - /** - * Sets up the listview's columns: merges view and fields data, move - * grouped-by columns to the front of the columns list and make them all - * visible. - * - * @param {Object} fields fields_view_get's fields section - * @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed - */ - setup_columns: function (fields, grouped) { - this.columns.splice(0, this.columns.length); - this.columns.push.apply(this.columns, - _(this.fields_view.arch.children).map(function (field) { - var id = field.attrs.name; - return for_(id, fields[id], field); - })); - if (grouped) { - this.columns.unshift(new ListView.MetaColumn('_group')); - } - - this.visible_columns = _.filter(this.columns, function (column) { - return column.invisible !== '1'; - }); - - this.aggregate_columns = _(this.visible_columns).invoke('to_aggregate'); - }, - /** - * Used to handle a click on a table row, if no other handler caught the - * event. - * - * The default implementation asks the list view's view manager to switch - * to a different view (by calling - * :js:func:`~instance.web.ViewManager.on_mode_switch`), using the - * provided record index (within the current list view's dataset). - * - * If the index is null, ``switch_to_record`` asks for the creation of a - * new record. - * - * @param {Number|void} index the record index (in the current dataset) to switch to - * @param {String} [view="page"] the view type to switch to - */ - select_record:function (index, view) { - view = view || index === null || index === undefined ? 'form' : 'form'; - this.dataset.index = index; - _.delay(_.bind(function () { - this.do_switch_view(view); - }, this)); - }, - do_show: function () { - this._super(); - if (this.sidebar) { - // Hide the sidebar by default (will be shown once a record is selected) - this.sidebar.do_hide(); - } - }, - /** - * re-renders the content of the list view - * - * @returns {$.Deferred} promise to content reloading - */ - reload_content: synchronized(function () { - var self = this; - this.setup_columns(this.fields_view.fields, this.grouped); - this.$('tbody .o_list_record_selector input').prop('checked', false); - this.records.reset(); - var reloaded = $.Deferred(); - this.groups.render(function () { - if (self.dataset.index === null) { - if (self.records.length) { - self.dataset.index = 0; - } - } else if (self.dataset.index >= self.records.length) { - self.dataset.index = self.records.length ? 0 : null; - } - self.load_list().then(function () { - if (!self.grouped && self.display_nocontent_helper()) { - self.no_result(); - } - reloaded.resolve(); - }); - }); - this.do_push_state({ - min: this.current_min, - limit: this._limit - }); - return reloaded.promise(); - }), - reload: function () { - return this.reload_content(); - }, - reload_record: function (record) { - var self = this; - var fields = this.fields_view.fields; - return this.dataset.read_ids( - [record.get('id')], - _.pluck(_(this.columns).filter(function (r) { - return r.tag === 'field'; - }), 'name'), - {check_access_rule: true} - ).then(function (records) { - var values = records[0]; - if (!values) { - self.records.remove(record); - return; - } - // _.each is broken if a field "length" is present - for (var key in values) { - if (fields[key] && fields[key].type === 'many2many') - record.set(key + '__display', false, {silent: true}); - record.set(key, values[key], {silent: true}); - } - record.trigger('change', record); - - /* When a record is reloaded, there is a rendering lag because of the addition/suppression of - a table row. Since the list view editable need to wait for the end of this rendering lag before - computing the position of the editable fields, a 100ms delay is added. */ - var def = $.Deferred(); - setTimeout(function() { - def.resolve(records); - }, 100); - return def; - }); - }, - - do_load_state: function(state, warm) { - var reload = false; - if (state.min && this.current_min !== state.min) { - this.current_min = state.min; - reload = true; - } - if (state.limit) { - if (_.isString(state.limit)) { - state.limit = null; - } - if (state.limit !== this._limit) { - this._limit = state.limit; - reload = true; - } - } - if (reload) { - this.reload_content(); - } - }, - on_sidebar_export: function() { - new DataExport(this, this.dataset).open(); - }, - /** - * Handler for the result of eval_domain_and_context, actually perform the - * searching - * - * @param {Object} results results of evaluating domain and process for a search - */ - do_search: function (domain, context, group_by) { - this.current_min = 1; - this.groups.datagroup = new DataGroup( - this, this.model, domain, context, group_by); - this.groups.datagroup.sort = this.dataset._sort; - - if (_.isEmpty(group_by) && !context['group_by_no_leaf']) { - group_by = null; - } - this.no_leaf = !!context['group_by_no_leaf']; - this.grouped = !!group_by; - - // Hide the pager in grouped mode - if (this.pager && this.grouped) { - this.pager.do_hide(); - } - return this.reload_content(); - }, - /** - * Handles the signal to delete lines from the records list - * - * @param {Array} ids the ids of the records to delete - */ - do_delete: function (ids) { - if (!(ids.length && confirm(_t("Do you really want to remove these records?")))) { - return; - } - var self = this; - - return $.when(this.dataset.unlink(ids)).done(function () { - _(ids).each(function (id) { - self.records.remove(self.records.get(id)); - }); - // Hide the table if there is no more record in the dataset - if (self.display_nocontent_helper()) { - self.no_result(); - } else { - if (self.records.length && self.current_min === 1) { - // Reload the list view if we delete all the records of the first page - self.reload(); - } else if (self.records.length && self.dataset.size() > 0) { - // Load previous page if the current one is empty - self.pager.previous(); - } - // Reload the list view if we are not on the last page - if (self.current_min + self._limit - 1 < self.dataset.size()) { - self.reload(); - } - } - self.update_pager(self.dataset); - self.compute_aggregates(); - }); - }, - /** - * Handles the signal indicating that a new record has been selected - * - * @param {Array} ids selected record ids - * @param {Array} records selected record values - */ - do_select: function (ids, records, deselected) { - // uncheck header hook if at least one row has been deselected - if (deselected) { - this.$('thead .o_list_record_selector input').prop('checked', false); - } - - if (!ids.length) { - this.dataset.index = 0; - if (this.sidebar) { - this.sidebar.do_hide(); - } - this.compute_aggregates(); - return; - } - - this.dataset.index = _(this.dataset.ids).indexOf(ids[0]); - if (this.sidebar) { - this.sidebar.do_show(); - } - - this.compute_aggregates(_(records).map(function (record) { - return {count: 1, values: record}; - })); - }, - /** - * Handles action button signals on a record - * - * @param {String} name action name - * @param {Object} id id of the record the action should be called on - * @param {Function} callback should be called after the action is executed, if non-null - */ - do_button_action: function (name, id, callback) { - this.handle_button(name, id, callback); - }, - /** - * Base handling of buttons, can be called when overriding do_button_action - * in order to bypass parent overrides. - * - * The callback will be provided with the ``id`` as its parameter, in case - * handle_button's caller had to alter the ``id`` (or even create one) - * while not being ``callback``'s creator. - * - * This method should not be overridden. - * - * @param {String} name action name - * @param {Object} id id of the record the action should be called on - * @param {Function} callback should be called after the action is executed, if non-null - */ - handle_button: function (name, id, callback) { - var action = _.detect(this.columns, function (field) { - return field.name === name; - }); - if (!action) { return; } - if ('confirm' in action && !window.confirm(action.confirm)) { - return; - } - - var c = new data.CompoundContext(); - c.set_eval_context(_.extend({ - active_id: id, - active_ids: [id], - active_model: this.model - }, this.records.get(id).toContext())); - if (action.context) { - c.add(action.context); - } - action.context = c; - this.do_execute_action( - action, this.dataset, id, _.bind(callback, null, id)); - }, - /** - * Handles the activation of a record (clicking on it) - * - * @param {Number} index index of the record in the dataset - * @param {Object} id identifier of the activated record - * @param {instance.web.DataSet} dataset dataset in which the record is available (may not be the listview's dataset in case of nested groups) - */ - do_activate_record: function (index, id, dataset, view) { - this.dataset.ids = dataset.ids; - this.select_record(index, view); - }, - /** - * Handles signal for the addition of a new record (can be a creation, - * can be the addition from a remote source, ...) - * - * The default implementation is to switch to a new record on the form view - */ - do_add_record: function () { - this.select_record(null); - }, - /** - * Handles deletion of all selected lines - */ - do_delete_selected: function () { - var ids = this.groups.get_selection().ids; - if (ids.length) { - this.do_delete(this.groups.get_selection().ids); - } else { - this.do_warn(_t("Warning"), _t("You must select at least one record.")); - } - }, - /** - * Handles archiving/unarchiving of selected lines - */ - do_archive_selected: function () { - var records = this.groups.get_selection().records; - this.do_archive(records, true); - }, - do_unarchive_selected: function () { - var records = this.groups.get_selection().records; - this.do_archive(records, false); - }, - do_archive: function (records, archive) { - var active_value = !archive; - var record_ids = []; - _.each(records, function(record) { - if (record.active != active_value) { - record_ids.push(record.id); - } - }); - if (record_ids.length) { - this.dataset.call('write', [record_ids, {active: active_value}]) - .done(_.bind(this.reload, this)); - } - }, - /** - * Computes the aggregates for the current list view, either on the - * records provided or on the records of the internal - * :js:class:`~instance.web.ListView.Group`, by calling - * :js:func:`~instance.web.ListView.group.get_records`. - * - * Then displays the aggregates in the table through - * :js:method:`~instance.web.ListView.display_aggregates`. - * - * @param {Array} [records] - */ - compute_aggregates: function (records) { - var columns = _(this.aggregate_columns).filter(function (column) { - return column['function']; }); - if (_.isEmpty(columns)) { return; } - - if (_.isEmpty(records)) { - records = this.groups.get_records(); - } - records = _(records).compact(); - - var count = 0, sums = {}; - _(columns).each(function (column) { - switch (column['function']) { - case 'max': - sums[column.id] = -Infinity; - break; - case 'min': - sums[column.id] = Infinity; - break; - default: - sums[column.id] = 0; - } - }); - _(records).each(function (record) { - count += record.count || 1; - _(columns).each(function (column) { - var field = column.id, - value = record.values[field]; - switch (column['function']) { - case 'sum': - sums[field] += value; - break; - case 'avg': - sums[field] += record.count * value; - break; - case 'min': - if (sums[field] > value) { - sums[field] = value; - } - break; - case 'max': - if (sums[field] < value) { - sums[field] = value; - } - break; - } - }); - }); - - var aggregates = {}; - _.each(_.filter(columns, function (column) { - if (column.currency_field && records.length > 0 && records[0].values['currency_id']) { - var currency_ids = _.map(records, function(record) {return record.values['currency_id'][0]}); - if (_.every(currency_ids, function (currency_id){return currency_id === currency_ids[0]})) { - return column; - } - } else { - return column; - } - }), function (column) { - var field = column.id; - switch (column['function']) { - case 'avg': - aggregates[field] = {value: sums[field] / count}; - break; - default: - aggregates[field] = {value: sums[field]}; - } - }); - - this.display_aggregates(aggregates); - }, - display_aggregates: function (aggregation) { - var $footer_cells = this.$('tfoot td'); - _(this.aggregate_columns).each(function (column) { - if (!column['function']) { - return; - } - - $footer_cells.filter(_.str.sprintf('[data-field=%s]', column.id)) - .addClass('o_list_number') - .html(column.format(aggregation, { process_modifiers: false })); - }); - }, - get_selected_ids: function() { - var ids = this.groups.get_selection().ids; - return ids; - }, - /** - * Calculate the active domain of the list view. This should be done only - * if the header checkbox has been checked. This is done by evaluating the - * search results, and then adding the dataset domain (i.e. action domain). - */ - get_active_domain: function () { - var self = this; - if (this.$('thead .o_list_record_selector input').prop('checked')) { - var search_view = this.getParent().searchview; - var search_data = search_view.build_search_data(); - return pyeval.eval_domains_and_contexts({ - domains: search_data.domains, - contexts: search_data.contexts, - group_by_seq: search_data.groupbys || [] - }).then(function (results) { - var domain = self.dataset.domain.concat(results.domain || []); - return domain; - }); - } - else { - return $.Deferred().resolve(); - } - }, - /** - * Adds padding columns at the start or end of all table rows (including - * field names row) - * - * @param {Number} count number of columns to add - * @param {Object} options - * @param {"before"|"after"} [options.position="after"] insertion position for the new columns - * @param {Object} [options.except] content row to not pad - */ - pad_columns: function (count, options) { - options = options || {}; - // padding for action/pager header - var $first_header = this.$('thead tr:first th'); - var colspan = $first_header.attr('colspan'); - if (colspan) { - if (!this.previous_colspan) { - this.previous_colspan = colspan; - } - $first_header.attr('colspan', parseInt(colspan, 10) + count); - } - // Padding for column titles, footer and data rows - var $rows = this.$el - .find('.oe_list_header_columns, tr:not(thead tr)') - .not(options['except']); - var newcols = new Array(count+1).join('<td class="oe_list_padding"></td>'); - if (options.position === 'before') { - $rows.prepend(newcols); - } else { - $rows.append(newcols); - } - }, - /** - * Removes all padding columns of the table - */ - unpad_columns: function () { - this.$('.oe_list_padding').remove(); - if (this.previous_colspan) { - this.$el - .find('thead tr:first th') - .attr('colspan', this.previous_colspan); - this.previous_colspan = null; - } - }, - display_nocontent_helper: function () { - return (this.dataset.size() === 0); - }, - no_result: function () { - this.$('.oe_view_nocontent').remove(); - if (this.groups.group_by || - !this.options.action || - !this.options.action.help) { - return; - } - this.$('table:first').hide(); - this.$el.prepend( - $('<div class="oe_view_nocontent">').html(this.options.action.help) - ); - } -}); -core.view_registry.add('list', ListView); - -ListView.List = Class.extend({ - /** - * List display for the ListView, handles basic DOM events and transforms - * them in the relevant higher-level events, to which the list view (or - * other consumers) can subscribe. - * - * Events on this object are registered via jQuery. - * - * Available events: - * - * `selected` - * Triggered when a row is selected (using check boxes), provides an - * array of ids of all the selected records. - * `deleted` - * Triggered when deletion buttons are hit, provide an array of ids of - * all the records being marked for suppression. - * `action` - * Triggered when an action button is clicked, provides two parameters: - * - * * The name of the action to execute (as a string) - * * The id of the record to execute the action on - * `row_link` - * Triggered when a row of the table is clicked, provides the index (in - * the rows array) and id of the selected record to the handle function. - * - * @constructs instance.web.ListView.List - * @extends instance.web.Class - * - * @param {Object} opts display options, identical to those of :js:class:`instance.web.ListView` - */ - init: function (group, opts) { - var self = this; - this.group = group; - this.view = group.view; - this.session = this.view.session; - - this.options = opts.options; - this.columns = opts.columns; - this.dataset = opts.dataset; - this.records = opts.records; - - this.record_callbacks = { - 'remove': function (event, record) { - var id = record.get('id'); - self.dataset.remove_ids([id]); - var $row = self.$current.children('[data-id=' + id + ']'); - var index = $row.data('index'); - $row.remove(); - }, - 'reset': function () { return self.on_records_reset(); }, - 'change': function (event, record, attribute, value, old_value) { - var $row; - if (attribute === 'id') { - if (old_value) { - throw new Error(_.str.sprintf( _t("Setting 'id' attribute on existing record %s"), - JSON.stringify(record.attributes) )); - } - self.dataset.add_ids([value], self.records.indexOf(record)); - // Set id on new record - $row = self.$current.children('[data-id=false]'); - } else { - $row = self.$current.children( - '[data-id=' + record.get('id') + ']'); - } - if ($row.length) { - var $newRow = $(self.render_record(record)); - $newRow.find('.o_list_record_selector input').prop('checked', !!$row.find('.o_list_record_selector input').prop('checked')); - $row.replaceWith($newRow); - } - }, - 'add': function (ev, records, record, index) { - var $new_row = $(self.render_record(record)); - var id = record.get('id'); - if (id) { self.dataset.add_ids([id], index); } - - if (index === 0) { - $new_row.prependTo(self.$current); - } else { - var previous_record = records.at(index-1), - $previous_sibling = self.$current.children( - '[data-id=' + previous_record.get('id') + ']'); - $new_row.insertAfter($previous_sibling); - } - } - }; - _(this.record_callbacks).each(function (callback, event) { - this.records.bind(event, callback); - }, this); - - this.$current = $('<tbody>') - .delegate('input[readonly=readonly]', 'click', function (e) { - /* - Against all logic and sense, as of right now @readonly - apparently does nothing on checkbox and radio inputs, so - the trick of using @readonly to have, well, readonly - checkboxes (which still let clicks go through) does not - work out of the box. We *still* need to preventDefault() - on the event, otherwise the checkbox's state *will* toggle - on click - */ - e.preventDefault(); - }) - .delegate('td.o_list_record_selector', 'click', function (e) { - e.stopPropagation(); - var selection = self.get_selection(); - var checked = $(e.currentTarget).find('input').prop('checked'); - $(self).trigger( - 'selected', [selection.ids, selection.records, ! checked]); - }) - .delegate('td.o_list_record_delete', 'click', function (e) { - e.stopPropagation(); - var $row = $(e.target).closest('tr'); - $(self).trigger('deleted', [[self.row_id($row)]]); - // IE Edge go crazy when we use confirm dialog and remove the focused element - if(document.hasFocus && !document.hasFocus()) { - $('<input />').appendTo('body').focus().remove(); - } - }) - .delegate('td button', 'click', function (e) { - e.stopPropagation(); - var $target = $(e.currentTarget), - field = $target.closest('td').data('field'), - $row = $target.closest('tr'), - record_id = self.row_id($row); - - if ($target.attr('disabled')) { - return; - } - $target.attr('disabled', 'disabled'); - - // note: $.data converts data to number if it's composed only - // of digits, nice when storing actual numbers, not nice when - // storing strings composed only of digits. Force the action - // name to be a string - $(self).trigger('action', [field.toString(), record_id, function (id) { - $target.removeAttr('disabled'); - return self.reload_record(self.records.get(id)); - }]); - }) - .delegate('a', 'click', function (e) { - e.stopPropagation(); - }) - .delegate('tr', 'click', function (e) { - var row_id = self.row_id(e.currentTarget); - if (row_id) { - e.stopPropagation(); - if (!self.dataset.select_id(row_id)) { - throw new Error(_t("Could not find id in dataset")); - } - self.row_clicked(e); - } - }); - }, - row_clicked: function (e, view) { - $(this).trigger( - 'row_link', - [this.dataset.ids[this.dataset.index], - this.dataset, view]); - }, - render_cell: function (record, column) { - var value; - if(column.type === 'reference') { - value = record.get(column.id); - var ref_match; - // Ensure that value is in a reference "shape", otherwise we're - // going to loop on performing name_get after we've resolved (and - // set) a human-readable version. m2o does not have this issue - // because the non-human-readable is just a number, where the - // human-readable version is a pair - if (value && (ref_match = /^([\w\.]+),(\d+)$/.exec(value))) { - // reference values are in the shape "$model,$id" (as a - // string), we need to split and name_get this pair in order - // to get a correctly displayable value in the field - var model = ref_match[1], - id = parseInt(ref_match[2], 10); - new data.DataSet(this.view, model).name_get([id]).done(function(names) { - if (!names.length) { return; } - record.set(column.id + '__display', names[0][1]); - }); - } - } else if (column.type === 'many2one') { - value = record.get(column.id); - // m2o values are usually name_get formatted, [Number, String] - // pairs, but in some cases only the id is provided. In these - // cases, we need to perform a name_get call to fetch the actual - // displayable value - if (typeof value === 'number' || value instanceof Number) { - // fetch the name, set it on the record (in the right field) - // and let the various registered events handle refreshing the - // row - new data.DataSet(this.view, column.relation) - .name_get([value]).done(function (names) { - if (!names.length) { return; } - record.set(column.id, names[0]); - }); - } - } else if (column.type === 'many2many') { - value = record.get(column.id); - // non-resolved (string) m2m values are arrays - if (value instanceof Array && !_.isEmpty(value) - && !record.get(column.id + '__display')) { - var ids; - // they come in two shapes: - if (value[0] instanceof Array) { - _.each(value, function(command) { - switch (command[0]) { - case 4: ids.push(command[1]); break; - case 5: ids = []; break; - case 6: ids = command[2]; break; - default: throw new Error(_.str.sprintf( _t("Unknown m2m command %s"), command[0])); - } - }); - } else { - // 2. an array of ids - ids = value; - } - new Model(column.relation) - .call('name_get', [ids, this.dataset.get_context()]).done(function (names) { - // FIXME: nth horrible hack in this poor listview - record.set(column.id + '__display', - _(names).pluck(1).join(', ')); - record.set(column.id, ids); - }); - // temporary empty display name - record.set(column.id + '__display', false); - } - } - return column.format(record.toForm().data, { - model: this.dataset.model, - id: record.get('id') - }); - }, - render: function () { - var self = this; - this.$current.html( - QWeb.render('ListView.rows', _.extend({}, this, { - render_cell: function () { - return self.render_cell.apply(self, arguments); } - }))); - this.pad_table_to(4); - }, - pad_table_to: function (count) { - if (this.records.length >= count || - _(this.columns).any(function(column) { return column.meta; })) { - return; - } - var cells = []; - if (this.options.selectable) { - cells.push('<td class="o_list_record_selector"></td>'); - } - _(this.columns).each(function(column) { - if (column.invisible === '1') { - return; - } - cells.push('<td title="' + column.string + '"> </td>'); - }); - if (this.options.deletable) { - cells.push('<td class="o_list_record_delete"></td>'); - } - cells.unshift('<tr>'); - cells.push('</tr>'); - - var row = cells.join(''); - this.$current - .children('tr:not([data-id])').remove().end() - .append(new Array(count - this.records.length + 1).join(row)); - }, - /** - * Gets the ids of all currently selected records, if any - * @returns {Object} object with the keys ``ids`` and ``records``, holding respectively the ids of all selected records and the records themselves. - */ - get_selection: function () { - var result = {ids: [], records: []}; - if (!this.options.selectable) { - return result; - } - var records = this.records; - this.$current.find('td.o_list_record_selector input:checked') - .closest('tr').each(function () { - var record = records.get($(this).data('id')); - result.ids.push(record.get('id')); - result.records.push(record.attributes); - }); - return result; - }, - /** - * Returns the identifier of the object displayed in the provided table - * row - * - * @param {Object} row the selected table row - * @returns {Number|String} the identifier of the row's object - */ - row_id: function (row) { - return $(row).data('id'); - }, - /** - * Death signal, cleans up records's callbacks - */ - on_records_reset: function () { - _(this.record_callbacks).each(function (callback, event) { - this.records.unbind(event, callback); - }, this); - }, - get_records: function () { - return this.records.map(function (record) { - return {count: 1, values: record.attributes}; - }); - }, - /** - * Reloads the provided record by re-reading its content from the server. - * - * @param {Record} record - * @returns {$.Deferred} promise to the finalization of the reloading - */ - reload_record: function (record) { - return this.view.reload_record(record); - }, - /** - * Renders a list record to HTML - * - * @param {Record} record index of the record to render in ``this.rows`` - * @returns {String} QWeb rendering of the selected record - */ - render_record: function (record) { - var self = this; - var index = this.records.indexOf(record); - return QWeb.render('ListView.row', { - columns: this.columns, - options: this.options, - record: record, - row_parity: (index % 2 === 0) ? 'even' : 'odd', - view: this.view, - render_cell: function () { - return self.render_cell.apply(self, arguments); } - }); - } -}); - -ListView.Groups = Class.extend({ - passthrough_events: 'action deleted row_link', - /** - * Grouped display for the ListView. Handles basic DOM events and interacts - * with the :js:class:`~DataGroup` bound to it. - * - * Provides events similar to those of - * :js:class:`~instance.web.ListView.List` - * - * @constructs instance.web.ListView.Groups - * @extends instance.web.Class - * - * @param {instance.web.ListView} view - * @param {Object} [options] - * @param {Collection} [options.records] - * @param {Object} [options.options] - * @param {Array} [options.columns] - */ - init: function (view, options) { - options = options || {}; - this.view = view; - this.records = options.records || view.records; - this.options = options.options || view.options; - this.columns = options.columns || view.columns; - this.datagroup = null; - - this.$row = null; - this.children = {}; - - this.pager = null; // group pager - - var self = this; - this.records.bind('reset', function () { - return self.on_records_reset(); }); - }, - make_fragment: function () { - return document.createDocumentFragment(); - }, - /** - * Returns a DOM node after which a new tbody can be inserted, so that it - * follows the provided row. - * - * Necessary to insert the result of a new group or list view within an - * existing groups render, without losing track of the groups's own - * elements - * - * @param {HTMLTableRowElement} row the row after which the caller wants to insert a body - * @returns {HTMLTableSectionElement} element after which a tbody can be inserted - */ - point_insertion: function (row) { - var $row = $(row); - var red_letter_tboday = $row.closest('tbody')[0]; - - var $next_siblings = $row.nextAll(); - if ($next_siblings.length) { - var $root_kanal = $('<tbody>').insertAfter(red_letter_tboday); - - $root_kanal.append($next_siblings); - this.elements.splice( - _.indexOf(this.elements, red_letter_tboday), - 0, - $root_kanal[0]); - } - return red_letter_tboday; - }, - /** - * Renders the group pager and append it to the group's header - */ - render_group_pager: function () { - var self = this; - if (this.datagroup.length > this.view._limit) { - this.pager = new Pager(this, this.datagroup.length, 1, this.view._limit); - this.pager.on('pager_changed', this, function (state) { - self.view._limit = state.limit; - self.current_min = state.current_min; - self.render().then(function() { - self.$row.closest('tbody').next().replaceWith($(self.elements)); - }); - }); - - var $last_cell = this.$row.children().last(); - $last_cell.addClass('o_group_pager'); - // Prevent group to fold when clicking on the pager - $last_cell.click(function(e) { - e.stopPropagation(); - }); - this.pager.appendTo($last_cell); - } - }, - open: function (point_insertion) { - this.render(); - $(this.elements).insertAfter(point_insertion); - - var no_subgroups = _(this.datagroup.group_by).isEmpty(), - records_terminated = !this.datagroup.context['group_by_no_leaf']; - if (no_subgroups && records_terminated) { - this.render_group_pager(); - } - }, - close: function () { - if (this.pager) { - this.pager.destroy(); - } - this.records.reset(); - this.$to_be_removed.remove(); - }, - /** - * Prefixes ``$node`` with floated spaces in order to indent it relative - * to its own left margin/baseline - * - * @param {jQuery} $node jQuery object to indent - * @param {Number} level current nesting level, >= 1 - * @returns {jQuery} the indentation node created - */ - indent: function ($node, level) { - return $('<span>') - .css({'float': 'left', 'white-space': 'pre'}) - .text(new Array(level).join(' ')) - .prependTo($node); - }, - render_groups: function (datagroups) { - var self = this; - var placeholder = this.make_fragment(); - _(datagroups).each(function (group) { - if (self.children[group.value]) { - self.records.proxy(group.value).reset(); - delete self.children[group.value]; - } - var child = self.children[group.value] = new (self.view.options.GroupsType)(self.view, { - records: self.records.proxy(group.value), - options: self.options, - columns: self.columns - }); - self.bind_child_events(child); - child.datagroup = group; - - var $row = child.$row = $('<tr class="o_group_header">'); - if (group.openable && group.length) { - $row.click(function (e) { - if (!$row.data('open')) { - $row.data('open', true) - .find('span.fa') - .removeClass('fa-caret-right') - .addClass('fa-caret-down'); - child.open(self.point_insertion(e.currentTarget)); - } else { - $row.removeData('open') - .find('span.fa') - .removeClass('fa-caret-down') - .addClass('fa-caret-right'); - child.close(); - // force recompute the selection as closing group reset properties - var selection = self.get_selection(); - $(self).trigger('selected', [selection.ids, this.records]); - } - }); - } - placeholder.appendChild($row[0]); - - var $group_column = $('<th class="o_group_name">').appendTo($row); - // Don't fill this if group_by_no_leaf but no group_by - if (group.grouped_on) { - var row_data = {}; - row_data[group.grouped_on] = group; - var group_label = _t("Undefined"); - var group_column = _(self.columns).detect(function (column) { - return column.id === group.grouped_on; }); - if (group_column) { - try { - group_label = group_column.format(row_data, { - value_if_empty: _t("Undefined"), - process_modifiers: false - }); - } catch (e) { - group_label = _.str.escapeHTML(row_data[group_column.id].value); - } - } else { - group_label = group.value; - var grouped_on_field = self.view.fields_get[group.grouped_on]; - if (grouped_on_field && grouped_on_field.type === 'selection') { - group_label = _.find(grouped_on_field.selection, function(selection) { - return selection[0] === group.value; - }); - } - if (group_label instanceof Array) { - group_label = group_label[1]; - } - if (group_label === false) { - group_label = _t('Undefined'); - } - group_label = _.str.escapeHTML(group_label); - } - - // group_label is html-clean (through format or explicit - // escaping if format failed), can inject straight into HTML - $group_column.html(_.str.sprintf("%s (%d)", - group_label, group.length)); - - if (group.length && group.openable) { - // Make openable if not terminal group & group_by_no_leaf - $group_column.prepend('<span class="fa fa-caret-right" style="padding-right: 5px;">'); - } else { - $group_column.prepend('<span class="fa">'); - } - } - self.indent($group_column, group.level); - - if (self.options.selectable) { - $row.append('<td>'); - } - _(self.columns).chain() - .filter(function (column) { return column.invisible !== '1'; }) - .each(function (column) { - if (column.meta) { - // do not do anything - } else if (column.id in group.aggregates) { - var r = {}; - r[column.id] = {value: group.aggregates[column.id]}; - $('<td class="oe_number">') - .html(column.format(r, {process_modifiers: false})) - .appendTo($row); - } else { - $row.append('<td>'); - } - }); - if (self.options.deletable) { - $row.append('<td class="oe_list_group_pagination">'); - } - }); - return placeholder; - }, - bind_child_events: function (child) { - var $this = $(this), - self = this; - $(child).bind('selected', function (e, _0, _1, deselected) { - // can have selections spanning multiple links - var selection = self.get_selection(); - $this.trigger(e, [selection.ids, selection.records, deselected]); - }).bind(this.passthrough_events, function (e) { - // additional positional parameters are provided to trigger as an - // Array, following the event type or event object, but are - // provided to the .bind event handler as *args. - // Convert our *args back into an Array in order to trigger them - // on the group itself, so it can ultimately be forwarded wherever - // it's supposed to go. - var args = Array.prototype.slice.call(arguments, 1); - $this.trigger.call($this, e, args); - }); - }, - render_dataset: function (dataset) { - var self = this, - list = new (this.view.options.ListType)(this, { - options: this.options, - columns: this.columns, - dataset: dataset, - records: this.records - }); - this.bind_child_events(list); - - var view = this.view; - var current_min = this.datagroup.openable ? this.current_min : view.current_min; - - var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name'); - var options = { offset: current_min - 1, limit: view._limit, context: {bin_size: true} }; - return utils.async_when().then(function() { - return dataset.read_slice(fields, options).then(function (records) { - // FIXME: ignominious hacks, parents (aka form view) should not send two ListView#reload_content concurrently - if (self.records.length) { - self.records.reset(null, {silent: true}); - } - if (!self.datagroup.openable) { - // Update the main list view pager - view.update_pager(dataset, current_min); - } - - self.records.add(records, {silent: true}); - list.render(); - return list; - }); - }); - }, - setup_resequence_rows: function (list, dataset) { - var sequence_field = _(this.columns).findWhere({'widget': 'handle'}); - var seqname = sequence_field ? sequence_field.name : 'sequence'; - - // drag and drop enabled if list is not sorted (unless it is sorted by - // its sequence field (ASC)), and there is a visible column with - // @widget=handle or "sequence" column in the view. - if ((dataset.sort && [seqname, seqname + ' ASC', ''].indexOf(dataset.sort()) === -1) - || !_(this.columns).findWhere({'name': seqname})) { - return; - } - - // ondrop, move relevant record & fix sequences - list.$current.sortable({ - axis: 'y', - items: '> tr[data-id]', - helper: 'clone' - }); - if (sequence_field) { - list.$current.sortable('option', 'handle', '.o_row_handle'); - } - list.$current.sortable('option', { - start: function (e, ui) { - ui.placeholder.height(ui.item.height()); - }, - stop: function (event, ui) { - var to_move = list.records.get(ui.item.data('id')), - target_id = ui.item.prev().data('id'), - from_index = list.records.indexOf(to_move), - target = list.records.get(target_id); - if (list.records.at(from_index - 1) == target) { - return; - } - - list.records.remove(to_move, {silent: true}); - var to = target_id ? list.records.indexOf(target) + 1 : 0; - list.records.add(to_move, { at: to, silent: true }); - - // resequencing time! - var record, index = to, - // if drag to 1st row (to = 0), start sequencing from 0 - // (exclusive lower bound) - seq = to ? list.records.at(to - 1).get(seqname) : 0; - var defs = []; - var fct = function (dataset, id, seq) { - defs.push(utils.async_when().then(function () { - var attrs = {}; - attrs[seqname] = seq; - return dataset.write(id, attrs, {internal_dataset_changed: true}); - })); - }; - while (++seq, (record = list.records.at(index++))) { - // write are independent from one another, so we can just - // launch them all at the same time and we don't really - // give a fig about when they're done - fct(dataset, record.get('id'), seq); - record.set(seqname, seq); - } - $.when.apply($, defs).then(function () { - // use internal_dataset_changed and trigger one onchange after all writes - dataset.trigger("dataset_changed"); - }); - } - }); - }, - render: function (post_render) { - var self = this; - var $el = $('<tbody>'); - this.elements = [$el[0]]; - - return this.datagroup.list( - _(this.view.visible_columns).chain() - .filter(function (column) { return column.tag === 'field';}) - .pluck('name').value(), - function (groups) { - $el[0].appendChild( - self.render_groups(groups)); - if (post_render) { post_render(); } - }, function (dataset) { - return self.render_dataset(dataset).then(function (list) { - self.children[null] = list; - self.elements = - [list.$current.replaceAll($el)[0]]; - self.setup_resequence_rows(list, dataset); - }).always(function() { - if (post_render) { post_render(); } - self.view.trigger('view_list_rendered'); - }); - }); - }, - /** - * Returns the ids of all selected records for this group, and the records - * themselves - */ - get_selection: function () { - var ids = [], records = []; - - _(this.children) - .each(function (child) { - var selection = child.get_selection(); - ids.push.apply(ids, selection.ids); - records.push.apply(records, selection.records); - }); - - return {ids: ids, records: records}; - }, - on_records_reset: function () { - this.$to_be_removed = $(this.elements); - _.each(this.children, function(child){ - this.$to_be_removed = this.$to_be_removed.add(child.$to_be_removed); - }.bind(this)); - this.children = {}; - }, - get_records: function () { - if (_(this.children).isEmpty()) { - if (!this.datagroup.length) { - return; - } - return { - count: this.datagroup.length, - values: this.datagroup.aggregates - }; - } - return _(this.children).chain() - .map(function (child) { - return child.get_records(); - }).flatten().value(); - } -}); - -/** - * Serializes concurrent calls to this asynchronous method. The method must - * return a deferred or promise. - * - * Current-implementation is class-serialized (the mutex is common to all - * instances of the list view). Can be switched to instance-serialized if - * having concurrent list views becomes possible and common. - */ -function synchronized(fn) { - var fn_mutex = new utils.Mutex(); - return function () { - var obj = this; - var args = _.toArray(arguments); - return fn_mutex.exec(function () { - if (obj.isDestroyed()) { return $.when(); } - return fn.apply(obj, args); - }); - }; -} - -var DataGroup = Class.extend({ - init: function(parent, model, domain, context, group_by, level) { - this.model = new Model(model, context, domain); - this.group_by = group_by; - this.context = context; - this.domain = domain; - - this.level = level || 0; - }, - list: function (fields, ifGroups, ifRecords) { - var self = this; - if (!_.isEmpty(this.group_by)) { - // ensure group_by fields are read. - fields = _.unique((fields || []).concat(this.group_by)); - } - var query = this.model.query(fields).order_by(this.sort).group_by(this.group_by); - return $.when(query).then(function (querygroups) { - // leaf node - if (!querygroups) { - var ds = new data.DataSetSearch(self, self.model.name, self.model.context(), self.model.domain()); - ds._sort = self.sort; - return ifRecords(ds); - } - // internal node - var child_datagroups = _(querygroups).map(function (group) { - var child_context = _.extend( - {}, self.model.context(), group.model.context()); - var child_dg = new DataGroup( - self, self.model.name, group.model.domain(), - child_context, group.model._context.group_by, - self.level + 1); - child_dg.sort = self.sort; - // copy querygroup properties - child_dg.__context = child_context; - child_dg.__domain = group.model.domain(); - child_dg.folded = group.get('folded'); - child_dg.grouped_on = group.get('grouped_on'); - child_dg.length = group.get('length'); - child_dg.value = group.get('value'); - child_dg.openable = group.get('has_children'); - child_dg.aggregates = group.get('aggregates'); - return child_dg; - }); - ifGroups(child_datagroups); - }); - } -}); - -var StaticDataGroup = DataGroup.extend({ - init: function (dataset) { - this.dataset = dataset; - }, - list: function (fields, ifGroups, ifRecords) { - return ifRecords(this.dataset); - } -}); - -var Column = Class.extend({ - init: function (id, tag, attrs) { - _.extend(attrs, { - id: id, - tag: tag - }); - this.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {}; - delete attrs.modifiers; - _.extend(this, attrs); - - if (this.modifiers['tree_invisible']) { - this.invisible = '1'; - } else { delete this.invisible; } - }, - modifiers_for: function (fields) { - var out = {}; - var domain_computer = data.compute_domain; - - for (var attr in this.modifiers) { - if (!this.modifiers.hasOwnProperty(attr)) { continue; } - var modifier = this.modifiers[attr]; - out[attr] = _.isBoolean(modifier) - ? modifier - : domain_computer(modifier, fields); - } - - return out; - }, - to_aggregate: function () { - if (this.type !== 'integer' && this.type !== 'float' && this.type !== 'monetary') { - return {}; - } - - var aggregation_func = (this.sum && 'sum') || (this.avg && 'avg') || - (this.max && 'max') || (this.min && 'min'); - - if (!aggregation_func) { - return {}; - } - - var C = function (fn, label) { - this['function'] = fn; - this.label = label; - }; - C.prototype = this; - return new C(aggregation_func, this[aggregation_func]); - }, - heading: function () { - return _.escape(this.string); - }, - width: function () {}, - /** - * - * @param row_data record whose values should be displayed in the cell - * @param {Object} [options] - * @param {String} [options.value_if_empty=''] what to display if the field's value is ``false`` - * @param {Boolean} [options.process_modifiers=true] should the modifiers be computed ? - * @param {String} [options.model] current record's model - * @param {Number} [options.id] current record's id - * @return {String} - */ - format: function (row_data, options) { - options = options || {}; - var attrs = {}; - if (options.process_modifiers !== false) { - attrs = this.modifiers_for(row_data); - } - if (attrs.invisible) { return ''; } - - if (!row_data[this.id]) { - return options.value_if_empty === undefined - ? '' - : options.value_if_empty; - } - var f = this._format(row_data, options); - return (f !== '')? f : ' '; - }, - /** - * Method to override in order to provide alternative HTML content for the - * cell. Column._format will simply call ``instance.web.format_value`` and - * escape the output. - * - * The output of ``_format`` will *not* be escaped by ``format``, any - * escaping *must be done* by ``format``. - * - * @private - */ - _format: function (row_data, options) { - return _.escape(formats.format_value( - row_data[this.id].value, this, options.value_if_empty)); - } -}); -ListView.Column = Column; - -var MetaColumn = Column.extend({ - meta: true, - init: function (id, string) { - this._super(id, '', {string: string}); - } -}); -// to do: do this in a better way (communicate with view_list_editable) -ListView.MetaColumn = MetaColumn; - -var ColumnButton = Column.extend({ - /** - * Return an actual ``<button>`` tag - */ - format: function (row_data, options) { - options = options || {}; - var attrs = {}; - if (options.process_modifiers !== false) { - attrs = this.modifiers_for(row_data); - } - if (attrs.invisible) { return ''; } - var template = this.icon && 'ListView.row.button' || 'ListView.row.text_button'; - return QWeb.render(template, { - widget: this, - prefix: session.prefix, - disabled: attrs.readonly - || isNaN(row_data.id.value) - || data.BufferedDataSet.virtual_id_regex.test(row_data.id.value) - }); - } -}); - -var ColumnBoolean = Column.extend({ - /** - * Return a potentially disabled checkbox input - * - * @private - */ - _format: function (row_data, options) { - return _.str.sprintf('<div class="o_checkbox"><input type="checkbox" %s disabled="disabled"/><span/></div>', - row_data[this.id].value ? 'checked="checked"' : ''); - } -}); - -var ColumnBinary = Column.extend({ - /** - * Return a link to the binary data as a file - * - * @private - */ - _format: function (row_data, options) { - var text = _t("Download"), filename=_t('Binary file'); - var value = row_data[this.id].value; - if (!value) { - return options.value_if_empty || ''; - } - - var download_url; - if (value.substr(0, 10).indexOf(' ') == -1) { - download_url = "data:application/octet-stream;base64," + value; - } else { - download_url = session.url('/web/content', {model: options.model, field: this.id, id: options.id, download: true}); - if (this.filename) { - download_url += '&filename_field=' + this.filename; - } - } - if (this.filename && row_data[this.filename]) { - text = _.str.sprintf(_t("Download \"%s\""), formats.format_value( - row_data[this.filename].value, {type: 'char'})); - filename = row_data[this.filename].value; - } - return _.template('<a download="<%-download%>" href="<%-href%>"><%-text%></a> (<%-size%>)')({ - text: text, - href: download_url, - size: utils.binary_to_binsize(value), - download: filename, - }); - } -}); - -var ColumnChar = Column.extend({ - replacement: '*', - /** - * If password field, only display replacement characters (if value is - * non-empty) - */ - _format: function (row_data, options) { - var value = row_data[this.id].value; - if (value && this.password === 'True') { - return value.replace(/[\s\S]/g, _.escape(this.replacement)); - } - return this._super(row_data, options); - } -}); - -var ColumnProgressBar = Column.extend({ - /** - * Return a formatted progress bar display - * - * @private - */ - _format: function (row_data, options) { - return _.template( - '<progress value="<%-value%>" max="100"><%-value%>%</progress>')({ - value: _.str.sprintf("%.0f", row_data[this.id].value || 0) - }); - } -}); - -var ColumnHandle = Column.extend({ - init: function () { - this._super.apply(this, arguments); - // Handle overrides the field to not be form-editable. - this.modifiers.readonly = true; - this.string = ""; // Don't display the column header - }, - heading: function () { - return '<span class="o_row_handle fa fa-arrows invisible"></span>'; - }, - width: function () { return 1; }, - /** - * Return styling hooks for a drag handle - * - * @private - */ - _format: function (row_data, options) { - return '<span class="o_row_handle fa fa-arrows"/>'; - } -}); - -var ColumnMany2OneButton = Column.extend({ - _format: function (row_data, options) { - this.has_value = !!row_data[this.id].value; - this.icon = this.has_value ? 'fa-circle o_toggle_button_success' : 'fa-circle text-danger'; - this.string = this.has_value ? _t('View') : _t('Create'); - return QWeb.render('Many2OneButton.cell', { - 'widget': this, - 'prefix': session.prefix, - }); - }, -}); - -var ColumnMany2Many = Column.extend({ - _format: function (row_data, options) { - if (!_.isEmpty(row_data[this.id].value)) { - // If value, use __display version for printing - row_data[this.id] = row_data[this.id + '__display']; - } - return this._super(row_data, options); - } -}); - -var ColumnReference = Column.extend({ - _format: function (row_data, options) { - if (!_.isEmpty(row_data[this.id].value)) { - // If value, use __display version for printing - if (!!row_data[this.id + '__display']) { - row_data[this.id] = row_data[this.id + '__display']; - } else { - row_data[this.id] = {'value': ''}; - } - } - return this._super(row_data, options); - } -}); - -var ColumnUrl = Column.extend({ - /** - * Regex checking if a URL has a scheme - */ - PROTOCOL_REGEX: /^(?!\w+:?\/\/)/, - - /** - * Format a column as a URL if the column has content. - * Add "//" (inherit current protocol) specified in - * RFC 1808, 2396, and 3986 if no other protocol is included. - * - * @param row_data record whose values should be displayed in the cell - * @param options - */ - _format: function(row_data, options) { - var value = row_data[this.id].value; - - if (value) { - return _.template("<a href='<%-href%>' target='_blank'><%-text%></a>")({ - href: value.trim().replace(this.PROTOCOL_REGEX, '//'), - text: value - }); - } - return this._super(row_data, options); - } -}); - -var ColumnMonetary = Column.extend({ - - _format: function (row_data, options) { - var options = pyeval.py_eval(this.options || '{}'); - //name of currency field is defined either by field attribute, in view options or we assume it is named currency_id - var currency_field = (_.isEmpty(options) === false && options.currency_field) || this.currency_field || 'currency_id'; - var currency_id = row_data[currency_field] && row_data[currency_field].value[0]; - var currency = session.get_currency(currency_id); - var digits_precision = this.digits || (currency && currency.digits); - var value = formats.format_value(row_data[this.id].value || 0, {type: this.type, digits: digits_precision}, options.value_if_empty); - if (currency) { - if (currency.position === "after") { - value += ' ' + currency.symbol; - } else { - value = currency.symbol + ' ' + value; - } - } - return value; - }, -}); - -var ColumnToggleButton = Column.extend({ - format: function (row_data, options) { - this._super(row_data, options); - var button_tips = JSON.parse(this.options); - var fieldname = this.field_name; - var has_value = row_data[fieldname] && !!row_data[fieldname].value; - this.icon = has_value ? 'fa-circle o_toggle_button_success' : 'fa-circle text-muted'; - this.string = has_value ? (button_tips ? button_tips['active']: ''): (button_tips ? button_tips['inactive']: ''); - return QWeb.render('toggle_button', { - widget: this, - prefix: session.prefix, - }); - }, -}); - -/** - * Registry for column objects used to format table cells (and some other tasks - * e.g. aggregation computations). - * - * Maps a field or button to a Column type via its ``$tag.$widget``, - * ``$tag.$type`` or its ``$tag`` (alone). - * - * This specific registry has a dedicated utility method ``for_`` taking a - * field (from fields_get/fields_view_get.field) and a node (from a view) and - * returning the right object *already instantiated from the data provided*. - * - * @type {instance.web.Registry} - */ -list_widget_registry - .add('field', Column) - .add('field.boolean', ColumnBoolean) - .add('field.binary', ColumnBinary) - .add('field.char', ColumnChar) - .add('field.progressbar', ColumnProgressBar) - .add('field.handle', ColumnHandle) - .add('button', ColumnButton) - .add('field.many2onebutton', ColumnMany2OneButton) - .add('field.reference', ColumnReference) - .add('field.many2many', ColumnMany2Many) - .add('field.url', ColumnUrl) - .add('button.toggle_button', ColumnToggleButton) - .add('field.monetary', ColumnMonetary); - -function for_ (id, field, node) { - var description = _.extend({tag: node.tag}, field, node.attrs); - var tag = description.tag; - var Type = list_widget_registry.get_any([ - tag + '.' + description.widget, - tag + '.'+ description.type, - tag - ]); - return new Type(id, node.tag, description); -} - -return ListView; -}); diff --git a/addons/web/static/src/js/views/list_view_editable.js b/addons/web/static/src/js/views/list_view_editable.js deleted file mode 100644 index 6beb6a7a044..00000000000 --- a/addons/web/static/src/js/views/list_view_editable.js +++ /dev/null @@ -1,866 +0,0 @@ -odoo.define('web.ListEditor', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Editable List view - *---------------------------------------------------------*/ -/** - * handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out - * @namespace - */ - -var core = require('web.core'); -var data = require('web.data'); -var FormView = require('web.FormView'); -var common = require('web.list_common'); -var ListView = require('web.ListView'); -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var _t = core._t; - -var Editor = Widget.extend({ - /** - * @constructs instance.web.list.Editor - * @extends instance.web.Widget - * - * Adapter between listview and formview for editable-listview purposes - * - * @param {instance.web.Widget} parent - * @param {Object} options - * @param {instance.web.FormView} [options.formView=instance.web.FormView] - * @param {Object} [options.delegate] - */ - init: function (parent, options) { - this._super(parent); - this.options = options || {}; - _.defaults(this.options, { - formView: FormView, - delegate: this.getParent(), - }); - this.delegate = this.options.delegate; - this.record = null; - }, - start: function () { - var self = this; - var form_fields_view = this._validate_view(this.delegate.edition_view(this)); - this.form = new (this.options.formView)(this, this.delegate.dataset, form_fields_view, { - initial_mode: 'edit', - is_list_editable: true, - disable_autofocus: true, - $buttons: $(), - $pager: $(), - }); - return $.when(this._super(), this.form.appendTo($('<div/>')).then(function() { - self.form.$el.addClass(self.$el.attr('class')); - self.replaceElement(self.form.$el); - }).done(this.proxy('do_hide'))); - }, - _validate_view: function (edition_view) { - if (!edition_view) { - throw new Error("editor delegate's #edition_view must return a view descriptor"); - } - var arch = edition_view.arch; - if (!(arch && arch.children instanceof Array)) { - throw new Error("Editor delegate's #edition_view must have a non-empty arch"); - } - if (arch.tag !== "form") { - throw new Error("Editor delegate's #edition_view must have a 'form' root node"); - } - if (!(arch.attrs && arch.attrs.version === "7.0")) { - throw new Error("Editor delegate's #edition_view must be a version 7 view"); - } - if (!/\bo_list_editable_form\b/.test(arch.attrs['class'])) { - throw new Error("Editor delegate's #edition_view must have the class " + - "'o_list_editable_form' on its root element"); - } - return edition_view; - }, - is_editing: function () { - return !!this.record; - }, - is_creating: function () { - return (this.is_editing() && !this.record.id); - }, - edit: function (record, configureField) { - var self = this; - // TODO: specify sequence of edit calls - var loaded; - if(record) { - this.form.trigger('load_record', _.extend({}, record)); - loaded = this.form.record_loaded; - } else { - loaded = this.form.load_defaults().then(function() { - return self.form.record_loaded; - }); - } - - return loaded.then(function () { - return self.do_show({reload: false}); - }).then(function () { - self.record = self.form.datarecord; - _(self.form.fields).each(function (field, name) { - configureField(name, field); - }); - return self.form; - }); - }, - save: function () { - var self = this; - return this.form - .save(this.delegate.prepends_on_create()) - .then(function (result) { - if(result.created && !self.record.id) { - self.record.id = result.result; - } - return self.record; - }); - }, - cancel: function (force) { - var self = this; - if(force) { - return do_cancel(); - } - var message = _t("The line has been modified, your changes will be discarded. Are you sure you want to discard the changes ?"); - return this.form.can_be_discarded(message).then(do_cancel); - - function do_cancel() { - var record = self.record; - self.record = null; - self.do_hide(); - return $.when(record); - } - }, - do_hide: function() { - this.form.do_hide.apply(this.form, arguments); - this.form.set({display_invalid_fields: false}); - }, - do_show: function() { - this.form.do_show.apply(this.form, arguments); - }, -}); - -// editability status of list rows -ListView.prototype.defaults.editable = null; - -// TODO: not sure second @lends on existing item is correct, to check -ListView.include(/** @lends instance.web.ListView# */{ - init: function () { - var self = this; - this._super.apply(this, arguments); - - this.saving_mutex = new utils.Mutex(); - - this._force_editability = null; - this._context_editable = false; - this.editor = new Editor(this); - // Stores records of {field, cell}, allows for re-rendering fields - // depending on cell state during and after resize events - this.fields_for_resize = []; - core.bus.on('resize', this, this.resize_fields); - - $(this.groups).bind({ - 'edit': function (e, id, dataset) { - self.do_edit(dataset.index, id, dataset); - }, - 'saved': function () { - if (self.groups.get_selection().length) { - return; - } - self.configure_pager(self.dataset); - self.compute_aggregates(); - } - }); - - this.records.bind('remove', function () { - if (self.editor.is_editing()) { - self.cancel_edition(); - } - }); - - this.on('edit:before', this, function (event) { - if (!self.editable() || self.editor.is_editing()) { - event.cancel = true; - } - }); - this.on('edit:after', this, function () { - self.$el.add(self.$buttons).addClass('o-editing'); - self.$('.ui-sortable').sortable('disable'); - }); - this.on('save:after cancel:after', this, function () { - self.$('.ui-sortable').sortable('enable'); - self.$el.add(self.$buttons).removeClass('o-editing'); - }); - }, - destroy: function () { - core.bus.off('resize', this, this.resize_fields); - this._super(); - }, - do_hide: function () { - if (this.editor.is_editing()) { - this.cancel_edition(true); - } - this._super(); - }, - sort_by_column: function (e) { - e.stopPropagation(); - if (!this.editor.is_editing()) { - this._super.apply(this, arguments); - } - }, - /** - * Handles the activation of a record in editable mode (making a record - * editable), called *after* the record has become editable. - * - * The default behavior is to setup the listview's dataset to match - * whatever dataset was provided by the editing List - * - * @param {Number} index index of the record in the dataset - * @param {Object} id identifier of the record being edited - * @param {instance.web.DataSet} dataset dataset in which the record is available - */ - do_edit: function (index, id, dataset) { - _.extend(this.dataset, dataset); - }, - do_delete: function (ids) { - var nonfalse = _.compact(ids); - var _super = this._super.bind(this); - var next = (this.editor.is_editing())? this.cancel_edition(true) : $.when(); - return next.then(function () { - return _super(nonfalse); - }); - }, - editable: function () { - return !this.grouped - && !this.options.disable_editable_mode - && (this.fields_view.arch.attrs.editable || this._context_editable || this.options.editable); - }, - /** - * Replace do_search to handle editability process - */ - do_search: function(domain, context, group_by) { - var self = this; - var _super = this._super; - var args = arguments; - var ready = (this.editor.is_editing())? this.cancel_edition(true) : $.when(); - return ready.then(function () { - self._context_editable = !!context.set_editable; - return _super.apply(self, args); - }); - }, - /** - * Replace do_add_record to handle editability (and adding new record - * as an editable row at the top or bottom of the list) - */ - do_add_record: function () { - if (this.editable()) { - this.$('table:first').show(); - this.$('.oe_view_nocontent').remove(); - this.start_edition(); - } else { - this._super.apply(this, arguments); - } - }, - load_list: function (data, grouped) { - // tree/@editable takes priority on everything else if present. - var result = this._super.apply(this, arguments); - - // In case current editor was started previously, also has to run - // when toggling from editable to non-editable in case form widgets - // have setup global behaviors expecting themselves to exist somehow. - this.editor.destroy(); - this.editor = new Editor(this); // Editor is not restartable due to formview not being restartable - - if(this.editable()) { - this.$el.addClass('o_list_editable'); - return $.when(result, this.editor.prependTo(this.$el).done(this.proxy('setup_events'))); - } else { - this.$el.removeClass('o_list_editable'); - } - return result; - }, - /** - * Extend the render_buttons function of ListView by adding event listeners - * in the case of an editable list. - * @return {jQuery} the rendered buttons - */ - render_buttons: function() { - var add_button = !this.$buttons; // Ensures that this is only done once - var result = this._super.apply(this, arguments); // Sets this.$buttons - - if (add_button && (this.editable() || this.grouped)) { - var self = this; - this.$buttons - .off('click', '.o_list_button_save') - .on('click', '.o_list_button_save', this.proxy('save_edition')) - .off('click', '.o_list_button_discard') - .on('click', '.o_list_button_discard', function (e) { - e.preventDefault(); - self.cancel_edition(); - }); - } - return result; - }, - do_button_action: function (name, id, callback) { - var self = this; - this.save_edition().done(function (data) { - if(!id && data.created) { - id = data.record.get('id'); - } - self.handle_button(name, id, callback); - }); - }, - /** - * Builds a record with the provided id (``false`` for a creation), - * setting all columns with ``false`` value so code which relies on - * having an actual value behaves correctly - * - * @param {*} id - * @return {instance.web.list.Record} - */ - make_empty_record: function (id) { - var attrs = {id: id}; - _(this.columns).chain() - .filter(function (x) { return x.tag === 'field'; }) - .pluck('name') - .each(function (field) { attrs[field] = false; }); - return new common.Record(attrs); - }, - /** - * Set up the edition of a record of the list view "inline" - * - * @param {instance.web.list.Record} [record] record to edit, leave empty to create a new record - * @param {Object} [options] - * @param {String} [options.focus_field] field to focus at start of edition - * @return {jQuery.Deferred} - */ - start_edition: function (record, options) { - var self = this; - var item = false; - if (record) { - item = record.attributes; - this.dataset.select_id(record.get('id')); - } else { - record = this.make_empty_record(false); - this.records.add(record, {at: (this.prepends_on_create())? 0 : null}); - } - - return this.save_edition().then(function() { - return $.when.apply($, self.editor.form.render_value_defs); - }).then(function () { - var $recordRow = self.groups.get_row_for(record); - var cells = self.get_cells_for($recordRow); - var fields = {}; - self.fields_for_resize.splice(0, self.fields_for_resize.length); // Empty array - return self.with_event('edit', { - record: record.attributes, - cancel: false, - }, function () { - return self.editor.edit(item, function (field_name, field) { - var cell = cells[field_name]; - if (!cell) { - return; - } - - // FIXME: need better way to get the field back from bubbling (delegated) DOM events somehow - field.$el.attr('data-fieldname', field_name); - fields[field_name] = field; - self.fields_for_resize.push({field: field, cell: cell}); - }, options).then(function () { - $recordRow.addClass('o_row_edition'); - self.resize_fields(); - // Local function that returns true if field is visible and editable - var is_focusable = function(field) { - return field && field.$el.is(':visible:not(.o_readonly)'); - }; - var focus_field = options && options.focus_field ? options.focus_field : undefined; - if (!is_focusable(fields[focus_field])) { - focus_field = _.find(self.editor.form.fields_order, function(field) { - return is_focusable(fields[field]); - }); - } - if (fields[focus_field]) { - fields[focus_field].$el.find('input, textarea').andSelf().filter('input, textarea').select(); - } - return record.attributes; - }); - }).fail(function () { - // if the start_edition event is cancelled and it was a creation, remove the newly-created empty record - if(!record.get('id')) { - self.records.remove(record); - } - }); - }, function() { - return $.Deferred().resolve(); // Here the save/cancel edition failed so the start_edition is considered as done and succeeded - }); - }, - get_cells_for: function ($row) { - var cells = {}; - $row.children('td').each(function (index, el) { - cells[el.getAttribute('data-field')] = el; - }); - return cells; - }, - /** - * If currently editing a row, resizes all registered form fields based - * on the corresponding row cell - */ - resize_fields: function () { - if (!this.editor.is_editing()) { - return; - } - for(var i = 0, len = this.fields_for_resize.length ; i < len ; i++) { - var item = this.fields_for_resize[i]; - this.resize_field(item.field, item.cell); - } - }, - /** - * Resizes a field's root element based on the corresponding cell of - * a listview row - * - * @param {instance.web.form.AbstractField} field - * @param {jQuery} cell - */ - resize_field: function (field, cell) { - var $cell = $(cell); - field.set_dimensions($cell.outerHeight(), $cell.outerWidth()); - position_element(field.$el, 'left top'); - position_element(field.$translate, 'right top'); - if(field.get('effective_readonly')) { - field.$el.addClass('o_readonly'); - } - if(field.widget == "handle") { - field.$el.addClass('o_row_handle'); - } - - // Workaround a bug in safari mobile where the inputs are not displayed correctly. - if (window.getComputedStyle(field.el.parentElement).webkitOverflowScrolling) { - field.el.style.zIndex = 1; - } - - function position_element($el, pos) { - $el.addClass('o_temp_visible').css({top: 0, left: 0}).position({ - my: pos, - at: pos, - of: $cell, - }).removeClass('o_temp_visible'); - } - }, - /** - * @return {jQuery.Deferred} - */ - save_edition: function (cancel_onfail) { - var self = this; - return self.saving_mutex.exec(function() { - if (!self.editor.is_editing()) { - return $.when(); - } - return self.with_event('save', { - editor: self.editor, - form: self.editor.form, - cancel: false, - }, function () { - return self.editor.save().then(function (attrs) { - var created = false; - var record = self.records.get(attrs.id); - if (!record) { - // new record - created = true; - record = self.records.find(function (r) { - return !r.get('id'); - }).set('id', attrs.id); - } - // onwrite callback could be altering & reloading the - // record which has *just* been saved, so first perform all - // onwrites then do a final reload of the record - return self.cancel_edition(true) - .then(function() { - return self.handle_onwrite(record); - }) - .then(function () { - return self.reload_record(record); - }) - .then(function () { - return {created: created, record: record}; - }); - }, function() { - if (cancel_onfail) { - return self.cancel_edition(); - } - }); - }); - }); - }, - /** - * @param {Boolean} [force] force the line to be discarded (even if there was changes) - * @return {jQuery.Deferred} - */ - cancel_edition: function (force) { - var self = this; - return this.with_event('cancel', { - editor: this.editor, - form: this.editor.form, - cancel: false - }, function () { - return this.editor.cancel(force).then(function (attrs) { - if (attrs.id) { - var record = self.records.get(attrs.id); - if (!record) { - return; // Record removed by third party during edition - } - return self.reload_record(record, {do_not_evict: true}); - } - var to_delete = self.records.find(function (r) { - return !r.get('id'); - }); - if (to_delete) { - self.records.remove(to_delete); - } - }); - }); - }, - /** - * Executes an action on the view's editor bracketed by a cancellable - * event of the name provided. - * - * The event name provided will be post-fixed with ``:before`` and - * ``:after``, the ``event`` parameter will be passed alongside the - * ``:before`` variant and if the parameter's ``cancel`` key is set to - * ``true`` the action *will not be called* and the method will return - * a rejection - * - * @param {String} event_name name of the event - * @param {Object} event event object, provided to ``:before`` sub-event - * @param {Function} action callable, called with the view's editor as its context - * @param {Array} [args] supplementary arguments provided to the action - * @param {Array} [trigger_params] supplementary arguments provided to the ``:after`` sub-event, before anything fetched by the ``action`` function - * @return {jQuery.Deferred} - */ - with_event: function (event_name, event, action) { - var self = this; - event = event || {}; - this.trigger(event_name + ':before', event); - if (event.cancel) { - return $.Deferred().reject({ - message: _.str.sprintf("Event %s:before cancelled", - event_name)}); - } - return $.when(action.call(this)).done(function () { - self.trigger.apply(self, [event_name + ':after'] - .concat(_.toArray(arguments))); - }); - }, - edition_view: function (editor) { - var view = $.extend(true, {}, this.fields_view); - view.arch.tag = 'form'; - _.extend(view.arch.attrs, { - 'class': 'o_list_editable_form', - version: '7.0' - }); - _(view.arch.children).chain() - .zip(_(this.columns).filter(function (c) { - return !(c instanceof ListView.MetaColumn);})) - .each(function (ar) { - var widget = ar[0], column = ar[1]; - var modifiers = _.extend({}, column.modifiers); - widget.attrs.nolabel = true; - if (modifiers['tree_invisible'] || widget.tag === 'button') { - modifiers.invisible = true; - } - widget.attrs.modifiers = JSON.stringify(modifiers); - }); - return view; - }, - handle_onwrite: function (source_record) { - var self = this; - var on_write_callback = self.fields_view.arch.attrs.on_write; - if (!on_write_callback) { - return $.when(); - } - var context = new data.CompoundContext(self.dataset.get_context(), {'on_write_domain': self.dataset.domain}).eval(); - return this.dataset.call(on_write_callback, [source_record.get('id'), context]) - .then(function (ids) { - return $.when.apply(null, _(ids).map(_.bind(self.handle_onwrite_record, self, source_record))); - }); - }, - handle_onwrite_record: function (source_record, id) { - var record = this.records.get(id); - if (!record) { - // insert after the source record - var index = this.records.indexOf(source_record) + 1; - record = this.make_empty_record(id); - this.records.add(record, {at: index}); - } - return this.reload_record(record); - }, - prepends_on_create: function () { - return (this.editable() === 'top'); - }, - setup_events: function () { - var self = this; - _.each(this.editor.form.fields, function(field, field_name) { - field.on("change:effective_readonly", self, function(){ - var item = _(self.fields_for_resize).find(function (item) { - return item.field === field; - }); - if (item) { - setTimeout(function() { - self.resize_field(item.field, item.cell); - }, 0); - } - - }); - }); - - this.editor.$el.on('keyup keypress keydown', function (e) { - if (!self.editor.is_editing()) { return true; } - var key = _($.ui.keyCode).chain() - .map(function (v, k) { return {name: k, code: v}; }) - .find(function (o) { return o.code === e.which; }) - .value(); - if (!key) { return true; } - var method = e.type + '_' + key.name; - if (!(method in self)) { return true; } - return self[method](e); - }); - }, - /** - * Saves the current record, and goes to the next one (creation or - * edition) - * - * @private - * @param {String} [next_record='succ'] method to call on the records collection to get the next record to edit - * @param {Object} [options] - * @param {String} [options.focus_field] - * @return {*} - */ - _next: function (next_record, options) { - next_record = next_record || 'succ'; - var self = this; - return this.save_edition().then(function (saveInfo) { - if (!saveInfo) { return null; } - if (saveInfo.created) { - return self.start_edition(); - } - if (!options) { - options = {}; - } - options.wraparound = !self.is_action_enabled('create'); - var record = self.records[next_record](saveInfo.record, options); - if (record === undefined) { - return self.start_edition(); - } - return self.start_edition(record, options); - }); - }, - keyup_ENTER: function () { - return this._next(); - }, - keydown_ESCAPE: function (e) { - return false; - }, - keyup_ESCAPE: function (e) { - return this.cancel_edition(); - }, - /** - * Gets the selection range (start, end) for the provided element, - * returns ``null`` if it can't get a range. - * - * @private - */ - _text_selection_range: function (el) { - var selectionStart; - try { - selectionStart = el.selectionStart; - } catch (e) { - // radio or checkbox throw on selectionStart access - return null; - } - if (selectionStart !== undefined) { - return { - start: selectionStart, - end: el.selectionEnd - }; - } else if (document.body.createTextRange) { - throw new Error("Implement text range handling for MSIE"); - } - // Element without selection ranges (select, div/@contenteditable) - return null; - }, - _text_cursor: function (el) { - var selection = this._text_selection_range(el); - if (!selection) { - return null; - } - if (selection.start !== selection.end) { - return {position: null, collapsed: false}; - } - return {position: selection.start, collapsed: true}; - }, - /** - * Checks if the cursor is at the start of the provided el - * - * @param {HTMLInputElement | HTMLTextAreaElement} - * @returns {Boolean} - * @private - */ - _at_start: function (cursor, el) { - return cursor.collapsed && (cursor.position === 0); - }, - /** - * Checks if the cursor is at the end of the provided el - * - * @param {HTMLInputElement | HTMLTextAreaElement} - * @returns {Boolean} - * @private - */ - _at_end: function (cursor, el) { - return cursor.collapsed && (cursor.position === el.value.length); - }, - /** - * @param DOMEvent event - * @param {String} record_direction direction to move into to get the next record (pred | succ) - * @param {Function} is_valid_move whether the edition should be moved to the next record - * @private - */ - _key_move_record: function (event, record_direction, is_valid_move) { - if (!this.editor.is_editing() || this.editor.is_creating()) { return $.when(); } - var cursor = this._text_cursor(event.target); - // if text-based input (has a cursor) - // and selecting (not collapsed) or not at a field boundary - // don't move to the next record - if (cursor && !is_valid_move(event.target, cursor)) { return $.when(); } - - event.preventDefault(); - var source_field = $(event.target).closest('[data-fieldname]') - .attr('data-fieldname'); - return this._next(record_direction, {focus_field: source_field}); - - }, - keyup_UP: function (e) { - var self = this; - return this._key_move_record(e, 'pred', function (el, cursor) { - return self._at_start(cursor, el) && !$(el).is('select,.ui-autocomplete-input'); - }); - }, - keyup_DOWN: function (e) { - var self = this; - return this._key_move_record(e, 'succ', function (el, cursor) { - return self._at_end(cursor, el) && !$(el).is('select,.ui-autocomplete-input'); - }); - }, - - keydown_LEFT: function (e) { - // If the cursor is at the beginning of the field - var source_field = $(e.target).closest('[data-fieldname]') - .attr('data-fieldname'); - var cursor = this._text_cursor(e.target); - if (cursor && !this._at_start(cursor, e.target)) { return $.when(); } - - var fields_order = this.editor.form.fields_order; - var field_index = _(fields_order).indexOf(source_field); - - // Look for the closest visible form field to the left - var fields = this.editor.form.fields; - var field; - do { - if (--field_index < 0) { return $.when(); } - - field = fields[fields_order[field_index]]; - } while (!field.$el.is(':visible')); - - // and focus it - field.focus(); - return $.when(); - }, - keydown_RIGHT: function (e) { - // same as above, but with cursor at the end of the field and - // looking for new fields at the right - var source_field = $(e.target).closest('[data-fieldname]') - .attr('data-fieldname'); - var cursor = this._text_cursor(e.target); - if (cursor && !this._at_end(cursor, e.target)) { return $.when(); } - - var fields_order = this.editor.form.fields_order; - var field_index = _(fields_order).indexOf(source_field); - - var fields = this.editor.form.fields; - var field; - do { - if (++field_index >= fields_order.length) { return $.when(); } - - field = fields[fields_order[field_index]]; - } while (!field.$el.is(':visible')); - - field.focus(); - return $.when(); - }, - keydown_TAB: function (e) { // Keydown and not keyup because this handler must be called before the browser has focused the next field - var form = this.editor.form; - var last_field = _(form.fields_order).chain() - .map(function (name) { return form.fields[name]; }) - .filter(function (field) { return field.$el.is(':visible') && !field.get('effective_readonly'); }) - .last() - .value(); - // tabbed from last field in form - if (last_field && $(e.target).closest(last_field.el).length) { - e.preventDefault(); - return this._next(); - } - this.editor.form.__clicked_inside = true; - return $.when(); - }, -}); - - -ListView.Groups.include(/** @lends instance.web.ListView.Groups# */{ - passthrough_events: ListView.Groups.prototype.passthrough_events + " edit saved", - get_row_for: function (record) { - return _(this.children).chain() - .invoke('get_row_for', record) - .compact() - .first() - .value(); - } -}); - -ListView.List.include(/** @lends instance.web.ListView.List# */{ - row_clicked: function (event) { - if (!this.view.editable() || !this.view.is_action_enabled('edit')) { - return this._super.apply(this, arguments); - } - if (this.__is_starting_edition) { - return; - } - this.__is_starting_edition = true; - - var self = this; - var args = arguments; - var _super = self._super; - - var record_id = $(event.currentTarget).data('id'); - return this.view.start_edition( - ((record_id)? this.records.get(record_id) : null), { - focus_field: $(event.target).not(".o_readonly").data('field'), - }).fail(function() { - return _super.apply(self, args); // The record can't be edited so open it in a modal (use-case: readonly mode) - }).always(function () { - self.__is_starting_edition = false; - }); - }, - /** - * If a row mapping to the record (@data-id matching the record's id or - * no @data-id if the record has no id), returns it. Otherwise returns - * ``null``. - * - * @param {Record} record the record to get a row for - * @return {jQuery|null} - */ - get_row_for: function (record) { - var $row = this.$current.children('[data-id=' + record.get('id') + ']'); - return (($row.length)? $row : null); - }, -}); - -return Editor; - -}); 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 00000000000..9b154214dfc --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_controller.js @@ -0,0 +1,368 @@ +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. + */ + +var AbstractController = require('web.AbstractController'); +var core = require('web.core'); +var crash_manager = require('web.crash_manager'); +var framework = require('web.framework'); +var session = require('web.session'); + +var _t = core._t; +var QWeb = core.qweb; + +var PivotController = AbstractController.extend({ + template: 'PivotView', + events: { + 'click .o_pivot_header_cell_opened': '_onOpenHeaderClick', + 'click .o_pivot_header_cell_closed': '_onClosedHeaderClick', + 'click .o_pivot_field_menu a': '_onFieldMenuSelection', + 'click td': '_onCellClick', + 'click .o_pivot_measure_row': '_onMeasureRowClick', + }, + /** + * @override + * @param {Object} params + * @param {Object} params.groupableFields a map from field name to field + * props + * @param {boolean} params.enableLinking configure the pivot view to allow + * opening a list view by clicking on a cell with some data. + */ + init: function (parent, model, renderer, params) { + this._super.apply(this, arguments); + + this.measures = params.measures; + this.groupableFields = params.groupableFields; + this.title = params.title; + this.enableLinking = params.enableLinking; + this.lastHeaderSelected = null; + }, + /** + * @override + */ + start: function () { + this.$el.toggleClass('o_enableLinking', this.enableLinking); + this.$fieldSelection = this.$('.o_field_selection'); + core.bus.on('click', this, function () { + this.$fieldSelection.empty(); + }); + return this._super(); + }, + /** + * @override + */ + destroy: function () { + if (this.$buttons) { + // remove jquery's tooltip() handlers + this.$buttons.find('button').off(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * We save the current measure and group bys, so we can restore the view + * when we save the current state in the search view. + * + * @override method from AbstractController + * @returns {Object} + */ + getContext: function () { + if (this.handle) { + var state = this.model.get(this.handle); + return { + pivot_measures: state.measures, + pivot_column_groupby: state.data.main_col.groupbys, + pivot_row_groupby: state.data.main_row.groupbys, + }; + } + return {}; + }, + /** + * Render the buttons according to the PivotView.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 PivotView + * does nothing + */ + renderButtons: function ($node) { + if ($node) { + var context = {measures: _.pairs(_.omit(this.measures, '__count'))}; + this.$buttons = $(QWeb.render('PivotView.buttons', context)); + this.$buttons.click(this._onButtonClick.bind(this)); + this.$buttons.find('button').tooltip(); + + this.$buttons.appendTo($node); + this._updateButtons(); + } + }, + + //-------------------------------------------------------------------------- + // 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_xls. + * + * @private + */ + _downloadTable: function () { + var table = this.model.exportData(); + if(table.measure_row.length + 1 > 256) { + crash_manager.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); + return; + } + framework.blockUI(); + session.get_file({ + url: '/web/pivot/export_xls', + data: {data: JSON.stringify(table)}, + complete: framework.unblockUI, + error: crash_manager.rpc_error.bind(crash_manager) + }); + }, + /** + * Render the field selection menu, to select a groupable field. We disable + * already selected groupbys. + * + * @private + * @param {number} top top coordinate where we have to render the menu + * @param {number} left left coordinate for the menu + */ + _renderFieldSelection: function (top, left) { + var state = this.model.get({raw: true}); + var groupedFields = state.rowGroupBys + .concat(state.colGroupBys) + .map(function (f) { return f.split(':')[0];}); + + var fields = _.chain(this.groupableFields) + .pairs() + .sortBy(function (f) { return f[1].string; }) + .map(function (f) { + return [f[0], f[1], _.contains(groupedFields, f[0])]; + }) + .value(); + + this.$fieldSelection.html(QWeb.render('PivotView.FieldSelection', { + fields: fields + })); + + this.$fieldSelection.find('ul').first() + .css({top: top, left: left}) + .show(); + }, + /** + * @private + */ + _update: function (state) { + this._updateButtons(); + var hasNoContent = !state.measures.length || !state.has_data; + + if (hasNoContent) { + this.renderer.$el.detach(); + this.$el.append(QWeb.render('PivotView.nodata')); + } else { + if (!document.contains(this.renderer.el)) { + this.$('div.oe_view_nocontent').remove(); + this.$el.append(this.renderer.$el); + } + } + return this._super.apply(this, arguments); + }, + /** + * @private + */ + _updateButtons: function () { + if (!this.$buttons) { + return; + } + var self = this; + var state = this.model.get({raw: true}); + _.each(this.measures, function (measure, name) { + var isSelected = _.contains(state.measures, name); + self.$buttons.find('li[data-field="' + name + '"]') + .toggleClass('selected', isSelected); + }); + }, + + //-------------------------------------------------------------------------- + // 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} event + */ + _onButtonClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_pivot_flip_button')) { + this.model.flip(this.handle); + this.update(null, {reload: false}); + } + if ($target.hasClass('o_pivot_expand_button')) { + this.model + .expandAll(this.handle) + .then(this.update_state.bind(this)); + } + if ($target.parents('.o_pivot_measures_list').length) { + var parent = $target.parent(); + var field = parent.data('field'); + event.preventDefault(); + event.stopPropagation(); + this.model + .toggleMeasure(field) + .then(this.update.bind(this, {}, {reload: false})); + } + if ($target.hasClass('o_pivot_download')) { + this._downloadTable(); + } + }, + /** + * When the user clicks on a cell, and the view is configured to allow + * 'linking' (with enableLinking), we want to open a list view with the + * corresponding record. + * + * @private + * @param {MouseEvent} event + */ + _onCellClick: function (event) { + var $target = $(event.target); + if ($target.hasClass('o_pivot_header_cell_closed') || + $target.hasClass('o_pivot_header_cell_opened') || + $target.hasClass('o_empty') || + !this.enableLinking) { + return; + } + var row_id = $target.data('id'); + var col_id = $target.data('col_id'); + var state = this.model.get(this.handle); + var row_domain = state.data.headers[row_id].domain; + var col_domain = state.data.headers[col_id].domain; + var context = _.omit(_.clone(state.context), 'group_by'); + + function _find_view_info (views, view_type) { + return _.find(views, function (view) { + return view[1] === view_type; + }) || [false, view_type]; + } + var views = [ + _find_view_info(this.options.action.views, "list"), + _find_view_info(this.options.action.views, "form"), + ]; + + this.do_action({ + type: 'ir.actions.act_window', + name: this.title, + res_model: this.model, + views: views, + view_type : "list", + view_mode : "list", + target: 'current', + context: context, + domain: state.domain.concat(row_domain, col_domain), + }); + }, + /** + * When we click on a closed header cell, we either want to open the + * dropdown menu to select a new groupby level, or we want to open the + * clicked header, if a corresponging groupby has already been selected. + * + * @private + * @param {MouseEvent} event + */ + _onClosedHeaderClick: function (event) { + var $target = $(event.target); + var id = $target.data('id'); + var header = this.model.getHeader(id); + var groupbys = header.root.groupbys; + + if (header.path.length - 1 < groupbys.length) { + this.model + .expandHeader(header, groupbys[header.path.length - 1]) + .then(this.update.bind(this, {}, {reload: false})); + } else { + this.lastHeaderSelected = id; + var position = $target.position(); + var top = position.top + $target.height(); + var left = position.left + event.offsetX; + this._renderFieldSelection(top, left); + event.stopPropagation(); + } + }, + /** + * This handler is called when the user selects a field in the dropdown menu + * + * @private + * @param {MouseEvent} event + */ + _onFieldMenuSelection: function (event) { + event.preventDefault(); + var $target = $(event.target); + if ($target.parent().hasClass('disabled')) { + event.stopPropagation(); + return; + } + var field = $target.parent().data('field'); + var interval = $target.data('interval'); + var header = this.model.getHeader(this.lastHeaderSelected); + if (interval) { + field = field + ':' + interval; + } + this.model + .expandHeader(header, field) + .then(this.update.bind(this, {}, {reload: false})); + }, + /** + * If the user clicks on a measure row, we can perform an in-memory sort + * + * @private + * @param {MouseEvent} event + */ + _onMeasureRowClick: function (event) { + var $target = $(event.target); + var col_id = $target.data('id'); + var measure = $target.data('measure'); + var isAscending = $target.hasClass('o_pivot_measure_row_sorted_asc'); + this.model + .sortRows(this.handle, col_id, measure, isAscending) + .then(this.update_state.bind(this)); + }, + /** + * This method is called when someone clicks on an open header. When that + * happens, we want to close the header, then redisplay the view. + * + * @private + * @param {MouseEvent} event + */ + _onOpenHeaderClick: function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + var headerID = $(event.target).data('id'); + this.model.closeHeader(headerID) + 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 00000000000..cd203c22d35 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_model.js @@ -0,0 +1,704 @@ +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 + * + * @todo add a full description/specification of the data layout + */ + +var core = require('web.core'); +var utils = require('web.utils'); +var AbstractModel = require('web.AbstractModel'); + +var _t = core._t; + +var PivotModel = AbstractModel.extend({ + /** + * @override + * @param {Object} params + */ + init: function () { + this._super.apply(this, arguments); + this.numbering = {}; + this.data = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Close a header. This method is actually synchronous, but returns a + * deferred. + * + * @param {any} headerID + * @returns {Deferred} + */ + closeHeader: function (headerID) { + var header = this.data.headers[headerID]; + header.expanded = false; + header.children = []; + var newGroupbyLength = this._getHeaderDepth(header.root) - 1; + header.root.groupbys.splice(newGroupbyLength); + }, + /** + * @param {string} id + * @returns {Deferred} + */ + expandAll: function (id) { + var record = this.localData[id]; + return this._loadData(record); + }, + /** + * Expand (open up) a given header, be it a row or a column. + * + * @todo: add discussion on the number of read_group that it will generate, + * which is (r+1) or (c+1) I think + * + * @param {any} header + * @param {any} field + * @returns + */ + expandHeader: function (header, field) { + var self = this; + + var other_root = header.root.other_root; + var other_groupbys = header.root.other_root.groupbys; + var fields = [].concat(field, other_groupbys, this.data.measures); + var groupbys = []; + + for (var i = 0; i <= other_groupbys.length; i++) { + groupbys.push([field].concat(other_groupbys.slice(0,i))); + } + + return $.when.apply(null, groupbys.map(function (groupBy) { + return self.performModelRPC(self.modelName, 'read_group', [], { + context: self.data.context, + domain: header.domain.length ? header.domain : self.data.domain, + fields: _.map(fields, function (field) { return field.split(':')[0]; }), + groupby: groupBy, + lazy: false, + }); + })).then(function () { + var data = Array.prototype.slice.call(arguments); + var datapt, attrs, j, l, row, col, cell_value, groupBys; + for (i = 0; i < data.length; i++) { + for (j = 0; j < data[i].length; j++){ + datapt = data[i][j]; + groupBys = [field].concat(other_groupbys.slice(0,i)); + attrs = { + value: self._getValue(datapt, groupBys), + domain: datapt.__domain || [], + length: datapt.__count, + }; + + if (i === 0) { + row = self._makeHeader(attrs.value, attrs.domain, header.root, 0, 1, header); + } else { + row = self._getHeader(attrs.value, header.root, 0, 1, header); + } + col = self._getHeader(attrs.value, other_root, 1, i + 1); + if (!col) { + continue; + } + for (cell_value = {}, l=0; l < self.data.measures.length; l++) { + cell_value[self.data.measures[l]] = datapt[self.data.measures[l]]; + } + // cell_value.__count = attrs.length; + if (!self.data.cells[row.id]) { + self.data.cells[row.id] = []; + } + self.data.cells[row.id][col.id] = cell_value; + } + } + if (!_.contains(header.root.groupbys, field)) { + header.root.groupbys.push(field); + } + }); + }, + /** + * @returns {Object} + */ + exportData: function () { + var record = this.model.get(this.handle); + var measureNbr = record.measures.length; + var headers = this.renderer._computeHeaders(); + var measureRow = measureNbr > 1 ? _.last(headers) : []; + var rows = this.renderer._computeRows(); + var i, j, value; + headers[0].splice(0,1); + + // process measureRow + for (i = 0; i < measureRow.length; i++) { + measureRow[i].measure = this.measures[measureRow[i].measure].string; + } + // process all rows + for (i =0, j, value; i < rows.length; i++) { + for (j = 0; j < rows[i].values.length; j++) { + value = rows[i].values[j]; + rows[i].values[j] = { + is_bold: (i === 0) || + ((record.data.main_col.width > 1) && + (j >= rows[i].values.length - measureNbr)), + value: (value === undefined) ? "" : value, + }; + } + } + return { + headers: _.initial(headers), + measure_row: measureRow, + rows: rows, + nbr_measures: measureNbr, + title: this.title, + }; + }, + /** + * Swap the columns and the rows. It is a synchronous operation. + */ + flip: function () { + // swap the data: the main column and the main row + var temp = this.data.main_col; + this.data.main_col = this.data.main_row; + this.data.main_row = temp; + + // we need to update the record metadata: row and col groupBys + temp = this.data.groupedBy; + this.data.groupedBy = this.data.colGroupBys; + this.data.colGroupBys = temp; + }, + /** + * @override + * @param {Object} [options] + * @param {boolean} [options.raw=false] + * @returns {Object} + */ + get: function (options) { + var isRaw = options && options.raw; + return { + colGroupBys: this.data.main_col.groupbys, + fields: this.fields, + has_data: this.data.has_data, + headers: !isRaw && this._computeHeaders(), + mainColWidth: this.data.main_col.width, + measures: this.data.measures, + rows: !isRaw && this._computeRows(), + rowGroupBys: this.data.main_row.groupbys, + sortedColumn: this.data.sorted_column, + }; + }, + getHeader: function (id) { + return this.data.headers[id]; + }, + /** + * @override + * @param {Object} params + * @param {string[]} [params.groupedBy] + * @param {string[]} [params.colGroupBys] + * @param {string[]} params.domain + * @param {string[]} params.rowGroupBys + * @param {string[]} params.colGroupBys + * @param {string[]} params.measures + * @param {Object} params.fields + * @returns {Deferred} + */ + load: function (params) { + this.initialDomain = params.domain; + this.initialRowGroupBys = params.rowGroupBys; + this.initialColGroupBys = params.colGroupBys; + this.initialMeasures = params.measures; + this.fields = params.fields; + this.modelName = params.modelName; + var groupedBy = params.groupedBy.length ? params.groupedBy : this.initialRowGroupBys; + this.data = { + domain: params.domain, + context: params.context, + groupedBy: groupedBy, + colGroupBys: params.colGroupBys || this.initialColGroupBys, + measures: this.initialMeasures, + sorted_column: {}, + }; + return this._loadData(params); + }, + /** + * @override + * @param {any} handle this parameter is ignored + * @param {Object} params + * @returns {Deferred} + */ + reload: function (handle, params) { + var self = this; + var old_row_root = this.data.main_row.root; + var old_col_root = this.data.main_col.root; + if ('domain' in params) { + this.data.domain = params.domain; + } else { + this.data.domain = this.initialDomain; + } + + return this._loadData(params).then(function () { + self._updateTree(old_row_root, self.data.main_row.root); + var new_groupby_length = self._getHeaderDepth(self.data.main_row.root) - 1; + self.data.main_row.groupbys = old_row_root.groupbys.slice(0, new_groupby_length); + + self._updateTree(old_col_root, self.data.main_col.root); + new_groupby_length = self._getHeaderDepth(self.data.main_col.root) - 1; + self.data.main_row.groupbys = old_col_root.groupbys.slice(0, new_groupby_length); + }); + }, + /** + * Sort the rows, depending on the values of a given column. This is an + * in-memory sort. + * + * Note that it returns a deferred. @todo: makes sure this does not return a + * deferred. + * + * @param {any} id + * @param {any} col_id + * @param {any} measure + * @param {any} descending + * @returns {Deferred} + */ + sortRows: function (id, col_id, measure, descending) { + var record = this.localData[id]; + + _traverseTree(record.data.main_row.root, function (header) { + header.children.sort(compare); + }); + record.sorted_column = { + id: col_id, + measure: measure, + order: descending ? 'desc' : 'asc', + }; + function _getValue (id1, id2) { + if ((id1 in record.data.cells) && (id2 in record.data.cells[id1])) { + return record.data.cells[id1][id2]; + } + if (id2 in record.data.cells) return record.data.cells[id2][id1]; + } + + function compare (row1, row2) { + var values1 = _getValue(row1.id, col_id), + values2 = _getValue(row2.id, col_id), + value1 = values1 ? values1[measure] : 0, + value2 = values2 ? values2[measure] : 0; + return descending ? value1 - value2 : value2 - value1; + } + return $.when(id); + }, + /** + * Toggle the active state for a given measure, then reload the data. + * + * @param {string} field + * @returns {Deferred} + */ + toggleMeasure: function (field) { + if (_.contains(this.data.measures, field)) { + this.data.measures = _.without(this.data.measures, field); + // in this case, we already have all data in memory, no need to + // actually reload a lesser amount of information + return $.when(); + } else { + this.data.measures.push(field); + } + return this._loadData(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _computeHeaders: function () { + var self = this; + var main_col_dims = this._getHeaderDim(this.data.main_col.root); + var depth = main_col_dims.depth; + var width = main_col_dims.width; + var nbr_measures = this.data.measures.length; + var result = [[{width:1, height: depth + 1}]]; + var col_ids = []; + this.data.main_col.width = width; + this._traverseTree(this.data.main_col.root, function (header) { + var index = header.path.length - 1; + var cell = { + width: self._getHeaderWidth(header) * nbr_measures, + height: header.expanded ? 1 : depth - index, + title: header.path[header.path.length-1], + id: header.id, + expanded: header.expanded, + }; + if (!header.expanded) col_ids.push(header.id); + if (result[index]) result[index].push(cell); + else result[index] = [cell]; + }); + col_ids.push(this.data.main_col.root.id); + this.data.main_col.width = width; + if (width > 1) { + var total_cell = {width:nbr_measures, height: depth, title:""}; + if (nbr_measures === 1) { + total_cell.total = true; + } + result[0].push(total_cell); + } + var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures; + for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) { + measure = this.data.measures[i % nbr_measures]; + measure_row.push({ + measure: measure, + is_bold: (width > 1) && (i >= nbr_measures*width), + id: col_ids[Math.floor(i / nbr_measures)], + }); + } + result.push(measure_row); + return result; + }, + _computeRows: function () { + var self = this; + var aggregates, i; + var result = []; + this._traverseTree(this.data.main_row.root, function (header) { + var values = [], + col_ids = []; + result.push({ + id: header.id, + col_ids: col_ids, + indent: header.path.length - 1, + title: header.path[header.path.length-1], + expanded: header.expanded, + values: values, + }); + self._traverseTree(self.data.main_col.root, add_cells, header.id, values, col_ids); + if (self.data.main_col.width > 1) { + aggregates = self._getCellValue(header.id, self.data.main_col.root.id); + for (i = 0; i < self.data.measures.length; i++) { + values.push(aggregates && aggregates[self.data.measures[i]]); + } + col_ids.push( self.data.main_col.root.id); + } + }); + return result; + function add_cells (col_hdr, row_id, values, col_ids) { + if (col_hdr.expanded) return; + col_ids.push(col_hdr.id); + aggregates = self._getCellValue(row_id, col_hdr.id); + for (i = 0; i < self.data.measures.length; i++) { + values.push(aggregates && aggregates[self.data.measures[i]]); + } + } + }, + /** + * Static helper method + * + * @private @static + * @param {any} root + * @param {any} path + * @returns + */ + _findPathInTree: function (root, path) { + var i, + l = root.path.length; + if (l === path.length) { + return (root.path[l-1] === path[l - 1]) ? root : null; + } + for (i = 0; i < root.children.length; i++) { + if (root.children[i].path[l] === path[l]) { + return this._findPathInTree(root.children[i], path); + } + } + return null; + }, + _getCellValue: function (id1, id2) { + if ((id1 in this.data.cells) && (id2 in this.data.cells[id1])) { + return this.data.cells[id1][id2]; + } + if (id2 in this.data.cells) return this.data.cells[id2][id1]; + }, + /** + * @param {any} value + * @param {any} root + * @param {any} i + * @param {any} j + * @param {any} parent + * @returns {Object} + */ + _getHeader: function (value, root, i, j, parent) { + var path; + var total = _t("Total"); + if (parent) { + path = parent.path.concat(value.slice(i,j)); + } else { + path = [total].concat(value.slice(i,j)); + } + return this._findPathInTree(root, path); + }, + /** + * @private @static + * @param {any} header + * @returns {integer} + */ + _getHeaderDepth: function (header) { + var depth = 1; + this._traverseTree(header, function (hdr) { + depth = Math.max(depth, hdr.path.length); + }); + return depth; + }, + _getHeaderDim: function (header) { + var depth = 1; + var width = 0; + this._traverseTree(header, function (hdr) { + depth = Math.max(depth, hdr.path.length); + if (!hdr.expanded) width++; + }); + return {width: width, depth: depth}; + }, + _getHeaderWidth: function (header) { + var self = this; + if (!header.children.length) return 1; + if (!header.expanded) return 1; + return header.children.reduce(function (s, c) { + return s + self._getHeaderWidth(c); + }, 0); + }, + /** + * @param {any} value + * @param {any} field + * @returns {string} + */ + _getNumberedValue: function (value, field) { + var id= value[0]; + var name= value[1]; + this.numbering[field] = this.numbering[field] || {}; + this.numbering[field][name] = this.numbering[field][name] || {}; + var numbers = this.numbering[field][name]; + numbers[id] = numbers[id] || _.size(numbers) + 1; + return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); + }, + /** + * @param {any} datapt + * @param {any} fields + * @returns {string[]} + */ + _getValue: function (datapt, fields) { + var result = []; + var value; + for (var i = 0; i < fields.length; i++) { + value = this._sanitizeValue(datapt[fields[i]],fields[i]); + result.push(value); + } + return result; + }, + /** + * @returns {Deferred} + */ + _loadData: function () { + var self = this; + var groupBys = []; + var rowGroupBys = this.data.groupedBy; + var colGroupBys = this.data.colGroupBys; + var fields = [].concat(rowGroupBys, colGroupBys, this.data.measures); + + for (var i = 0; i < rowGroupBys.length + 1; i++) { + for (var j = 0; j < colGroupBys.length + 1; j++) { + groupBys.push(rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j))); + } + } + + return $.when.apply(null, groupBys.map(function (groupBy) { + return self.performModelRPC(self.modelName, 'read_group', [], { + context: self.data.context, + domain: self.data.domain, + fields: _.map(fields, function (field) { return field.split(':')[0]; }), + groupby: groupBy, + lazy: false, + }); + })).then(function () { + var data = Array.prototype.slice.call(arguments); + if (data[0][0].__count === 0) { + self.data.has_data = false; + return; + } + self._prepareData(data); + }); + }, + /** + * @param {any} value + * @param {any} domain + * @param {any} root + * @param {any} i + * @param {any} j + * @param {any} parent_header + * @returns {Object} + */ + _makeHeader: function (value, domain, root, i, j, parent_header) { + var total = _t("Total"); + var title = value.length ? value[value.length - 1] : total; + var path, parent; + if (parent_header) { + path = parent_header.path.concat(title); + parent = parent_header; + } else { + path = [total].concat(value.slice(i,j-1)); + parent = value.length ? this._findPathInTree(root, path) : null; + } + var header = { + id: utils.generateID(), + expanded: false, + domain: domain || [], + children: [], + path: value.length ? parent.path.concat(title) : [title] + }; + this.data.headers[header.id] = header; + header.root = root || header; + if (parent) { + parent.children.push(header); + parent.expanded = true; + } + return header; + }, + /** + * @param {Object} data + */ + _prepareData: function (data) { + var self = this; + _.extend(self.data, { + main_row: {}, + main_col: {}, + headers: {}, + cells: [], + }); + + var index = 0; + var rowGroupBys = this.data.groupedBy; + var colGroupBys = this.data.colGroupBys; + var datapt, row, col, attrs, cell_value; + var main_row_header, main_col_header; + var groupBys; + var m; + + + for (var i = 0; i < rowGroupBys.length + 1; i++) { + for (var j = 0; j < colGroupBys.length + 1; j++) { + for (var k = 0; k < data[index].length; k++) { + datapt = data[index][k]; + groupBys = rowGroupBys.slice(0,i).concat(colGroupBys.slice(0,j)); + attrs = { + value: self._getValue(datapt, groupBys), + domain: datapt.__domain || [], + length: datapt.__count, + }; + + if (j === 0) { + row = this._makeHeader(attrs.value, attrs.domain, main_row_header, 0, i); + } else { + row = this._getHeader(attrs.value, main_row_header, 0, i); + } + if (i === 0) { + col = this._makeHeader(attrs.value, attrs.domain, main_col_header, i, i+j); + } else { + col = this._getHeader(attrs.value, main_col_header, i, i+j); + } + if (i + j === 0) { + this.data.has_data = attrs.length > 0; + main_row_header = row; + main_col_header = col; + } + if (!this.data.cells[row.id]) this.data.cells[row.id] = []; + for (cell_value = {}, m=0; m < this.data.measures.length; m++) { + cell_value[this.data.measures[m]] = datapt[this.data.measures[m]]; + } + this.data.cells[row.id][col.id] = cell_value; + } + index++; + } + } + + this.data.main_row.groupbys = rowGroupBys; + this.data.main_col.groupbys = colGroupBys; + + main_row_header.other_root = main_col_header; + main_col_header.other_root = main_row_header; + + main_row_header.groupbys = rowGroupBys; + main_col_header.groupbys = colGroupBys; + + this.data.main_row.root = main_row_header; + this.data.main_col.root = main_col_header; + }, + /** + * Format a value to a usable string, for the renderer to display. + * + * @param {any} value + * @param {any} field + * @returns {string} + */ + _sanitizeValue: function (value, field) { + if (value === false) { + return _t("Undefined"); + } + if (value instanceof Array) { + return this._getNumberedValue(value, field); + } + if (field && this.fields[field] && (this.fields[field].type === 'selection')) { + var selected = _.where(this.fields[field].selection, {0: value})[0]; + return selected ? selected[1] : value; + } + return value; + }, + /** + * @private @static + * @param {any} root + * @param {any} f + * @param {any} arg1 + * @param {any} arg2 + * @param {any} arg3 + * @returns + */ + _traverseTree: function (root, f, arg1, arg2, arg3) { + f(root, arg1, arg2, arg3); + if (!root.expanded) return; + for (var i = 0; i < root.children.length; i++) { + this._traverseTree(root.children[i], f, arg1, arg2, arg3); + } + }, + /** + * @param {Object} old_tree + * @param {Object} new_tree + */ + _updateTree: function (old_tree, new_tree) { + if (!old_tree.expanded) { + new_tree.expanded = false; + new_tree.children = []; + return; + } + var tree, j, old_title, new_title; + for (var i = 0; i < new_tree.children.length; i++) { + tree = undefined; + new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1]; + for (j = 0; j < old_tree.children.length; j++) { + old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1]; + if (old_title === new_title) { + tree = old_tree.children[j]; + break; + } + } + if (tree) this._updateTree(tree, new_tree.children[i]); + else { + new_tree.children[i].expanded = false; + new_tree.children[i].children = []; + } + } + }, +}); + +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 00000000000..0323dfd8bed --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -0,0 +1,142 @@ +odoo.define('web.PivotRenderer', function (require) { +"use strict"; + +var AbstractRenderer = require('web.AbstractRenderer'); +var field_utils = require('web.field_utils'); + +var PivotRenderer = AbstractRenderer.extend({ + tagName: 'table', + className: 'table-hover table-condensed table-bordered', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {Deferred} + */ + _render: function () { + var $fragment = $(document.createDocumentFragment()); + var $table = $('<table>').appendTo($fragment); + var $thead = $('<thead>').appendTo($table); + var $tbody = $('<tbody>').appendTo($table); + var nbr_measures = this.state.measures.length; + var nbrCols = (this.state.mainColWidth === 1) ? + nbr_measures : + (this.state.mainColWidth + 1) * nbr_measures; + for (var i=0; i < nbrCols + 1; i++) { + $table.prepend($('<col>')); + } + this._renderHeaders($thead, this.state.headers); + this._renderRows($tbody, this.state.rows); + $table.on('hover', 'td', function () { + $table.find('col:eq(' + $(this).index()+')').toggleClass('hover'); + }); + // todo: make sure the next line does something + $table.find('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip(); + this.$el.html($table.contents()); + return this._super.apply(this, arguments); + }, + /** + * @private + * @param {jQueryElement} $thead + * @param {jQueryElement} headers + */ + _renderHeaders: function ($thead, headers) { + var self = this; + var i, j, cell, $row, $cell; + + var groupbyLabels = _.map(this.state.colGroupBys, function (gb) { + return self.state.fields[gb.split(':')[0]].string; + }); + + for (i = 0; i < headers.length; i++) { + $row = $('<tr>'); + for (j = 0; j < headers[i].length; j++) { + cell = headers[i][j]; + $cell = $('<th>') + .text(cell.title) + .attr('rowspan', cell.height) + .attr('colspan', cell.width); + if (i > 0) { + $cell.attr('title', groupbyLabels[i-1]); + } + if (cell.expanded !== undefined) { + $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); + $cell.data('id', cell.id); + } + if (cell.measure) { + $cell.addClass('o_pivot_measure_row text-muted') + .text(this.state.fields[cell.measure].string); + $cell.data('id', cell.id).data('measure', cell.measure); + if (cell.id === this.state.sortedColumn.id && cell.measure === this.state.sortedColumn.measure) { + $cell.addClass('o_pivot_measure_row_sorted_' + this.state.sortedColumn.order); + } + } + $row.append($cell); + + $cell.toggleClass('hidden-xs', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.state.measures.length)); + if (cell.height > 1) { + $cell.css('padding', 0); + } + } + $thead.append($row); + } + }, + /** + * @private + * @param {jQueryElement} $tbody + * @param {jQueryElement} rows + */ + _renderRows: function ($tbody, rows) { + var self = this; + var i, j, value, measure, name, $row, $cell, $header; + var nbrMeasures = this.state.measures.length; + var length = rows[0].values.length; + var shouldDisplayTotal = this.state.mainColWidth > 1; + + var groupbyLabels = _.map(this.state.rowGroupBys, function (gb) { + return self.state.fields[gb.split(':')[0]].string; + }); + var measureTypes = this.state.measures.map(function (name) { + return self.state.fields[name].type; + }); + for (i = 0; i < rows.length; i++) { + $row = $('<tr>'); + $header = $('<td>') + .text(rows[i].title) + .data('id', rows[i].id) + .css('padding-left', (5 + rows[i].indent * 30) + 'px') + .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); + if (rows[i].indent > 0) $header.attr('title', groupbyLabels[rows[i].indent - 1]); + $header.appendTo($row); + for (j = 0; j < length; j++) { + value = rows[i].values[j]; + if (value !== undefined) { + name = this.state.measures[j % nbrMeasures]; + measure = this.state.fields[name]; + value = field_utils['format_' + measureTypes[j % nbrMeasures]](value, measure); + } + $cell = $('<td>') + .data('id', rows[i].id) + .data('col_id', rows[i].col_ids[Math.floor(j / nbrMeasures)]) + .toggleClass('o_empty', !value) + .text(value) + .addClass('o_pivot_cell_value text-right'); + if (((j >= length - this.state.measures.length) && shouldDisplayTotal) || i === 0){ + $cell.css('font-weight', 'bold'); + } + $row.append($cell); + + $cell.toggleClass('hidden-xs', j < length - nbrMeasures); + } + $tbody.append($row); + } + }, + +}); + +return 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 00000000000..777d9bcb5a7 --- /dev/null +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -0,0 +1,103 @@ +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. + */ + +var AbstractView = require('web.AbstractView'); +var core = require('web.core'); +var PivotModel = require('web.PivotModel'); +var PivotController = require('web.PivotController'); +var PivotRenderer = require('web.PivotRenderer'); + +var _t = core._t; +var _lt = core._lt; + +var GROUPABLE_TYPES = + ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; + +var PivotView = AbstractView.extend({ + display_name: _lt('Pivot'), + icon: 'fa-table', + config: { + Model: PivotModel, + Controller: PivotController, + Renderer: PivotRenderer, + }, + /** + * @override + * @param {Object} params + */ + init: function (arch, fields, params) { + this._super.apply(this, arguments); + + var activeMeasures = []; + var colGroupBys = []; + var rowGroupBys = []; + + var measures = {}; + var groupableFields = {}; + + _.each(fields, function (field, name) { + if ((name !== 'id') && (field.store === true)) { + if (_.contains(['integer', 'float', 'monetary'], field.type)) { + measures[name] = field; + } + if (_.contains(GROUPABLE_TYPES, field.type)) { + groupableFields[name] = field; + } + } + }); + measures.__count = {string: _t("Count"), type: "integer"}; + + arch.children.forEach(function (field) { + var name = field.attrs.name; + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + + // 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.type === 'measure' && !(field.name in measures)) { + measures[field.name] = field; + } + if (field.attrs.type === 'measure' || 'operator' in field.attrs) { + activeMeasures.push(name); + } + if (field.attrs.type === 'col') { + colGroupBys.push(name); + } + if (field.attrs.type === 'row') { + rowGroupBys.push(name); + } + }); + if ((!activeMeasures.length) || arch.attrs.display_quantity) { + activeMeasures.push('__count'); + } + fields.__count = {string: _t("Count"), type: "integer"}; + + this.loadParams.measures = activeMeasures; + this.loadParams.colGroupBys = colGroupBys; + this.loadParams.rowGroupBys = rowGroupBys; + this.loadParams.fields = fields; + + this.controllerParams.title = params.title || arch.attrs.string || _t("Untitled"); + this.controllerParams.enableLinking = !arch.attrs.disable_linking; + this.controllerParams.measures = measures; + this.controllerParams.groupableFields = groupableFields; + }, +}); + +return PivotView; + +}); + + diff --git a/addons/web/static/src/js/views/pivot_view.js b/addons/web/static/src/js/views/pivot_view.js deleted file mode 100644 index 95e8d13e5ff..00000000000 --- a/addons/web/static/src/js/views/pivot_view.js +++ /dev/null @@ -1,892 +0,0 @@ -odoo.define('web.PivotView', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Pivot Table view - *---------------------------------------------------------*/ - -var core = require('web.core'); -var crash_manager = require('web.crash_manager'); -var data_manager = require('web.data_manager'); -var formats = require('web.formats'); -var framework = require('web.framework'); -var Model = require('web.DataModel'); -var session = require('web.session'); -var Sidebar = require('web.Sidebar'); -var utils = require('web.utils'); -var View = require('web.View'); - -var _lt = core._lt; -var _t = core._t; -var QWeb = core.qweb; - -var PivotView = View.extend({ - events: { - 'click .o_pivot_header_cell_opened': 'on_open_header_click', - 'click .o_pivot_header_cell_closed': 'on_closed_header_click', - 'click .o_pivot_field_menu a': 'on_field_menu_selection', - 'click td': 'on_cell_click', - 'click .o_pivot_measure_row': 'on_measure_row_click', - }, - display_name: _lt('Pivot'), - icon: 'fa-table', - require_fields: true, - template: 'PivotView', - - init: function() { - this._super.apply(this, arguments); - this._model = new Model(this.model, {group_by_no_leaf: true}); - - this.measures = {}; - this.groupable_fields = {}; - this.ready = false; // will be ready after the first do_search - this.data_loaded = $.Deferred(); - this.title = this.options.title || this.fields_view.arch.attrs.string; - - this.main_row = { - root: undefined, - groupbys: [], - }; - this.main_col = { - root: undefined, - groupbys: [], - }; - this.initial_col_groupby = []; - this.initial_row_groupby = []; - - this.active_measures = []; - this.headers = {}; - this.cells = {}; - this.has_data = false; - this.$buttons = $(); - - this.last_header_selected = null; - this.sorted_column = {}; - - this.numbering = {}; - this.widgets = []; - - this.enable_linking = !this.fields_view.arch.attrs.disable_linking; - }, - willStart: function () { - var self = this; - - var fields_def = data_manager.load_fields(this.dataset); - var xlwt_def = session.rpc('/web/pivot/check_xlwt').then(function(result) { - self.xlwt_installed = result; - }); - - this.fields_view.arch.children.forEach(function (field) { - var name = field.attrs.name; - if (field.attrs.interval) { - name += ':' + field.attrs.interval; - } - //noinspection FallThroughInSwitchStatementJS - switch (field.attrs.type) { - case 'measure': - self.widgets.push(field.attrs.widget || ""); - self.active_measures.push(name); - break; - case 'col': - self.initial_col_groupby.push(name); - break; - default: - if ('operator' in field.attrs) { - self.active_measures.push(name); - break; - } - case 'row': - self.initial_row_groupby.push(name); - } - }); - if ((!this.active_measures.length) || this.fields_view.arch.attrs.display_quantity) { - this.active_measures.push('__count__'); - } - - return $.when(fields_def, xlwt_def, this._super()).then(function (fields) { - self.prepare_fields(fields); - // 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). - _.each(self.active_measures, function (m) { - if (!(m in self.measures)) { - self.measures[m] = self.fields[m]; - } - }); - }); - }, - start: function () { - this.$el.toggleClass('o_enable_linking', this.enable_linking); - this.$field_selection = this.$('.o_field_selection'); - core.bus.on('click', this, function () { - this.$field_selection.empty(); - }); - return this._super(); - }, - render_field_selection: function (top, left) { - var grouped_fields = this.main_row.groupbys - .concat(this.main_col.groupbys) - .map(function(f) { return f.split(':')[0];}); - - var fields = _.chain(this.groupable_fields) - .pairs() - .sortBy(function(f) { return f[1].string; }) - .map(function (f) { return [f[0], f[1], _.contains(grouped_fields, f[0])]; }) - .value(); - - this.$field_selection.html(QWeb.render('PivotView.FieldSelection', { - fields: fields - })); - - this.$field_selection.find('ul').first() - .css({top: top, left: left}) - .show(); - }, - /** - * Render the buttons according to the PivotView.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 PivotView does nothing - */ - render_buttons: function ($node) { - if ($node) { - var self = this; - - var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; - this.$buttons = $(QWeb.render('PivotView.buttons', context)); - this.$buttons.click(this.on_button_click.bind(this)); - this.active_measures.forEach(function (measure) { - self.$buttons.find('li[data-field="' + measure + '"]').addClass('selected'); - }); - this.$buttons.find('button').tooltip(); - - this.$buttons.appendTo($node); - } - }, - /** - * Instantiate and render the sidebar. - * Sets this.sidebar - * @param {jQuery} [$node] a jQuery node where the sidebar should be inserted - * $node may be undefined, in which case the PivotView does nothing - **/ - render_sidebar: function($node) { - if (this.xlwt_installed && $node && this.options.sidebar) { - this.sidebar = new Sidebar(this, {editable: this.is_action_enabled('edit')}); - this.sidebar.appendTo($node); - } - }, - prepare_fields: function (fields) { - var self = this, - groupable_types = ['many2one', 'char', 'boolean', - 'selection', 'date', 'datetime']; - this.fields = fields; - _.each(fields, function (field, name) { - if ((name !== 'id') && (field.store === true)) { - if (field.type === 'integer' || field.type === 'float' || field.type === 'monetary') { - self.measures[name] = field; - } - if (_.contains(groupable_types, field.type)) { - self.groupable_fields[name] = field; - } - } - }); - this.measures.__count__ = {string: _t("Count"), type: "integer"}; - }, - do_search: function (domain, context, group_by) { - if (!this.ready) { - this.initial_row_groupby = context.pivot_row_groupby || this.initial_row_groupby; - this.initial_col_groupby = context.pivot_col_groupby || this.initial_col_groupby; - } - this.main_row.groupbys = group_by.length ? group_by : (context.pivot_row_groupby || this.initial_row_groupby.slice(0)); - this.main_col.groupbys = context.pivot_column_groupby || this.initial_col_groupby.slice(0); - this.active_measures = context.pivot_measures || this.active_measures; - - this.domain = domain; - this.context = context; - if (!this.ready) { - this.data_loaded = this.load_data(true); - this.ready = true; - return; - } - this.data_loaded = this.load_data(false); - return this.do_show(); - }, - do_show: function () { - var self = this; - var _super = this._super.bind(this); - this.do_push_state({}); - return this.data_loaded.done(function () { - self.display_table(); - self.$buttons.find('.o_pivot_measures_list li').removeClass('selected'); - self.active_measures.forEach(function (measure) { - self.$buttons.find('li[data-field="' + measure + '"]').addClass('selected'); - }); - _super(); - }); - }, - get_context: function () { - return !this.ready ? {} : { - pivot_measures: this.active_measures, - pivot_column_groupby: this.main_col.groupbys, - pivot_row_groupby: this.main_row.groupbys, - }; - }, - on_button_click: function (event) { - var $target = $(event.target); - if ($target.hasClass('o_pivot_flip_button')) { return this.flip(); } - if ($target.hasClass('o_pivot_expand_button')) { return this.expand_all(); } - if ($target.parents('.o_pivot_measures_list').length) { - var parent = $target.parent(); - var field = parent.data('field'); - parent.toggleClass('selected'); - event.preventDefault(); - event.stopPropagation(); - return this.toggle_measure(field); - } - if ($target.hasClass('o_pivot_download')) { - return this.download_table(); - } - }, - on_open_header_click: function (event) { - var id = $(event.target).data('id'), - header = this.headers[id]; - header.expanded = false; - header.children = []; - var new_groupby_length = this.get_header_depth(header.root) - 1; - header.root.groupbys.splice(new_groupby_length); - this.display_table(); - }, - on_closed_header_click: function (event) { - var $target = $(event.target); - var id = $target.data('id'); - var header = this.headers[id]; - var groupbys = header.root.groupbys; - - if (header.path.length - 1 < groupbys.length) { - this.expand_header(header, groupbys[header.path.length - 1]) - .then(this.proxy('display_table')); - } else { - this.last_header_selected = id; - var position = $target.position(); - var top = position.top + $target.height(); - var left = position.left + event.offsetX; - this.render_field_selection(top, left); - event.stopPropagation(); - } - }, - on_cell_click: function (event) { - var $target = $(event.target); - if ($target.hasClass('o_pivot_header_cell_closed') - || $target.hasClass('o_pivot_header_cell_opened') - || $target.hasClass('o_empty') - || !this.enable_linking) { - return; - } - var row_id = $target.data('id'), - col_id = $target.data('col_id'), - row_domain = this.headers[row_id].domain, - col_domain = this.headers[col_id].domain, - context = _.omit(_.clone(this.context), 'group_by'); - - var views = [ - _find_view_info.call(this, "list"), - _find_view_info.call(this, "form"), - ]; - - return this.do_action({ - type: 'ir.actions.act_window', - name: this.title, - res_model: this.model, - views: views, - view_type : "list", - view_mode : "list", - target: 'current', - context: context, - domain: this.domain.concat(row_domain, col_domain), - }); - - function _find_view_info(view_type) { - return _.find(this.options.action.views, function (view) { - return view[1] === view_type; - }) || [false, view_type]; - } - }, - on_measure_row_click: function (event) { - var $target = $(event.target), - col_id = $target.data('id'), - measure = $target.data('measure'); - - this.sort_rows(col_id, measure, $target.hasClass('o_pivot_measure_row_sorted_asc')); - this.display_table(); - }, - sort_rows: function (col_id, measure, descending) { - var self = this; - traverse_tree(this.main_row.root, function (header) { - header.children.sort(compare); - }); - this.sorted_column = { - id: col_id, - measure: measure, - order: descending ? 'desc' : 'asc', - }; - - function compare (row1, row2) { - var values1 = self.get_value(row1.id, col_id), - values2 = self.get_value(row2.id, col_id), - value1 = values1 ? values1[measure] : 0, - value2 = values2 ? values2[measure] : 0; - return descending ? value1 - value2 : value2 - value1; - } - }, - on_field_menu_selection: function (event) { - event.preventDefault(); - var $target = $(event.target); - if ($target.parent().hasClass('disabled')) { - event.stopPropagation(); - return; - } - var field = $target.parent().data('field'), - interval = $target.data('interval'), - header = this.headers[this.last_header_selected]; - if (interval) field = field + ':' + interval; - this.expand_header(header, field) - .then(function () { - header.root.groupbys.push(field); - }) - .then(this.proxy('display_table')); - }, - expand_header: function (header, field) { - var self = this; - - var other_root = header.root.other_root, - other_groupbys = header.root.other_root.groupbys, - fields = [].concat(field, other_groupbys, this.active_measures), - groupbys = []; - - for (var i = 0; i <= other_groupbys.length; i++) { - groupbys.push([field].concat(other_groupbys.slice(0,i))); - } - return $.when.apply(null, groupbys.map(function (groupby) { - return self._model.query(fields) - .filter(header.domain.length ? header.domain : self.domain) - .context(self.context) - .lazy(false) - .group_by(groupby); - })).then(function () { - var data = Array.prototype.slice.call(arguments), - datapt, attrs, j, k, l, row, col, cell_value, field_name; - for (i = 0; i < data.length; i++) { - for (j = 0; j < data[i].length; j++){ - datapt = data[i][j]; - attrs = datapt.attributes; - if (i === 0) attrs.value = [attrs.value]; - for (k = 0; k < attrs.value.length; k++) { - if (k < 1) field_name = field; - else field_name = other_groupbys[k - 1]; - attrs.value[k] = self.sanitize_value(attrs.value[k], field_name); - } - if (i === 0) { - row = self.make_header(datapt, header.root, 0, 1, header); - } else { - row = self.get_header(datapt, header.root, 0, 1, header); - } - col = self.get_header(datapt, other_root, 1, i + 1); - if (!col) continue; - for (cell_value = {}, l=0; l < self.active_measures.length; l++) { - cell_value[self.active_measures[l]] = attrs.aggregates[self.active_measures[l]]; - } - cell_value.__count__ = attrs.length; - if (!self.cells[row.id]) self.cells[row.id] = []; - self.cells[row.id][col.id] = cell_value; - } - } - }); - }, - expand_all: function () { - this.load_data(false).then(this.proxy('display_table')); - }, - // returns a deferred that resolve when the data is loaded. - load_data: function (update) { - var should_update = (update && this.main_row.root && this.main_col.root); - var self = this, - i, j, - groupbys = [], - row_gbs = this.main_row.groupbys, - col_gbs = this.main_col.groupbys, - fields = [].concat(row_gbs, col_gbs, this.active_measures); - for (i = 0; i < row_gbs.length + 1; i++) { - for (j = 0; j < col_gbs.length + 1; j++) { - groupbys.push(row_gbs.slice(0,i).concat(col_gbs.slice(0,j))); - } - } - return $.when.apply(null, groupbys.map(function (groupby) { - return self._model.query(fields) - .filter(self.domain) - .context(self.context) - .lazy(false) - // .order_by(['probable_revenue']) - .group_by(groupby); - })).then(function () { - var data = Array.prototype.slice.call(arguments); - self.prepare_data(data, should_update); - }); - }, - prepare_data: function (data, should_update) { - var i, j, k, l, m, - index = 0, - row_gbs = this.main_row.groupbys, - col_gbs = this.main_col.groupbys, - main_row_header, main_col_header, - row, col, attrs, datapt, cell_value, - field; - - for (i = 0; i < row_gbs.length + 1; i++) { - for (j = 0; j < col_gbs.length + 1; j++) { - for (k = 0; k < data[index].length; k++) { - datapt = data[index][k]; - attrs = datapt.attributes; - if (i + j === 1) { - attrs.value = [attrs.value]; - } - for (l = 0; l < attrs.value.length; l++) { - if (l < i) field = row_gbs[l]; - else field = col_gbs[l - i]; - attrs.value[l] = this.sanitize_value(attrs.value[l], field); - } - if (j === 0) { - row = this.make_header(datapt, main_row_header, 0, i); - } else { - row = this.get_header(datapt, main_row_header, 0, i); - } - if (i === 0) { - col = this.make_header(datapt, main_col_header, i, i+j); - } else { - col = this.get_header(datapt, main_col_header, i, i+j); - } - if (i + j === 0) { - this.has_data = attrs.length > 0; - main_row_header = row; - main_col_header = col; - } - if (!this.cells[row.id]) this.cells[row.id] = []; - for (cell_value = {}, m=0; m < this.active_measures.length; m++) { - cell_value[this.active_measures[m]] = attrs.aggregates[this.active_measures[m]]; - } - cell_value.__count__ = attrs.length; - this.cells[row.id][col.id] = cell_value; - } - index++; - } - } - if (should_update) { - this.update_tree(this.main_row.root, main_row_header); - var new_groupby_length = this.get_header_depth(main_row_header) - 1; - main_row_header.groupbys = this.main_row.root.groupbys.slice(0, new_groupby_length); - this.update_tree(this.main_col.root, main_col_header); - new_groupby_length = this.get_header_depth(main_col_header) - 1; - main_col_header.groupbys = this.main_col.root.groupbys.slice(0, new_groupby_length); - } else { - main_row_header.groupbys = this.main_row.groupbys; - main_col_header.groupbys = this.main_col.groupbys; - } - main_row_header.other_root = main_col_header; - main_col_header.other_root = main_row_header; - this.main_row.root = main_row_header; - this.main_col.root = main_col_header; - }, - update_tree: function (old_tree, new_tree) { - if (!old_tree.expanded) { - new_tree.expanded = false; - new_tree.children = []; - return; - } - var tree, j, old_title, new_title; - for (var i = 0; i < new_tree.children.length; i++) { - tree = undefined; - new_title = new_tree.children[i].path[new_tree.children[i].path.length - 1]; - for (j = 0; j < old_tree.children.length; j++) { - old_title = old_tree.children[j].path[old_tree.children[j].path.length - 1]; - if (old_title === new_title) { - tree = old_tree.children[j]; - break; - } - } - if (tree) this.update_tree(tree, new_tree.children[i]); - else { - new_tree.children[i].expanded = false; - new_tree.children[i].children = []; - } - } - }, - sanitize_value: function (value, field) { - if (value === false) return _t("Undefined"); - if (value instanceof Array) return this.get_numbered_value(value, field); - if (field && this.fields[field] && (this.fields[field].type === 'selection')) { - var selected = _.where(this.fields[field].selection, {0: value})[0]; - return selected ? selected[1] : value; - } - return value; - }, - get_numbered_value: function(value, field) { - var id= value[0]; - var name= value[1] - this.numbering[field] = this.numbering[field] || {}; - this.numbering[field][name] = this.numbering[field][name] || {}; - var numbers = this.numbering[field][name]; - numbers[id] = numbers[id] || _.size(numbers) + 1; - return name + (numbers[id] > 1 ? " (" + numbers[id] + ")" : ""); - }, - make_header: function (data_pt, root, i, j, parent_header) { - var total = _t("Total"); - var attrs = data_pt.attributes, - value = attrs.value, - title = value.length ? value[value.length - 1] : total; - var path, parent; - if (parent_header) { - path = parent_header.path.concat(title); - parent = parent_header; - } else { - path = [total].concat(value.slice(i,j-1)); - parent = value.length ? find_path_in_tree(root, path) : null; - } - var header = { - id: utils.generate_id(), - expanded: false, - domain: data_pt.model._domain, - children: [], - path: value.length ? parent.path.concat(title) : [title] - }; - this.headers[header.id] = header; - header.root = root || header; - if (parent) { - parent.children.push(header); - parent.expanded = true; - } - return header; - }, - get_header: function (data_pt, root, i, j, parent) { - var path; - var total = _t("Total"); - if (parent) { - path = parent.path.concat(data_pt.attributes.value.slice(i,j)); - } else { - path = [total].concat(data_pt.attributes.value.slice(i,j)); - } - return find_path_in_tree(root, path); - }, - _clean_table: function () { - return this.$el - .find('table') - .remove() - .end() - .find('.oe_view_nocontent') - .remove() - .end(); - }, - display_table: function () { - if (!this.active_measures.length || !this.has_data) { - return this._clean_table().append(QWeb.render('PivotView.nodata')); - } - var $fragment = $(document.createDocumentFragment()), - $table = $('<table>') - .addClass('table table-hover table-condensed table-bordered') - .appendTo($fragment), - $thead = $('<thead>').appendTo($table), - $tbody = $('<tbody>').appendTo($table), - headers = this.compute_headers(), - rows = this.compute_rows(), - nbr_measures = this.active_measures.length, - nbr_cols = (this.main_col.width === 1) ? nbr_measures : (this.main_col.width + 1)*nbr_measures; - for (var i=0; i < nbr_cols + 1; i++) { - $table.prepend($('<col>')); - } - this.draw_headers($thead, headers); - this.draw_rows($tbody, rows); - $table.on('hover', 'td', function () { - $table.find('col:eq(' + $(this).index()+')').toggleClass('hover'); - }); - this._clean_table().append($fragment); - this.$('.o_pivot_header_cell_opened,.o_pivot_header_cell_closed').tooltip(); - }, - draw_headers: function ($thead, headers) { - var self = this, - i, j, cell, $row, $cell; - - var groupby_labels = _.map(this.main_col.groupbys, function (gb) { - return self.fields[gb.split(':')[0]].string; - }); - - for (i = 0; i < headers.length; i++) { - $row = $('<tr>'); - for (j = 0; j < headers[i].length; j++) { - cell = headers[i][j]; - $cell = $('<th>') - .text(cell.title) - .attr('rowspan', cell.height) - .attr('colspan', cell.width); - if (i > 0) { - $cell.attr('title', groupby_labels[i-1]); - } - if (cell.expanded !== undefined) { - $cell.addClass(cell.expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); - $cell.data('id', cell.id); - } - if (cell.measure) { - $cell.addClass('o_pivot_measure_row text-muted') - .text(this.measures[cell.measure].string); - $cell.data('id', cell.id).data('measure', cell.measure); - if (cell.id === this.sorted_column.id && cell.measure === this.sorted_column.measure) { - $cell.addClass('o_pivot_measure_row_sorted_' + this.sorted_column.order); - } - } - $row.append($cell); - - $cell.toggleClass('hidden-xs', (cell.expanded !== undefined) || (cell.measure !== undefined && j < headers[i].length - this.active_measures.length)); - if (cell.height > 1) { - $cell.css('padding', 0); - } - } - $thead.append($row); - } - }, - draw_rows: function ($tbody, rows) { - var self = this, - i, j, value, $row, $cell, $header, - nbr_measures = this.active_measures.length, - length = rows[0].values.length, - display_total = this.main_col.width > 1; - - var groupby_labels = _.map(this.main_row.groupbys, function (gb) { - return self.fields[gb.split(':')[0]].string; - }); - var measure_types = this.active_measures.map(function (name) { - return self.measures[name].type; - }); - var widgets = this.widgets; - for (i = 0; i < rows.length; i++) { - $row = $('<tr>'); - $header = $('<td>') - .text(rows[i].title) - .data('id', rows[i].id) - .css('padding-left', (5 + rows[i].indent * 30) + 'px') - .addClass(rows[i].expanded ? 'o_pivot_header_cell_opened' : 'o_pivot_header_cell_closed'); - if (rows[i].indent > 0) $header.attr('title', groupby_labels[rows[i].indent - 1]); - $header.appendTo($row); - for (j = 0; j < length; j++) { - value = formats.format_value(rows[i].values[j], {type: measure_types[j % nbr_measures], widget: widgets[j % nbr_measures]}); - $cell = $('<td>') - .data('id', rows[i].id) - .data('col_id', rows[i].col_ids[Math.floor(j / nbr_measures)]) - .toggleClass('o_empty', !value) - .text(value) - .addClass('o_pivot_cell_value text-right'); - if (((j >= length - this.active_measures.length) && display_total) || i === 0){ - $cell.css('font-weight', 'bold'); - } - $row.append($cell); - - $cell.toggleClass('hidden-xs', j < length - this.active_measures.length); - } - $tbody.append($row); - } - }, - compute_headers: function () { - var self = this, - main_col_dims = this.get_header_width_depth(this.main_col.root), - depth = main_col_dims.depth, - width = main_col_dims.width, - nbr_measures = this.active_measures.length, - result = [[{width:1, height: depth + 1}]], - col_ids = []; - this.main_col.width = width; - traverse_tree(this.main_col.root, function (header) { - var index = header.path.length - 1, - cell = { - width: self.get_header_width(header) * nbr_measures, - height: header.expanded ? 1 : depth - index, - title: header.path[header.path.length-1], - id: header.id, - expanded: header.expanded, - }; - if (!header.expanded) col_ids.push(header.id); - if (result[index]) result[index].push(cell); - else result[index] = [cell]; - }); - col_ids.push(this.main_col.root.id); - this.main_col.width = width; - if (width > 1) { - var total_cell = {width:nbr_measures, height: depth, title:""}; - if (nbr_measures === 1) { - total_cell.total = true; - } - result[0].push(total_cell); - } - var nbr_cols = width === 1 ? nbr_measures : (width + 1)*nbr_measures; - for (var i = 0, measure_row = [], measure; i < nbr_cols; i++) { - measure = this.active_measures[i % nbr_measures]; - measure_row.push({ - measure: measure, - is_bold: (width > 1) && (i >= nbr_measures*width), - id: col_ids[Math.floor(i / nbr_measures)], - }); - } - result.push(measure_row); - return result; - }, - get_header_width: function (header) { - var self = this; - if (!header.children.length) return 1; - if (!header.expanded) return 1; - return header.children.reduce(function (s, c) { - return s + self.get_header_width(c); - }, 0); - }, - get_header_width_depth: function (header) { - var depth = 1, - width = 0; - traverse_tree (header, function (hdr) { - depth = Math.max(depth, hdr.path.length); - if (!hdr.expanded) width++; - }); - return {width: width, depth: depth}; - }, - get_header_depth: function (header) { - var depth = 1; - traverse_tree(header, function (hdr) { - depth = Math.max(depth, hdr.path.length); - }); - return depth; - }, - compute_rows: function () { - var self = this, - aggregates, i, - result = []; - traverse_tree(this.main_row.root, function (header) { - var values = [], - col_ids = []; - result.push({ - id: header.id, - col_ids: col_ids, - indent: header.path.length - 1, - title: header.path[header.path.length-1], - expanded: header.expanded, - values: values, - }); - traverse_tree(self.main_col.root, add_cells, header.id, values, col_ids); - if (self.main_col.width > 1) { - aggregates = self.get_value(header.id, self.main_col.root.id); - for (i = 0; i < self.active_measures.length; i++) { - values.push(aggregates && aggregates[self.active_measures[i]]); - } - col_ids.push( self.main_col.root.id); - } - }); - return result; - function add_cells (col_hdr, row_id, values, col_ids) { - if (col_hdr.expanded) return; - col_ids.push(col_hdr.id); - aggregates = self.get_value(row_id, col_hdr.id); - for (i = 0; i < self.active_measures.length; i++) { - values.push(aggregates && aggregates[self.active_measures[i]]); - } - } - }, - get_value: function (id1, id2) { - if ((id1 in this.cells) && (id2 in this.cells[id1])) { - return this.cells[id1][id2]; - } - if (id2 in this.cells) return this.cells[id2][id1]; - }, - flip: function () { - var temp = this.main_col; - this.main_col = this.main_row; - this.main_row = temp; - this.display_table(); - }, - toggle_measure: function (field) { - if (_.contains(this.active_measures, field)) { - this.active_measures = _.without(this.active_measures, field); - this.display_table(); - } else { - this.active_measures.push(field); - this.load_data().then(this.display_table.bind(this)); - } - }, - download_table: function () { - framework.blockUI(); - var nbr_measures = this.active_measures.length, - headers = this.compute_headers(), - measure_row = nbr_measures > 1 ? _.last(headers) : [], - rows = this.compute_rows(), - i, j, value; - headers[0].splice(0,1); - // process measure_row - for (i = 0; i < measure_row.length; i++) { - measure_row[i].measure = this.measures[measure_row[i].measure].string; - } - // process all rows - for (i =0, j, value; i < rows.length; i++) { - for (j = 0; j < rows[i].values.length; j++) { - value = rows[i].values[j]; - rows[i].values[j] = { - is_bold: (i === 0) || ((this.main_col.width > 1) && (j >= rows[i].values.length - nbr_measures)), - value: (value === undefined) ? "" : value, - }; - } - } - var table = { - headers: _.initial(headers), - measure_row: measure_row, - rows: rows, - nbr_measures: nbr_measures, - title: this.title, - }; - if(table.measure_row.length + 1 > 256) { - c.show_message(_t("For Excel compatibility, data cannot be exported if there are more than 256 columns.\n\nTip: try to flip axis, filter further or reduce the number of measures.")); - return; - } - session.get_file({ - url: '/web/pivot/export_xls', - data: {data: JSON.stringify(table)}, - complete: framework.unblockUI, - error: crash_manager.rpc_error.bind(crash_manager) - }); - }, - destroy: function () { - if (this.$buttons) { - this.$buttons.find('button').off(); // remove jquery's tooltip() handlers - } - return this._super.apply(this, arguments); - }, -}); - -function traverse_tree(root, f, arg1, arg2, arg3) { - f(root, arg1, arg2, arg3); - if (!root.expanded) return; - for (var i = 0; i < root.children.length; i++) { - traverse_tree(root.children[i], f, arg1, arg2, arg3); - } -} - -function find_path_in_tree(root, path) { - var i, - l = root.path.length; - if (l === path.length) { - return (root.path[l-1] === path[l - 1]) ? root : null; - } - for (i = 0; i < root.children.length; i++) { - if (root.children[i].path[l] === path[l]) { - return find_path_in_tree(root.children[i], path); - } - } - return null; -} - -core.view_registry.add('pivot', PivotView); - -return PivotView; - -}); diff --git a/addons/web/static/src/js/views/search_filters.js b/addons/web/static/src/js/views/search_filters.js deleted file mode 100644 index cedb63fa1d9..00000000000 --- a/addons/web/static/src/js/views/search_filters.js +++ /dev/null @@ -1,366 +0,0 @@ -odoo.define('web.search_filters', function (require) { -"use strict"; - -var core = require('web.core'); -var datepicker = require('web.datepicker'); -var formats = require('web.formats'); -var Widget = require('web.Widget'); - -var _t = core._t; -var _lt = core._lt; - -var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{ - template: 'SearchView.extended_search.proposition', - events: { - 'change .o_searchview_extended_prop_field': 'changed', - 'change .o_searchview_extended_prop_op': 'operator_changed', - 'click .o_searchview_extended_delete_prop': function (e) { - e.stopPropagation(); - this.getParent().remove_proposition(this); - }, - }, - /** - * @constructs instance.web.search.ExtendedSearchProposition - * @extends instance.web.Widget - * - * @param parent - * @param fields - */ - init: function (parent, fields) { - this._super(parent); - this.fields = _(fields).chain() - .map(function(val, key) { return _.extend({}, val, {'name': key}); }) - .filter(function (field) { return !field.deprecated && field.searchable; }) - .sortBy(function(field) {return field.string;}) - .value(); - this.attrs = {_: _, fields: this.fields, selected: null}; - this.value = null; - }, - start: function () { - return this._super().done(this.proxy('changed')); - }, - changed: function() { - var nval = this.$(".o_searchview_extended_prop_field").val(); - if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) { - this.select_field(_.detect(this.fields, function(x) {return x.name == nval;})); - } - }, - operator_changed: function (e) { - this.value.show_inputs($(e.target)); - }, - /** - * Selects the provided field object - * - * @param field a field descriptor object (as returned by fields_get, augmented by the field name) - */ - select_field: function(field) { - var self = this; - if(this.attrs.selected !== null && this.attrs.selected !== undefined) { - this.value.destroy(); - this.value = null; - this.$('.o_searchview_extended_prop_op').html(''); - } - this.attrs.selected = field; - if(field === null || field === undefined) { - return; - } - - var type = field.type; - var Field = core.search_filters_registry.get_any([type, "char"]); - - this.value = new Field(this, field); - _.each(this.value.operators, function(operator) { - $('<option>', {value: operator.value}) - .text(String(operator.text)) - .appendTo(self.$('.o_searchview_extended_prop_op')); - }); - var $value_loc = this.$('.o_searchview_extended_prop_value').show().empty(); - this.value.appendTo($value_loc); - - }, - get_filter: function () { - if (this.attrs.selected === null || this.attrs.selected === undefined) - return null; - var field = this.attrs.selected, - op_select = this.$('.o_searchview_extended_prop_op')[0], - operator = op_select.options[op_select.selectedIndex]; - - return { - attrs: { - domain: this.value.get_domain(field, operator), - string: this.value.get_label(field, operator), - }, - children: [], - tag: 'filter', - }; - }, -}); - -ExtendedSearchProposition.Field = Widget.extend({ - init: function (parent, field) { - this._super(parent); - this.field = field; - }, - get_label: function (field, operator) { - var format; - switch (operator.value) { - case '∃': case '∄': format = _t('%(field)s %(operator)s'); break; - default: format = _t('%(field)s %(operator)s "%(value)s"'); break; - } - return this.format_label(format, field, operator); - }, - format_label: function (format, field, operator) { - return _.str.sprintf(format, { - field: field.string, - // According to spec, HTMLOptionElement#label should return - // HTMLOptionElement#text when not defined/empty, but it does - // not in older Webkit (between Safari 5.1.5 and Chrome 17) and - // Gecko (pre Firefox 7) browsers, so we need a manual fallback - // for those - operator: operator.label || operator.text, - value: this - }); - }, - get_domain: function (field, operator) { - switch (operator.value) { - case '∃': return [[field.name, '!=', false]]; - case '∄': return [[field.name, '=', false]]; - default: return [[field.name, operator.value, this.get_value()]]; - } - }, - show_inputs: function ($operator) { - var $value = this.$el.parent(); - switch ($operator.val()) { - case '∃': - case '∄': - $value.hide(); - break; - default: - $value.show(); - } - }, - /** - * Returns a human-readable version of the value, in case the "logical" - * and the "semantic" values of a field differ (as for selection fields, - * for instance). - * - * The default implementation simply returns the value itself. - * - * @return {String} human-readable version of the value - */ - toString: function () { - return this.get_value(); - } -}); - -ExtendedSearchProposition.Char = ExtendedSearchProposition.Field.extend({ - tagName: 'input', - attributes: { - type: 'text' - }, - operators: [ - {value: "ilike", text: _lt("contains")}, - {value: "not ilike", text: _lt("doesn't contain")}, - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - get_value: function() { - return this.$el.val(); - } -}); - -ExtendedSearchProposition.DateTime = ExtendedSearchProposition.Field.extend({ - tagName: 'span', - attributes: { - type: 'datetime' - }, - operators: [ - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">=", text: _lt("is after")}, - {value: "<=", text: _lt("is before")}, - {value: "between", text: _lt("is between")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - get_value: function (index) { - return this["datewidget_" + (index || 0)].get_value(); - }, - get_domain: function (field, operator) { - switch (operator.value) { - case '∃': - return [[field.name, '!=', false]]; - case '∄': - return [[field.name, '=', false]]; - case 'between': - return [[field.name, '>=', this.get_value()], [field.name, '<=', this.get_value(1)]]; - default: - return [[field.name, operator.value, this.get_value()]]; - } - }, - show_inputs: function ($operator) { - this._super.apply(this, arguments); - - if ($operator.val() === "between") { - if (!this.datewidget_1) { - this._create_new_widget("datewidget_1"); - } else { - this.datewidget_1.do_show(); - } - } else { - if (this.datewidget_1) { - this.datewidget_1.do_hide(); - } - } - }, - toString: function () { - var str = formats.format_value(this.get_value(), {type: this.attributes['type']}); - var date_1_value = this.datewidget_1 && this.get_value(1); - if (date_1_value) { - str += _lt(" and ") + formats.format_value(date_1_value, {type: this.attributes['type']}); - } - return str; - }, - start: function() { - return $.when( - this._super.apply(this, arguments), - this._create_new_widget("datewidget_0") - ); - }, - _create_new_widget: function (name) { - this[name] = new (this._get_widget_class())(this); - return this[name].appendTo(this.$el).then((function () { - this[name].set_value(new Date()); - }).bind(this)); - }, - _get_widget_class: function () { - return datepicker.DateTimeWidget; - }, -}); - -ExtendedSearchProposition.Date = ExtendedSearchProposition.DateTime.extend({ - attributes: { - type: 'date' - }, - _get_widget_class: function () { - return datepicker.DateWidget; - }, -}); - -ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({ - tagName: 'input', - attributes: { - type: 'number', - value: '0', - }, - operators: [ - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">", text: _lt("greater than")}, - {value: "<", text: _lt("less than")}, - {value: ">=", text: _lt("greater than or equal to")}, - {value: "<=", text: _lt("less than or equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - toString: function () { - return this.$el.val(); - }, - get_value: function() { - try { - var val =this.$el.val(); - return formats.parse_value(val === "" ? 0 : val, {'widget': 'integer'}); - } catch (e) { - return ""; - } - } -}); - -ExtendedSearchProposition.Id = ExtendedSearchProposition.Integer.extend({ - operators: [{value: "=", text: _lt("is")}] -}); - -ExtendedSearchProposition.Float = ExtendedSearchProposition.Field.extend({ - template: 'SearchView.extended_search.proposition.float', - operators: [ - {value: "=", text: _lt("is equal to")}, - {value: "!=", text: _lt("is not equal to")}, - {value: ">", text: _lt("greater than")}, - {value: "<", text: _lt("less than")}, - {value: ">=", text: _lt("greater than or equal to")}, - {value: "<=", text: _lt("less than or equal to")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - init: function (parent) { - this._super(parent); - this.decimal_point = _t.database.parameters.decimal_point; - }, - toString: function () { - return this.$el.val(); - }, - get_value: function() { - try { - var val =this.$el.val(); - return formats.parse_value(val === "" ? 0.0 : val, {'widget': 'float'}); - } catch (e) { - return ""; - } - } -}); - -ExtendedSearchProposition.Selection = ExtendedSearchProposition.Field.extend({ - template: 'SearchView.extended_search.proposition.selection', - operators: [ - {value: "=", text: _lt("is")}, - {value: "!=", text: _lt("is not")}, - {value: "∃", text: _lt("is set")}, - {value: "∄", text: _lt("is not set")} - ], - toString: function () { - var select = this.$el[0]; - var option = select.options[select.selectedIndex]; - return option.label || option.text; - }, - get_value: function() { - return this.$el.val(); - } -}); - -ExtendedSearchProposition.Boolean = ExtendedSearchProposition.Field.extend({ - tagName: 'span', - operators: [ - {value: "=", text: _lt("is true")}, - {value: "!=", text: _lt("is false")} - ], - get_label: function (field, operator) { - return this.format_label( - _t('%(field)s %(operator)s'), field, operator); - }, - get_value: function() { - return true; - } -}); - -core.search_filters_registry - .add('char', ExtendedSearchProposition.Char) - .add('text', ExtendedSearchProposition.Char) - .add('one2many', ExtendedSearchProposition.Char) - .add('many2one', ExtendedSearchProposition.Char) - .add('many2many', ExtendedSearchProposition.Char) - .add('datetime', ExtendedSearchProposition.DateTime) - .add('date', ExtendedSearchProposition.Date) - .add('integer', ExtendedSearchProposition.Integer) - .add('float', ExtendedSearchProposition.Float) - .add('monetary', ExtendedSearchProposition.Float) - .add('boolean', ExtendedSearchProposition.Boolean) - .add('selection', ExtendedSearchProposition.Selection) - .add('id', ExtendedSearchProposition.Id); - -return { - ExtendedSearchProposition: ExtendedSearchProposition -}; - -}); diff --git a/addons/web/static/src/js/views/search_inputs.js b/addons/web/static/src/js/views/search_inputs.js deleted file mode 100644 index 99535f29797..00000000000 --- a/addons/web/static/src/js/views/search_inputs.js +++ /dev/null @@ -1,762 +0,0 @@ -odoo.define('web.search_inputs', function (require) { -"use strict"; - -var core = require('web.core'); -var data = require('web.data'); -var formats = require('web.formats'); -var Model = require('web.DataModel'); -var pyeval = require('web.pyeval'); -var time = require('web.time'); -var utils = require('web.utils'); -var Widget = require('web.Widget'); - -var _t = core._t; -var _lt = core._lt; - -var Input = Widget.extend( /** @lends instance.web.search.Input# */{ - /** - * @constructs instance.web.search.Input - * @extends instance.web.Widget - * - * @param parent - */ - init: function (parent) { - this._super(parent); - this.searchview = parent; - this.load_attrs({}); - }, - /** - * Fetch auto-completion values for the widget. - * - * The completion values should be an array of objects with keys category, - * label, value prefixed with an object with keys type=section and label - * - * @param {String} value value to complete - * @returns {jQuery.Deferred<null|Array>} - */ - complete: function (value) { - return $.when(null); - }, - /** - * Returns a Facet instance for the provided defaults if they apply to - * this widget, or null if they don't. - * - * This default implementation will try calling - * :js:func:`instance.web.search.Input#facet_for` if the widget's name - * matches the input key - * - * @param {Object} defaults - * @returns {jQuery.Deferred<null|Object>} - */ - facet_for_defaults: function (defaults) { - if (!this.attrs || - !(this.attrs.name in defaults && defaults[this.attrs.name])) { - return $.when(null); - } - return this.facet_for(defaults[this.attrs.name]); - }, - get_context: function () { - throw new Error( - "get_context not implemented for widget " + this.attrs.type); - }, - get_groupby: function () { - throw new Error( - "get_groupby not implemented for widget " + this.attrs.type); - }, - get_domain: function () { - throw new Error( - "get_domain not implemented for widget " + this.attrs.type); - }, - load_attrs: function (attrs) { - if (!_.isObject(attrs.modifiers)) { - attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {}; - } - this.attrs = attrs; - }, - /** - * Returns whether the input is "visible". The default behavior is to - * query the ``modifiers.invisible`` flag on the input's description or - * view node. - * - * @returns {Boolean} - */ - visible: function () { - return !this.attrs.modifiers.invisible; - }, -}); - -var Field = Input.extend( /** @lends instance.web.search.Field# */ { - template: 'SearchView.field', - default_operator: '=', - /** - * @constructs instance.web.search.Field - * @extends instance.web.search.Input - * - * @param view_section - * @param field - * @param parent - */ - init: function (view_section, field, parent) { - this._super(parent); - this.load_attrs(_.extend({}, field, view_section.attrs)); - }, - facet_for: function (value) { - return $.when({ - field: this, - category: this.attrs.string || this.attrs.name, - values: [{label: String(value), value: value}] - }); - }, - value_from: function (facetValue) { - return facetValue.get('value'); - }, - get_context: function (facet) { - var self = this; - // A field needs a context to send when active - var context = this.attrs.context; - if (_.isEmpty(context) || !facet.values.length) { - return; - } - var contexts = facet.values.map(function (facetValue) { - return new data.CompoundContext(context) - .set_eval_context({self: self.value_from(facetValue)}); - }); - - if (contexts.length === 1) { return contexts[0]; } - - return _.extend(new data.CompoundContext(), { - __contexts: contexts - }); - }, - get_groupby: function () { }, - /** - * Function creating the returned domain for the field, override this - * methods in children if you only need to customize the field's domain - * without more complex alterations or tests (and without the need to - * change override the handling of filter_domain) - * - * @param {String} name the field's name - * @param {String} operator the field's operator (either attribute-specified or default operator for the field - * @param {Number|String} facet parsed value for the field - * @returns {Array<Array>} domain to include in the resulting search - */ - make_domain: function (name, operator, facet) { - return [[name, operator, this.value_from(facet)]]; - }, - get_domain: function (facet) { - if (!facet.values.length) { return; } - - var value_to_domain; - var self = this; - var domain = this.attrs.filter_domain; - if (domain) { - value_to_domain = function (facetValue) { - return new data.CompoundDomain(domain) - .set_eval_context({self: self.value_from(facetValue)}); - }; - } else { - value_to_domain = function (facetValue) { - return self.make_domain( - self.attrs.name, - self.attrs.operator || self.default_operator, - facetValue); - }; - } - var domains = facet.values.map(value_to_domain); - - if (domains.length === 1) { return domains[0]; } - for (var i = domains.length; --i;) { - domains.unshift(['|']); - } - - return _.extend(new data.CompoundDomain(), { - __domains: domains - }); - } -}); - -/** - * Implementation of the ``char`` OpenERP field type: - * - * * Default operator is ``ilike`` rather than ``=`` - * - * * The Javascript and the HTML values are identical (strings) - * - * @class - * @extends instance.web.search.Field - */ -var CharField = Field.extend( /** @lends instance.web.search.CharField# */ { - default_operator: 'ilike', - complete: function (value) { - if (_.isEmpty(value)) { return $.when(null); } - var label = _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s for: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: value, value: value}] - } - }]); - } -}); - -var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{ - complete: function (value) { - var val = this.parse(value); - if (isNaN(val)) { return $.when(); } - var label = _.str.sprintf( - _t("Search %(field)s for: %(value)s"), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: value, value: val}] - } - }]); - }, -}); - -/** - * @class - * @extends instance.web.search.NumberField - */ -var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{ - error_message: _t("not a valid integer"), - parse: function (value) { - try { - return formats.parse_value(value, {'widget': 'integer'}); - } catch (e) { - return NaN; - } - } -}); - -/** - * @class - * @extends instance.web.search.NumberField - */ -var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{ - error_message: _t("not a valid number"), - parse: function (value) { - try { - return formats.parse_value(value, {'widget': 'float'}); - } catch (e) { - return NaN; - } - } -}); - -/** - * Utility function for m2o & selection fields taking a selection/name_get pair - * (value, name) and converting it to a Facet descriptor - * - * @param {instance.web.search.Field} field holder field - * @param {Array} pair pair value to convert - */ -function facet_from(field, pair) { - return { - field: field, - category: field.attrs.string, - values: [{label: pair[1], value: pair[0]}] - }; -} - -/** - * @class - * @extends instance.web.search.Field - */ -var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{ - // This implementation is a basic <select> field, but it may have to be - // altered to be more in line with the GTK client, which uses a combo box - // (~ jquery.autocomplete): - // * If an option was selected in the list, behave as currently - // * If something which is not in the list was entered (via the text input), - // the default domain should become (`ilike` string_value) but **any - // ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator`` - // is specified. So at least get_domain needs to be quite a bit - // overridden (if there's no @value and there is no filter_domain and - // there is no @operator, return [[name, 'ilike', str_val]] - template: 'SearchView.field.selection', - init: function () { - this._super.apply(this, arguments); - // prepend empty option if there is no empty option in the selection list - this.prepend_empty = !_(this.attrs.selection).detect(function (item) { - return !item[1]; - }); - }, - complete: function (needle) { - var self = this; - var results = _(this.attrs.selection).chain() - .filter(function (sel) { - var value = sel[0], label = sel[1]; - if (value === undefined || !label) { return false; } - return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1; - }) - .map(function (sel) { - return { - label: _.escape(sel[1]), - indent: true, - facet: facet_from(self, sel) - }; - }).value(); - if (_.isEmpty(results)) { return $.when(null); } - return $.when.call(null, [{ - label: _.escape(this.attrs.string) - }].concat(results)); - }, - facet_for: function (value) { - var match = _(this.attrs.selection).detect(function (sel) { - return sel[0] === value; - }); - if (!match) { return $.when(null); } - return $.when(facet_from(this, match)); - } -}); - -var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{ - /** - * @constructs instance.web.search.BooleanField - * @extends instance.web.search.BooleanField - */ - init: function () { - this._super.apply(this, arguments); - this.attrs.selection = [ - [true, _t("Yes")], - [false, _t("No")] - ]; - } -}); - -/** - * @class - * @extends instance.web.search.DateField - */ -var DateField = Field.extend(/** @lends instance.web.search.DateField# */{ - value_from: function (facetValue) { - return time.date_to_str(facetValue.get('value')); - }, - complete: function (needle) { - // Make sure the needle has a correct format before the creation of the moment object. See - // issue https://github.com/moment/moment/issues/1407 - var t, v; - try { - t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date'; - v = formats.parse_value(needle, {'widget': t}); - } catch (e) { - return $.when(null); - } - - var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'); - if (!m.isValid()) { return $.when(null); } - var d = m.toDate(); - var date_string = formats.format_value(d, this.attrs); - var label = _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s at: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + date_string + '</strong>'}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: date_string, value: d}] - } - }]); - } -}); - -/** - * Implementation of the ``datetime`` openerp field type: - * - * * Uses the same widget as the ``date`` field type (a simple date) - * - * * Builds a slighly more complex, it's a datetime range (includes time) - * spanning the whole day selected by the date widget - * - * @class - * @extends instance.web.DateField - */ -var DateTimeField = DateField.extend(/** @lends instance.web.search.DateTimeField# */{ - value_from: function (facetValue) { - return time.datetime_to_str(facetValue.get('value')); - } -}); - -var ManyToOneField = CharField.extend({ - default_operator: {}, - init: function (view_section, field, parent) { - this._super(view_section, field, parent); - this.model = new Model(this.attrs.relation); - this.searchview = parent; - }, - - complete: function (value) { - if (_.isEmpty(value)) { return $.when(null); } - var label = _.str.sprintf(_.str.escapeHTML( - _t("Search %(field)s for: %(value)s")), { - field: '<em>' + _.escape(this.attrs.string) + '</em>', - value: '<strong>' + _.escape(value) + '</strong>'}); - return $.when([{ - label: label, - facet: { - category: this.attrs.string, - field: this, - values: [{label: value, value: value, operator: 'ilike'}] - }, - expand: this.expand.bind(this), - }]); - }, - - expand: function (needle) { - var self = this; - // FIXME: "concurrent" searches (multiple requests, mis-ordered responses) - var context = pyeval.eval( - 'contexts', [this.searchview.dataset.get_context()]); - var args = this.attrs.domain; - if (typeof args === 'string') { - try { - args = pyeval.eval('domain', args); - } catch(e) { - args = []; - } - } - return this.model.call('name_search', [], { - name: needle, - args: args, - limit: 8, - context: context - }).then(function (results) { - if (_.isEmpty(results)) { return null; } - return _(results).map(function (result) { - return { - label: _.escape(result[1]), - facet: facet_from(self, result) - }; - }); - }); - }, - facet_for: function (value) { - var self = this; - if (value instanceof Array) { - if (value.length === 2 && _.isString(value[1])) { - return $.when(facet_from(this, value)); - } - utils.assert(value.length <= 1, - _t("M2O search fields do not currently handle multiple default values")); - // there are many cases of {search_default_$m2ofield: [id]}, need - // to handle this as if it were a single value. - value = value[0]; - } - var context = pyeval.eval('contexts', [this.searchview.dataset.get_context()]); - return this.model.call('name_get', [value], {context: context}).then(function (names) { - if (_(names).isEmpty()) { return null; } - return facet_from(self, names[0]); - }); - }, - value_from: function (facetValue) { - return facetValue.get('label'); - }, - make_domain: function (name, operator, facetValue) { - operator = facetValue.get('operator') || operator; - - switch(operator){ - case this.default_operator: - return [[name, '=', facetValue.get('value')]]; - case 'ilike': - return [[name, 'ilike', facetValue.get('value')]]; - case 'child_of': - return [[name, 'child_of', facetValue.get('value')]]; - } - return this._super(name, operator, facetValue); - }, - get_context: function (facet) { - var values = facet.values; - if (_.isEmpty(this.attrs.context) && values.length === 1) { - var c = {}; - var v = values.at(0); - if (v.get('operator') !== 'ilike') { - c['default_' + this.attrs.name] = v.get('value'); - } - return c; - } - return this._super(facet); - } -}); - -var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{ - template: 'SearchView.filters', - icon: "fa-filter", - completion_label: _lt("Filter on: %s"), - /** - * Inclusive group of filters, creates a continuous "button" with clickable - * sections (the normal display for filters is to be a self-contained button) - * - * @constructs instance.web.search.FilterGroup - * @extends instance.web.search.Input - * - * @param {Array<instance.web.search.Filter>} filters elements of the group - * @param {instance.web.SearchView} parent parent in which the filters are contained - */ - init: function (filters, parent) { - // If all filters are group_by and we're not initializing a GroupbyGroup, - // create a GroupbyGroup instead of the current FilterGroup - if (!(this instanceof GroupbyGroup) && - _(filters).all(function (f) { - if (!f.attrs.context) { return false; } - var c = pyeval.eval('context', f.attrs.context); - return !_.isEmpty(c.group_by);})) { - return new GroupbyGroup(filters, parent); - } - this._super(parent); - this.filters = filters; - this.searchview = parent; - this.searchview.query.on('add remove change reset', this.proxy('search_change')); - }, - start: function () { - this.$el.on('click', 'a', this.proxy('toggle_filter')); - return $.when(null); - }, - /** - * Handles change of the search query: any of the group's filter which is - * in the search query should be visually checked in the drawer - */ - search_change: function () { - var self = this; - var $filters = this.$el.removeClass('selected'); - var facet = this.searchview.query.find(_.bind(this.match_facet, this)); - if (!facet) { return; } - facet.values.each(function (v) { - var i = _(self.filters).indexOf(v.get('value')); - if (i === -1) { return; } - $filters.filter(function () { - return Number($(this).data('index')) === i; - }).addClass('selected'); - }); - }, - /** - * Matches the group to a facet, in order to find if the group is - * represented in the current search query - */ - match_facet: function (facet) { - return facet.get('field') === this; - }, - make_facet: function (values) { - return { - category: _t("Filter"), - icon: this.icon, - separator: _t(" or "), - values: values, - field: this - }; - }, - make_value: function (filter) { - return { - label: filter.attrs.string || filter.attrs.help || filter.attrs.name, - value: filter - }; - }, - facet_for_defaults: function (defaults) { - var self = this; - var fs = _(this.filters).chain() - .filter(function (f) { - return f.attrs && f.attrs.name && !!defaults[f.attrs.name]; - }).map(function (f) { - return self.make_value(f); - }).value(); - if (_.isEmpty(fs)) { return $.when(null); } - return $.when(this.make_facet(fs)); - }, - /** - * Fetches contexts for all enabled filters in the group - * - * @param {openerp.web.search.Facet} facet - * @return {*} combined contexts of the enabled filters in this group - */ - get_context: function (facet) { - var contexts = facet.values.chain() - .map(function (f) { return f.get('value').attrs.context; }) - .without('{}') - .reject(_.isEmpty) - .value(); - - if (!contexts.length) { return; } - if (contexts.length === 1) { return contexts[0]; } - return _.extend(new data.CompoundContext(), { - __contexts: contexts - }); - }, - /** - * Fetches group_by sequence for all enabled filters in the group - * - * @param {VS.model.SearchFacet} facet - * @return {Array} enabled filters in this group - */ - get_groupby: function (facet) { - return facet.values.chain() - .map(function (f) { return f.get('value').attrs.context; }) - .without('{}') - .reject(_.isEmpty) - .value(); - }, - /** - * Handles domains-fetching for all the filters within it: groups them. - * - * @param {VS.model.SearchFacet} facet - * @return {*} combined domains of the enabled filters in this group - */ - get_domain: function (facet) { - var domains = facet.values.chain() - .map(function (f) { return f.get('value').attrs.domain; }) - .without('[]') - .reject(_.isEmpty) - .value(); - - if (!domains.length) { return; } - if (domains.length === 1) { return domains[0]; } - for (var i=domains.length; --i;) { - domains.unshift(['|']); - } - return _.extend(new data.CompoundDomain(), { - __domains: domains - }); - }, - toggle_filter: function (e) { - e.preventDefault(); - e.stopPropagation(); - this.toggle(this.filters[Number($(e.target).parent().data('index'))]); - }, - toggle: function (filter, options) { - this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options); - }, - is_visible: function () { - return _.some(this.filters, function (filter) { - return !filter.attrs.invisible; - }); - }, - complete: function (item) { - var self = this; - item = item.toLowerCase(); - var facet_values = _(this.filters).chain() - .filter(function (filter) { return filter.visible(); }) - .filter(function (filter) { - var at = { - string: filter.attrs.string || '', - help: filter.attrs.help || '', - name: filter.attrs.name || '' - }; - var include = _.str.include; - return include(at.string.toLowerCase(), item) - || include(at.help.toLowerCase(), item) - || include(at.name.toLowerCase(), item); - }) - .map(this.make_value) - .value(); - if (_(facet_values).isEmpty()) { return $.when(null); } - return $.when(_.map(facet_values, function (facet_value) { - return { - label: _.str.sprintf(self.completion_label.toString(), - _.escape(facet_value.label)), - facet: self.make_facet([facet_value]) - }; - })); - } -}); - -var GroupbyGroup = FilterGroup.extend({ - icon: 'fa-bars', - completion_label: _lt("Group by: %s"), - init: function (filters, parent) { - this._super(filters, parent); - this.searchview = parent; - // Not flanders: facet unicity is handled through the - // (category, field) pair of facet attributes. This is all well and - // good for regular filter groups where a group matches a facet, but for - // groupby we want a single facet. So cheat: add an attribute on the - // view which proxies to the first GroupbyGroup, so it can be used - // for every GroupbyGroup and still provides the various methods needed - // by the search view. Use weirdo name to avoid risks of conflicts - if (!this.searchview._s_groupby) { - this.searchview._s_groupby = { - help: "See GroupbyGroup#init", - get_context: this.proxy('get_context'), - get_domain: this.proxy('get_domain'), - get_groupby: this.proxy('get_groupby') - }; - } - }, - match_facet: function (facet) { - return facet.get('field') === this.searchview._s_groupby; - }, - make_facet: function (values) { - return { - category: _t("Group By"), - icon: this.icon, - separator: " > ", - values: values, - field: this.searchview._s_groupby - }; - } -}); - -var Filter = Input.extend(/** @lends instance.web.search.Filter# */{ - template: 'SearchView.filter', - /** - * Implementation of the OpenERP filters (button with a context and/or - * a domain sent as-is to the search view) - * - * Filters are only attributes holder, the actual work (compositing - * domains and contexts, converting between facets and filters) is - * performed by the filter group. - * - * @constructs instance.web.search.Filter - * @extends instance.web.search.Input - * - * @param node - * @param parent - */ - init: function (node, parent) { - this._super(parent); - this.load_attrs(node.attrs); - }, - facet_for: function () { return $.when(null); }, - get_context: function () { }, - get_domain: function () { }, -}); - -/** - * Registry of search fields, called by :js:class:`instance.web.SearchView` to - * find and instantiate its field widgets. - */ - -core.search_widgets_registry - .add('char', CharField) - .add('text', CharField) - .add('html', CharField) - .add('boolean', BooleanField) - .add('integer', IntegerField) - .add('id', IntegerField) - .add('float', FloatField) - .add('monetary', FloatField) - .add('selection', SelectionField) - .add('datetime', DateTimeField) - .add('date', DateField) - .add('many2one', ManyToOneField) - .add('many2many', CharField) - .add('one2many', CharField); - - -return { - FilterGroup: FilterGroup, - Filter: Filter, - Field: Field, - GroupbyGroup: GroupbyGroup, -}; - -}); diff --git a/addons/web/static/src/js/views/search_menus.js b/addons/web/static/src/js/views/search_menus.js deleted file mode 100644 index 0b28d13fea2..00000000000 --- a/addons/web/static/src/js/views/search_menus.js +++ /dev/null @@ -1,483 +0,0 @@ -odoo.define('web.FavoriteMenu', function (require) { -"use strict"; - -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var pyeval = require('web.pyeval'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var _t = core._t; - -return Widget.extend({ - template: 'SearchView.FavoriteMenu', - events: { - 'click li': function (event) { - event.stopImmediatePropagation(); - }, - 'click li a': function (event) { - event.preventDefault(); - }, - 'click .o_save_search a': function (event) { - event.preventDefault(); - this.toggle_save_menu(); - }, - 'click .o_save_name button': 'save_favorite', - 'hidden.bs.dropdown': 'close_menus', - 'keyup .o_save_name input': function (ev) { - if (ev.which === $.ui.keyCode.ENTER) { - this.save_favorite(); - } - }, - }, - init: function (parent, query, target_model, action_id, filters) { - this._super.apply(this,arguments); - this.searchview = parent; - this.query = query; - this.target_model = target_model; - this.action_id = action_id; - this.filters = {}; - _.each(filters, this.add_filter.bind(this)); - }, - start: function () { - var self = this; - this.$filters = {}; - this.$save_search = this.$('.o_save_search'); - this.$save_name = this.$('.o_save_name'); - this.$inputs = this.$save_name.find('input'); - this.$user_divider = this.$('.divider.user_filter'); - this.$shared_divider = this.$('.divider.shared_filter'); - this.$inputs.eq(0).val(this.searchview.get_title()); - var $shared_filter = this.$inputs.eq(1), - $default_filter = this.$inputs.eq(2); - $shared_filter.click(function () {$default_filter.prop('checked', false);}); - $default_filter.click(function () {$shared_filter.prop('checked', false);}); - - this.query - .on('remove', function (facet) { - if (facet.get('is_custom_filter')) { - self.clear_selection(); - } - }) - .on('reset', this.proxy('clear_selection')); - - _.each(this.filters, this.append_filter.bind(this)); - - return this._super(); - }, - toggle_save_menu: function (is_open) { - this.$save_search - .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined) - .toggleClass('o_open_menu', is_open); - this.$save_name.toggle(is_open); - if (this.$save_search.hasClass('o_open_menu')) { - this.$save_name.find('input').first().focus(); - } - }, - close_menus: function () { - this.toggle_save_menu(false); - }, - save_favorite: function () { - var self = this, - filter_name = this.$inputs[0].value, - default_filter = this.$inputs[1].checked, - shared_filter = this.$inputs[2].checked; - if (!filter_name.length){ - this.do_warn(_t("Error"), _t("Filter name is required.")); - this.$inputs.first().focus(); - return; - } - if (_.chain(this.filters) - .pluck('name') - .contains(filter_name).value()) { - this.do_warn(_t("Error"), _t("Filter with same name already exists.")); - this.$inputs.first().focus(); - return; - } - var search = this.searchview.build_search_data(), - view_manager = this.findAncestor(function (a) { - // HORRIBLE HACK. PLEASE SAVE ME FROM MYSELF (BUT IN A PAINLESS WAY IF POSSIBLE) - return 'active_view' in a; - }), - view_context = view_manager ? view_manager.active_view.controller.get_context() : {}, - results = pyeval.sync_eval_domains_and_contexts({ - domains: search.domains, - contexts: search.contexts.concat(view_context || []), - group_by_seq: search.groupbys || [], - }); - if (!_.isEmpty(results.group_by)) { - results.context.group_by = results.group_by; - } - // Don't save user_context keys in the custom filter, otherwise end - // up with e.g. wrong uid or lang stored *and used in subsequent - // reqs* - var ctx = results.context; - _(_.keys(session.user_context)).each(function (key) { - delete ctx[key]; - }); - var filter = { - name: filter_name, - user_id: shared_filter ? false : session.uid, - model_id: this.target_model, - context: results.context, - domain: results.domain, - is_default: default_filter, - action_id: this.action_id, - }; - return data_manager.create_filter(filter).done(function (id) { - filter.id = id; - self.toggle_save_menu(false); - self.$save_name.find('input').val('').prop('checked', false); - self.add_filter(filter); - self.append_filter(filter); - self.toggle_filter(filter, true); - }); - }, - get_default_filter: function () { - var personal_filter = _.find(this.filters, function (filter) { - return filter.user_id && filter.is_default; - }); - if (personal_filter) { - return personal_filter; - } - return _.find(this.filters, function (filter) { - return !filter.user_id && filter.is_default; - }); - }, - /** - * Generates a mapping key (in the filters and $filter mappings) for the - * filter descriptor object provided (as returned by ``get_filters``). - * - * The mapping key is guaranteed to be unique for a given (user_id, name) - * pair. - * - * @param {Object} filter - * @param {String} filter.name - * @param {Number|Pair<Number, String>} [filter.user_id] - * @return {String} mapping key corresponding to the filter - */ - key_for: function (filter) { - var user_id = filter.user_id, - action_id = filter.action_id, - uid = (user_id instanceof Array) ? user_id[0] : user_id, - act_id = (action_id instanceof Array) ? action_id[0] : action_id; - return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name); - }, - /** - * Generates a :js:class:`~instance.web.search.Facet` descriptor from a - * filter descriptor - * - * @param {Object} filter - * @param {String} filter.name - * @param {Object} [filter.context] - * @param {Array} [filter.domain] - * @return {Object} - */ - facet_for: function (filter) { - return { - category: _t("Custom Filter"), - icon: 'fa-star', - field: { - get_context: function () { return filter.context; }, - get_groupby: function () { return [filter.context]; }, - get_domain: function () { return filter.domain; } - }, - _id: filter.id, - is_custom_filter: true, - values: [{label: filter.name, value: null}] - }; - }, - clear_selection: function () { - this.$('li.selected').removeClass('selected'); - }, - /** - * Adds a filter description to the filters dict - * @param {Object} [filter] the filter description - */ - add_filter: function (filter) { - this.filters[this.key_for(filter)] = filter; - }, - /** - * Creates a $filter JQuery node, adds it to the $filters dict and appends it to the filter menu - * @param {Object} [filter] the filter description - */ - append_filter: function (filter) { - var self = this; - var key = this.key_for(filter); - - if (filter.user_id) { - this.$user_divider.show(); - } else { - this.$shared_divider.show(); - } - if (!(key in this.$filters)) { - var $filter = $('<li>') - .addClass(filter.user_id ? 'o-searchview-custom-private' - : 'o-searchview-custom-public') - .append($('<a>', {'href': '#'}).text(filter.name)) - .append($('<span>', { - class: 'fa fa-trash-o o-remove-filter', - on: { - click: function (event) { - event.stopImmediatePropagation(); - self.remove_filter(filter, $filter, key); - }, - }, - })) - .insertBefore(filter.user_id ? this.$user_divider : this.$shared_divider); - this.$filters[key] = $filter; - } - this.$filters[key].unbind('click').click(function () { - self.toggle_filter(filter); - }); - }, - toggle_filter: function (filter, preventSearch) { - var current = this.query.find(function (facet) { - return facet.get('_id') === filter.id; - }); - if (current) { - this.query.remove(current); - this.$filters[this.key_for(filter)].removeClass('selected'); - return; - } - this.query.reset([this.facet_for(filter)], { - preventSearch: preventSearch || false}); - this.$filters[this.key_for(filter)].addClass('selected'); - }, - remove_filter: function (filter, $filter, key) { - var self = this; - var global_warning = _t("This filter is global and will be removed for everybody if you continue."), - warning = _t("Are you sure that you want to remove this filter?"); - if (!confirm(filter.user_id ? warning : global_warning)) { - return; - } - return data_manager - .delete_filter(filter) - .done(function () { - $filter.remove(); - delete self.$filters[key]; - delete self.filters[key]; - var has_user_filter = _.find(self.filters, function(filter) { return filter.user_id; }); - var has_shared_filer = _.find(self.filters, function(filter) { return !filter.user_id; }); - if (!has_user_filter) { - self.$user_divider.hide(); - } - if (!has_shared_filer) { - self.$shared_divider.hide(); - } - }); - }, -}); - -}); - -odoo.define('web.FilterMenu', function (require) { -"use strict"; - -var data_manager = require('web.data_manager'); -var search_filters = require('web.search_filters'); -var search_inputs = require('web.search_inputs'); -var Widget = require('web.Widget'); - -return Widget.extend({ - template: 'SearchView.FilterMenu', - events: { - 'click .o_add_filter': function (event) { - event.preventDefault(); - this.toggle_custom_filter_menu(); - }, - 'click li': function (event) { - event.preventDefault(); - event.stopImmediatePropagation(); - }, - 'hidden.bs.dropdown': function () { - this.toggle_custom_filter_menu(false); - }, - 'click .o_add_condition': 'append_proposition', - 'click .o_apply_filter': 'commit_search', - 'keyup .o_searchview_extended_prop_value': function (ev) { - if (ev.which === $.ui.keyCode.ENTER) { - this.commit_search(); - } - }, - }, - init: function (parent, filters) { - this._super(parent); - this.filters = filters || []; - this.searchview = parent; - this.propositions = []; - this.custom_filters_open = false; - }, - start: function () { - var self = this; - this.$menu = this.$('.o_filters_menu'); - this.$add_filter = this.$('.o_add_filter'); - this.$apply_filter = this.$('.o_apply_filter'); - this.$add_filter_menu = this.$('.o_add_filter_menu'); - _.each(this.filters, function (group) { - if (group.is_visible()) { - group.insertBefore(self.$add_filter); - $('<li class="divider">').insertBefore(self.$add_filter); - } - }); - }, - get_fields: function () { - if (!this._fields_def) { - this._fields_def = data_manager.load_fields(this.searchview.dataset).then(function (data) { - var fields = { - id: { string: 'ID', type: 'id', searchable: true } - }; - _.each(data, function(field_def, field_name) { - if (field_def.selectable !== false && field_name !== 'id') { - fields[field_name] = field_def; - } - }); - return fields; - }); - } - return this._fields_def; - }, - toggle_custom_filter_menu: function (is_open) { - var self = this; - this.custom_filters_open = !_.isUndefined(is_open) ? is_open : !this.custom_filters_open; - var def; - if (this.custom_filters_open && !this.propositions.length) { - def = this.append_proposition(); - } - $.when(def).then(function () { - self.$add_filter - .toggleClass('o_closed_menu', !self.custom_filters_open) - .toggleClass('o_open_menu', self.custom_filters_open); - self.$add_filter_menu.toggle(self.custom_filters_open); - self.$('.o_filter_condition').toggle(self.custom_filters_open); - }); - }, - append_proposition: function () { - var self = this; - return this.get_fields().then(function (fields) { - var prop = new search_filters.ExtendedSearchProposition(self, fields); - self.propositions.push(prop); - prop.insertBefore(self.$add_filter_menu); - self.$apply_filter.prop('disabled', false); - return prop; - }); - }, - remove_proposition: function (prop) { - this.propositions = _.without(this.propositions, prop); - if (!this.propositions.length) { - this.$apply_filter.prop('disabled', true); - } - prop.destroy(); - }, - commit_search: function () { - var filters = _.invoke(this.propositions, 'get_filter'), - filters_widgets = _.map(filters, function (filter) { - return new search_inputs.Filter(filter, this); - }), - filter_group = new search_inputs.FilterGroup(filters_widgets, this.searchview), - facets = filters_widgets.map(function (filter) { - return filter_group.make_facet([filter_group.make_value(filter)]); - }); - filter_group.insertBefore(this.$add_filter); - $('<li class="divider">').insertBefore(this.$add_filter); - this.searchview.query.add(facets, {silent: true}); - this.searchview.query.trigger('reset'); - - _.invoke(this.propositions, 'destroy'); - this.propositions = []; - this.append_proposition(); - this.toggle_custom_filter_menu(false); - }, -}); - -}); - -odoo.define('web.GroupByMenu', function (require) { -"use strict"; - -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var search_inputs = require('web.search_inputs'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; - -return Widget.extend({ - template: 'SearchView.GroupByMenu', - events: { - 'click li': function (event) { - event.stopImmediatePropagation(); - }, - 'hidden.bs.dropdown': function () { - this.toggle_add_menu(false); - }, - 'click .o_add_custom_group a': function (event) { - event.preventDefault(); - this.toggle_add_menu(); - }, - }, - init: function (parent, groups) { - this._super(parent); - this.groups = groups || []; - this.groupable_fields = []; - this.searchview = parent; - }, - start: function () { - this.$menu = this.$('.o_group_by_menu'); - var divider = this.$menu.find('.divider'); - _.invoke(this.groups, 'insertBefore', divider); - if (this.groups.length) { - divider.show(); - } - this.$add_group = this.$menu.find('.o_add_custom_group'); - }, - get_fields: function () { - var self = this; - if (!this._fields_def) { - this._fields_def = data_manager.load_fields(this.searchview.dataset).then(function (fields) { - var groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime']; - var filter_group_field = _.filter(fields, function(field, name) { - if (field.store && _.contains(groupable_types, field.type)) { - field.name = name; - return field; - } - }); - self.groupable_fields = _.sortBy(filter_group_field, 'string'); - - self.$menu.append(QWeb.render('GroupByMenuSelector', self)); - self.$add_group_menu = self.$('.o_add_group'); - self.$group_selector = self.$('.o_group_selector'); - self.$('.o_select_group').click(function () { - self.toggle_add_menu(false); - var field = self.$group_selector.find(':selected').data('name'); - self.add_groupby_to_menu(field); - }); - }); - } - return this._fields_def; - }, - toggle_add_menu: function (is_open) { - var self = this; - this.get_fields().then(function () { - self.$add_group - .toggleClass('o_closed_menu', !(_.isUndefined(is_open)) ? !is_open : undefined) - .toggleClass('o_open_menu', is_open); - self.$add_group_menu.toggle(is_open); - if (self.$add_group.hasClass('o_open_menu')) { - self.$group_selector.focus(); - } - }); - }, - add_groupby_to_menu: function (field_name) { - var filter = new search_inputs.Filter({attrs:{ - context:"{'group_by':'" + field_name + "''}", - name: _.find(this.groupable_fields, {name: field_name}).string, - }}, this.searchview); - var group = new search_inputs.FilterGroup([filter], this.searchview), - divider = this.$('.divider').show(); - group.insertBefore(divider); - group.toggle(filter); - }, -}); - -}); diff --git a/addons/web/static/src/js/views/search_view.js b/addons/web/static/src/js/views/search_view.js deleted file mode 100644 index 65dbb4557e7..00000000000 --- a/addons/web/static/src/js/views/search_view.js +++ /dev/null @@ -1,613 +0,0 @@ -odoo.define('web.SearchView', function (require) { -"use strict"; - -var AutoComplete = require('web.AutoComplete'); -var config = require('web.config'); -var core = require('web.core'); -var data_manager = require('web.data_manager'); -var FavoriteMenu = require('web.FavoriteMenu'); -var FilterMenu = require('web.FilterMenu'); -var GroupByMenu = require('web.GroupByMenu'); -var pyeval = require('web.pyeval'); -var search_inputs = require('web.search_inputs'); -var View = require('web.View'); -var Widget = require('web.Widget'); -var local_storage = require('web.local_storage'); -var _t = core._t; - -var Backbone = window.Backbone; - -var FacetValue = Backbone.Model.extend({}); - -var FacetValues = Backbone.Collection.extend({ - model: FacetValue -}); - -var Facet = Backbone.Model.extend({ - initialize: function (attrs) { - var values = attrs.values; - delete attrs.values; - - Backbone.Model.prototype.initialize.apply(this, arguments); - - this.values = new FacetValues(values || []); - this.values.on('add remove change reset', function (_, options) { - this.trigger('change', this, options); - }, this); - }, - get: function (key) { - if (key !== 'values') { - return Backbone.Model.prototype.get.call(this, key); - } - return this.values.toJSON(); - }, - set: function (key, value) { - if (key !== 'values') { - return Backbone.Model.prototype.set.call(this, key, value); - } - this.values.reset(value); - }, - toJSON: function () { - var out = {}; - var attrs = this.attributes; - for(var att in attrs) { - if (!attrs.hasOwnProperty(att) || att === 'field') { - continue; - } - out[att] = attrs[att]; - } - out.values = this.values.toJSON(); - return out; - } -}); - -var SearchQuery = Backbone.Collection.extend({ - model: Facet, - initialize: function () { - Backbone.Collection.prototype.initialize.apply( - this, arguments); - this.on('change', function (facet) { - if(!facet.values.isEmpty()) { return; } - - this.remove(facet, {silent: true}); - }, this); - }, - add: function (values, options) { - options = options || {}; - - if (!values) { - values = []; - } else if (!(values instanceof Array)) { - values = [values]; - } - - _(values).each(function (value) { - var model = this._prepareModel(value, options); - var previous = this.detect(function (facet) { - return facet.get('category') === model.get('category') && - facet.get('field') === model.get('field'); - }); - if (previous) { - previous.values.add(model.get('values'), _.omit(options, 'at', 'merge')); - return; - } - Backbone.Collection.prototype.add.call(this, model, options); - }, this); - // warning: in backbone 1.0+ add is supposed to return the added models, - // but here toggle may delegate to add and return its value directly. - // return value of neither seems actually used but should be tested - // before change, probably - return this; - }, - toggle: function (value, options) { - options = options || {}; - - var facet = this.detect(function (facet) { - return facet.get('category') === value.category - && facet.get('field') === value.field; - }); - if (!facet) { - return this.add(value, options); - } - - var changed = false; - _(value.values).each(function (val) { - var already_value = facet.values.detect(function (v) { - return v.get('value') === val.value - && v.get('label') === val.label; - }); - // toggle value - if (already_value) { - facet.values.remove(already_value, {silent: true}); - } else { - facet.values.add(val, {silent: true}); - } - changed = true; - }); - // "Commit" changes to values array as a single call, so observers of - // change event don't get misled by intermediate incomplete toggling - // states - facet.trigger('change', facet); - return this; - } -}); - -var InputView = Widget.extend({ - template: 'SearchView.InputView', - events: { - focus: function () { this.trigger('focused', this); }, - blur: function () { this.$el.val(''); this.trigger('blurred', this); }, - keydown: 'onKeydown', - }, - onKeydown: function (e) { - switch (e.which) { - case $.ui.keyCode.BACKSPACE: - if(this.$el.val() === '') { - var preceding = this.getParent().siblingSubview(this, -1); - if (preceding && (preceding instanceof FacetView)) { - preceding.model.destroy(); - } - } - break; - - case $.ui.keyCode.LEFT: // Stop propagation to parent if not at beginning of input value - if(this.el.selectionStart > 0) { - e.stopPropagation(); - } - break; - - case $.ui.keyCode.RIGHT: // Stop propagation to parent if not at end of input value - if(this.el.selectionStart < this.$el.val().length) { - e.stopPropagation(); - } - break; - } - } -}); - -var FacetView = Widget.extend({ - template: 'SearchView.FacetView', - events: { - 'focus': function () { this.trigger('focused', this); }, - 'blur': function () { this.trigger('blurred', this); }, - 'click': function (e) { - if ($(e.target).hasClass('o_facet_remove')) { - this.model.destroy(); - return false; - } - this.$el.focus(); - e.stopPropagation(); - }, - 'keydown': function (e) { - var keys = $.ui.keyCode; - switch (e.which) { - case keys.BACKSPACE: - case keys.DELETE: - this.model.destroy(); - return false; - } - } - }, - init: function (parent, model) { - this._super(parent); - this.model = model; - this.model.on('change', this.model_changed, this); - }, - destroy: function () { - this.model.off('change', this.model_changed, this); - this._super(); - }, - start: function () { - var self = this; - var $e = this.$('.o_facet_values').last(); - return $.when(this._super()).then(function () { - return $.when.apply(null, self.model.values.map(function (value, index) { - if (index > 0) { - $('<span/>', {html: self.model.get('separator') || _t(" or ")}).addClass('o_facet_values_sep').appendTo($e); - } - return new FacetValueView(self, value).appendTo($e); - })); - }); - }, - model_changed: function () { - this.$el.text(this.$el.text() + '*'); - } -}); - -var FacetValueView = Widget.extend({ - template: 'SearchView.FacetView.Value', - init: function (parent, model) { - this._super(parent); - this.model = model; - this.model.on('change', this.model_changed, this); - }, - destroy: function () { - this.model.off('change', this.model_changed, this); - this._super(); - }, - model_changed: function () { - this.$el.text(this.$el.text() + '*'); - } -}); - -var SearchView = View.extend({ - events: { - 'click .o_searchview_more': function (e) { - $(e.target).toggleClass('fa-search-plus fa-search-minus'); - var visible_search_menu = (local_storage.getItem('visible_search_menu') !== 'true'); - local_storage.setItem('visible_search_menu', visible_search_menu); - this.toggle_buttons(); - }, - 'keydown .o_searchview_input, .o_searchview_facet': function (e) { - switch(e.which) { - case $.ui.keyCode.LEFT: - this.focusPreceding(e.target); - e.preventDefault(); - break; - case $.ui.keyCode.RIGHT: - if(!this.autocomplete.is_expandable()) { - this.focusFollowing(e.target); - } - e.preventDefault(); - break; - } - }, - }, - defaults: _.extend({}, View.prototype.defaults, { - hidden: false, - disable_filters: false, - disable_groupby: false, - disable_favorites: false, - disable_custom_filters: false, - }), - template: "SearchView", - - /** - * @constructs SearchView - * @extends View - * - * @param parent - * @param dataset - * @param fields_view - * @param {Object} [options] - * @param {Boolean} [options.hidden=false] hide the search view - * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters - */ - init: function() { - this._super.apply(this, arguments); - this.query = undefined; - this.title = this.options.action && this.options.action.name; - this.action_id = this.options.action && this.options.action.id; - this.search_fields = []; - this.filters = []; - this.groupbys = []; - this.visible_filters = (local_storage.getItem('visible_search_menu') === 'true'); - this.input_subviews = []; // for user input in searchbar - this.search_defaults = this.options.search_defaults || {}; - this.headless = this.options.hidden && _.isEmpty(this.search_defaults); - this.$buttons = this.options.$buttons; - - this.filter_menu = undefined; - this.groupby_menu = undefined; - this.favorite_menu = undefined; - }, - willStart: function () { - var self = this; - var def; - if (!this.options.disable_favorites) { - def = data_manager.load_filters(this.dataset, this.action_id).then(function (filters) { - self.favorite_filters = filters; - }); - } - return $.when(this._super(), def); - }, - start: function() { - if (this.headless) { - this.do_hide(); - } - this.toggle_visibility(false); - this.setup_global_completion(); - this.query = new SearchQuery() - .on('add change reset remove', this.proxy('do_search')) - .on('change', this.proxy('renderChangedFacets')) - .on('add reset remove', this.proxy('renderFacets')); - this.$('.o_searchview_more') - .toggleClass('fa-search-minus', this.visible_filters) - .toggleClass('fa-search-plus', !this.visible_filters); - var menu_defs = []; - this.prepare_search_inputs(); - if (this.$buttons) { - if (!this.options.disable_filters) { - this.filter_menu = new FilterMenu(this, this.filters); - menu_defs.push(this.filter_menu.appendTo(this.$buttons)); - } - if (!this.options.disable_groupby) { - this.groupby_menu = new GroupByMenu(this, this.groupbys); - menu_defs.push(this.groupby_menu.appendTo(this.$buttons)); - } - if (!this.options.disable_favorites) { - this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action_id, this.favorite_filters); - menu_defs.push(this.favorite_menu.appendTo(this.$buttons)); - } - } - return $.when.apply($, menu_defs).then(this.set_default_filters.bind(this)); - }, - get_title: function() { - return this.title; - }, - set_default_filters: function () { - var self = this, - default_custom_filter = this.$buttons && this.favorite_menu.get_default_filter(); - if (!self.options.disable_custom_filters && default_custom_filter) { - return this.favorite_menu.toggle_filter(default_custom_filter, true); - } - if (!_.isEmpty(this.search_defaults)) { - var inputs = this.search_fields.concat(this.filters, this.groupbys), - search_defaults = _.invoke(inputs, 'facet_for_defaults', this.search_defaults); - return $.when.apply(null, search_defaults).then(function () { - self.query.reset(_(arguments).compact(), {preventSearch: true}); - }); - } - this.query.reset([], {preventSearch: true}); - return $.when(); - }, - /** - * Performs the search view collection of widget data. - * - * If the collection went well (all fields are valid), then triggers - * :js:func:`instance.web.SearchView.on_search`. - * - * If at least one field failed its validation, triggers - * :js:func:`instance.web.SearchView.on_invalid` instead. - * - * @param [_query] - * @param {Object} [options] - */ - do_search: function (_query, options) { - if (options && options.preventSearch) { - return; - } - var search = this.build_search_data(); - this.trigger('search_data', search.domains, search.contexts, search.groupbys); - }, - /** - * Extract search data from the view's facets. - * - * Result is an object with 3 (own) properties: - * - * domains - * Array of domains - * contexts - * Array of contexts - * groupbys - * Array of domains, in groupby order rather than view order - * - * @return {Object} - */ - build_search_data: function () { - var domains = [], contexts = [], groupbys = []; - - this.query.each(function (facet) { - var field = facet.get('field'); - var domain = field.get_domain(facet); - if (domain) { - domains.push(domain); - } - var context = field.get_context(facet); - if (context) { - contexts.push(context); - } - var group_by = field.get_groupby(facet); - if (group_by) { - groupbys.push.apply(groupbys, group_by); - } - }); - return { - domains: domains, - contexts: contexts, - groupbys: groupbys, - }; - }, - toggle_visibility: function (is_visible) { - this.do_toggle(!this.headless && is_visible); - if (this.$buttons) { - this.$buttons.toggle(!this.headless && is_visible && this.visible_filters); - } - if (!config.device.touch && config.device.size_class >= config.device.SIZES.SM) { - this.$('input').focus(); - } - }, - toggle_buttons: function (is_visible) { - this.visible_filters = is_visible || !this.visible_filters; - if (this.$buttons) { - this.$buttons.toggle(this.visible_filters); - } - }, - /** - * Sets up search view's view-wide auto-completion widget - */ - setup_global_completion: function () { - var self = this; - this.autocomplete = new AutoComplete(this, { - source: this.proxy('complete_global_search'), - select: this.proxy('select_completion'), - get_search_string: function () { - return self.$('.o_searchview_input').val().trim(); - }, - }); - this.autocomplete.appendTo(this.$el); - }, - /** - * Provide auto-completion result for req.term (an array to `resp`) - * - * @param {Object} req request to complete - * @param {String} req.term searched term to complete - * @param {Function} resp response callback - */ - complete_global_search: function (req, resp) { - var inputs = this.search_fields.concat(this.filters, this.groupbys); - $.when.apply(null, _(inputs).chain() - .filter(function (input) { return input.visible(); }) - .invoke('complete', req.term) - .value()).then(function () { - resp(_(arguments).chain() - .compact() - .flatten(true) - .value()); - }); - }, - /** - * Action to perform in case of selection: create a facet (model) - * and add it to the search collection - * - * @param {Object} e selection event, preventDefault to avoid setting value on object - * @param {Object} ui selection information - * @param {Object} ui.item selected completion item - */ - select_completion: function (e, ui) { - e.preventDefault(); - if(ui.item.facet && ui.item.facet.values && ui.item.facet.values.length && String(ui.item.facet.values[0].value).trim() !== "") { - this.query.add(ui.item.facet); - } else { - this.query.trigger('add'); - } - }, - subviewForRoot: function (subview_root) { - return _(this.input_subviews).detect(function (subview) { - return subview.$el[0] === subview_root; - }); - }, - siblingSubview: function (subview, direction, wrap_around) { - var index = _(this.input_subviews).indexOf(subview) + direction; - if (wrap_around && index < 0) { - index = this.input_subviews.length - 1; - } else if (wrap_around && index >= this.input_subviews.length) { - index = 0; - } - return this.input_subviews[index]; - }, - focusPreceding: function (subview_root) { - return this.siblingSubview( - this.subviewForRoot(subview_root), -1, true) - .$el.focus(); - }, - focusFollowing: function (subview_root) { - return this.siblingSubview( - this.subviewForRoot(subview_root), +1, true) - .$el.focus(); - }, - /** - * @param {openerp.web.search.SearchQuery | undefined} Undefined if event is change - * @param {openerp.web.search.Facet} - * @param {Object} [options] - */ - renderFacets: function (collection, model, options) { - var self = this; - var started = []; - _.invoke(this.input_subviews, 'destroy'); - this.input_subviews = []; - - this.query.each(function (facet) { - var f = new FacetView(this, facet); - started.push(f.appendTo(self.$el)); - self.input_subviews.push(f); - }, this); - var i = new InputView(this); - started.push(i.appendTo(self.$el)); - self.input_subviews.push(i); - _.each(this.input_subviews, function (childView) { - childView.on('focused', self, self.proxy('childFocused')); - childView.on('blurred', self, self.proxy('childBlurred')); - }); - - $.when.apply(null, started).then(function () { - _.last(self.input_subviews).$el.focus(); - }); - }, - childFocused: function () { - this.$el.addClass('active'); - }, - childBlurred: function () { - this.$el.val('').removeClass('active').trigger('blur'); - this.autocomplete.close(); - }, - /** - * Call the renderFacets method with the correct arguments. - * This is due to the fact that change events are called with two arguments - * (model, options) while add, reset and remove events are called with - * (collection, model, options) as arguments - */ - renderChangedFacets: function (model, options) { - this.renderFacets(undefined, model, options); - }, - // it should parse the arch field of the view, instantiate the corresponding - // filters/fields, and put them in the correct variables: - // * this.search_fields is a list of all the fields, - // * this.filters: groups of filters - // * this.group_by: group_bys - prepare_search_inputs: function () { - var self = this, - arch = this.fields_view.arch; - - var filters = [].concat.apply([], _.map(arch.children, function (item) { - return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item); - })); - function eval_item (item) { - var category = 'filters'; - if (item.attrs.context) { - try { - var context = pyeval.eval('context', item.attrs.context); - if (context.group_by) { - category = 'group_by'; - } - } catch (e) {} - } - return { - item: item, - category: category, - }; - } - var current_group = [], - current_category = 'filters', - categories = {filters: this.filters, group_by: this.groupbys}; - - _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) { - if (filter.item.tag === 'filter' && filter.category === current_category) { - return current_group.push(new search_inputs.Filter(filter.item, self)); - } - if (current_group.length) { - var group = new search_inputs.FilterGroup(current_group, self); - categories[current_category].push(group); - current_group = []; - } - if (filter.item.tag === 'field') { - var attrs = filter.item.attrs; - var field = self.fields_view.fields[attrs.name]; - - // M2O combined with selection widget is pointless and broken in search views, - // but has been used in the past for unsupported hacks -> ignore it - if (field.type === "many2one" && attrs.widget === "selection") { - attrs.widget = undefined; - } - - var Obj = core.search_widgets_registry.get_any([attrs.widget, field.type]); - if (Obj) { - self.search_fields.push(new (Obj) (filter.item, field, self)); - } - } - if (filter.item.tag === 'filter') { - current_group.push(new search_inputs.Filter(filter.item, self)); - } - current_category = filter.category; - }); - }, -}); - -_.extend(SearchView, { - SearchQuery: SearchQuery, - Facet: Facet, -}); - -return SearchView; - -}); diff --git a/addons/web/static/src/js/views/tree_view.js b/addons/web/static/src/js/views/tree_view.js deleted file mode 100644 index 604c9204b42..00000000000 --- a/addons/web/static/src/js/views/tree_view.js +++ /dev/null @@ -1,249 +0,0 @@ -odoo.define('web.TreeView', function (require) { -"use strict"; -/*--------------------------------------------------------- - * Odoo Tree view - *---------------------------------------------------------*/ - -var core = require('web.core'); -var data = require('web.data'); -var formats = require('web.formats'); -var pyeval = require('web.pyeval'); -var session = require('web.session'); -var View = require('web.View'); - -var _lt = core._lt; -var QWeb = core.qweb; - -var TreeView = View.extend({ - display_name: _lt('Tree'), - icon: 'fa-align-left', - // Indicates that this view is not searchable, and thus that no search view should be displayed. - searchable: false, - - /** - * Genuine tree view (the one displayed as a tree, not the list) - */ - init: function() { - this._super.apply(this, arguments); - this.records = {}; - _.bindAll(this, 'color_for'); - this.fields = this.fields_view.fields; - this.children_field = this.fields_view.field_parent; - }, - - /** - * Returns the list of fields needed to correctly read objects. - * - * Gathers the names of all fields in fields_view_get, and adds the - * field_parent (children_field in the tree view) if it's not already one - * of the fields to fetch - * - * @returns {Array} an array of fields which can be provided to DataSet.read_slice and others - */ - fields_list: function () { - var fields = _.keys(this.fields); - if (!_(fields).contains(this.children_field)) { - fields.push(this.children_field); - } - return fields; - }, - willStart: function () { - _(this.fields_view.arch.children).each(function (field) { - if (field.attrs.modifiers && typeof field.attrs.modifiers === "string") { - field.attrs.modifiers = JSON.parse(field.attrs.modifiers); - } - }); - - if (this.fields_view.arch.attrs.colors) { - this.colors = _(this.fields_view.arch.attrs.colors.split(';')).chain() - .compact() - .map(function(color_pair) { - var pair = color_pair.split(':'), - color = pair[0], - expr = pair[1]; - return [color, py.parse(py.tokenize(expr)), expr]; - }).value(); - } - - return this._super(); - }, - start: function () { - var self = this; - var has_toolbar = !!this.fields_view.arch.attrs.toolbar; - this.hook_row_click(); - this.$el.html(QWeb.render('TreeView', { - 'title': this.fields_view.arch.attrs.string, - 'fields_view': this.fields_view.arch.children, - 'fields': this.fields, - 'toolbar': has_toolbar - })); - this.$el.addClass(this.fields_view.arch.attrs['class']); - - this.dataset.read_slice(this.fields_list()).done(function(records) { - if (!has_toolbar) { - // WARNING: will do a second read on the same ids, but only on - // first load so not very important - self.getdata(null, _(records).pluck('id')); - return; - } - - var $select = self.$('select') - .change(function () { - var $option = $(this).find(':selected'); - self.getdata($option.val(), $option.data('children')); - }); - _(records).each(function (record) { - self.records[record.id] = record; - $('<option>') - .val(record.id) - .text(record.name) - .data('children', record[self.children_field]) - .appendTo($select); - }); - - if (!_.isEmpty(records)) { - $select.change(); - } - }); - - // TODO store open nodes in url ?... - this.do_push_state({}); - - return this._super(); - }, - /** - * Returns the color for the provided record in the current view (from the - * ``@colors`` attribute) - * - * @param {Object} record record for the current row - * @returns {String} CSS color declaration - */ - color_for: function (record) { - if (!this.colors) { return ''; } - var context = _.extend({}, record, { - uid: session.uid, - current_date: moment().format('YYYY-MM-DD') - // TODO: time, datetime, relativedelta - }); - for(var i=0, len=this.colors.length; i<len; ++i) { - var pair = this.colors[i], - color = pair[0], - expression = pair[1]; - if (py.PY_isTrue(py.evaluate(expression, context))) { - return 'color: ' + color + ';'; - } - // TODO: handle evaluation errors - } - return ''; - }, - /** - * Sets up opening a row - */ - hook_row_click: function () { - var self = this; - this.$el.delegate('.treeview-td span, .treeview-tr span', 'click', function (e) { - e.stopImmediatePropagation(); - self.activate($(this).closest('tr').data('id')); - }); - - this.$el.delegate('.treeview-tr', 'click', function () { - var is_loaded = 0, - $this = $(this), - record_id = $this.data('id'), - row_parent_id = $this.data('row-parent-id'), - record = self.records[record_id], - children_ids = record[self.children_field]; - - _(children_ids).each(function(childid) { - if (self.$('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').length ) { - if (self.$('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').is(':hidden')) { - is_loaded = -1; - } else { - is_loaded++; - } - } - }); - if (is_loaded === 0) { - if (!$this.parent().hasClass('o_open')) { - self.getdata(record_id, children_ids); - } - } else { - self.showcontent($this, record_id, is_loaded < 0); - } - }); - }, - // get child data of selected value - getdata: function (id, children_ids) { - var self = this; - - self.dataset.read_ids(children_ids, this.fields_list()).done(function(records) { - _(records).each(function (record) { - self.records[record.id] = record; - }); - var $curr_node = self.$('#treerow_' + id); - var children_rows = QWeb.render('TreeView.rows', { - 'records': records, - 'children_field': self.children_field, - 'fields_view': self.fields_view.arch.children, - 'fields': self.fields, - 'level': $curr_node.data('level') || 0, - 'render': formats.format_value, - 'color_for': self.color_for, - 'row_parent_id': id - }); - if ($curr_node.length) { - $curr_node.addClass('o_open'); - $curr_node.after(children_rows); - } else { - self.$('tbody').html(children_rows); - } - }); - }, - - // Get details in listview - activate: function(id) { - var self = this; - var local_context = { - active_model: self.model, - active_id: id, - active_ids: [id] - }; - var ctx = pyeval.eval( - 'context', new data.CompoundContext( - this.dataset.get_context(), local_context)); - return this.rpc('/web/treeview/action', { - id: id, - model: this.model, - context: ctx - }).then(function (actions) { - if (!actions.length) { return; } - var action = actions[0][2]; - var c = new data.CompoundContext(local_context).set_eval_context(ctx); - if (action.context) { - c.add(action.context); - } - action.context = c; - return self.do_action(action); - }); - }, - - // show & hide the contents - showcontent: function (curnode,record_id, show) { - curnode.parent('tr').toggleClass('o_open', show); - _(this.records[record_id][this.children_field]).each(function (child_id) { - var $child_row = this.$('[id=treerow_' + child_id + '][data-row-parent-id='+ curnode.data('id') +']'); - if ($child_row.hasClass('o_open')) { - $child_row.toggleClass('o_open',show); - this.showcontent($child_row, child_id, false); - } - $child_row.toggle(show); - }, this); - }, -}); - -core.view_registry.add('tree', TreeView); - -return TreeView; - -}); - 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 00000000000..0310656cd37 --- /dev/null +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -0,0 +1,376 @@ +odoo.define('web.view_dialogs', function (require) { +"use strict"; + +var core = require('web.core'); +var data = require('web.data'); +var Dialog = require('web.Dialog'); +var ListController = require('web.ListController'); +var ListView = require('web.ListView'); +var pyeval = require('web.pyeval'); +var SearchView = require('web.SearchView'); +var view_registry = require('web.view_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.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 || {}); + + // FIXME: remove this once a dataset won't be necessary anymore to interact + // with data_manager and instantiate views + this.dataset = new data.DataSet(this, this.res_model, this.context); + }, + + //-------------------------------------------------------------------------- + // 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 {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 {function} [options.on_save] callback to execute when clicking on + * 'Save' (form view's 'saveRecord' by default) + * @param {function} [options.on_saved] callback executed after on_save + * @param {BasicModel} [options.model] if given, it will be used instead of + * a new form view model + */ + init: function (parent, options) { + var self = this; + + this.res_id = options.res_id || null; + this.on_saved = options.on_saved || (function () {}); + this.context = options.context; + this.model = options.model; + + var multi_select = !_.isNumber(options.res_id) && !options.disable_multiple_selection; + var readonly = _.isNumber(options.res_id) && options.readonly; + + if (!options || !options.buttons) { + options = options || {}; + options.buttons = [{ + text: (readonly ? _t("Close") : _t("Discard")), + classes: "btn-default o_form_button_cancel", + close: true, + click: function () { + if (!readonly) { + self.form_view.model.discardChanges(self.form_view.handle); + } + }, + }]; + + if (!readonly) { + options.buttons.unshift({ + text: _t("Save") + ((multi_select)? " " + _t(" & Close") : ""), + classes: "btn-primary", + click: function () { + this._save().then(self.close.bind(self)); + } + }); + + if (multi_select) { + options.buttons.splice(1, 0, { + text: _t("Save & New"), + classes: "btn-primary", + click: function () { + this._save().then(self.form_view.createRecord.bind(self.form_view)); + }, + }); + } + } + } + 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 = $.when(this.options.fields_view); + } else { + fields_view_def = this.loadFieldView(this.dataset, this.options.view_id, 'form'); + } + + fields_view_def.then(function (fields_view) { + var formview = new FormView(fields_view.arch, fields_view.fields, { + modelName: self.res_model, + context: self.context, + ids: self.res_id ? [self.res_id] : [], + currentId: self.res_id || undefined, + index: 0, + mode: self.res_id && self.options.readonly ? 'readonly' : 'edit', + footer_to_buttons: true, + default_buttons: false, + model: self.model, + }); + return formview.createController(self); + }).then(function (formView) { + self.form_view = formView; + var fragment = document.createDocumentFragment(); + self.form_view.appendTo(fragment) + .then(function () { + var $buttons = $('<div>'); + self.form_view.renderButtons($buttons); + if ($buttons.children().length) { + self.$footer.empty().append($buttons.contents()); + } + _super().$el.append(fragment); + self.form_view.autofocus(); + }); + }); + + return this; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _save: function () { + var self = this; + var def; + if (this.options.on_save) { + def = this.options.on_save(this.form_view.model.get(this.form_view.handle)); + } else { + def = this.form_view.saveRecord({stayInEdit: true, reload: false}); + } + return $.when(def).then(function () { + // 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 + self.on_saved(self.form_view.model.get(self.form_view.handle)); + }); + }, +}); + +var SelectCreateListController = ListController.extend({ + // Override the ListView to handle the custom events 'open_record' (triggered when clicking on a + // row of the list) such that it triggers up 'select_record' with its res_id. + custom_events: _.extend({}, ListController.prototype.custom_events, { + open_record: function (event) { + var selected_record = this.model.get(event.data.id); + this.trigger_up('select_record', {id: selected_record.res_id}); + }, + }), +}); + +/** + * 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.id]); + this.close(); + } + }, + selection_changed: function (event) { + this.$footer.find(".o_select_button").prop('disabled', !event.data.selection.length); + }, + search: function (event) { + event.stopPropagation(); // prevent this event from bubbling up to the view manager + var d = event.data; + var searchData = this._process_search_data(d.domains, d.contexts, d.groupbys); + this.list_controller.reload(searchData); + }, + }), + + /** + * 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 + */ + init: function () { + this._super.apply(this, arguments); + _.defaults(this.options, { initial_view: 'search' }); + this.on_selected = this.options.on_selected || (function () {}); + this.initial_ids = this.options.initial_ids; + }, + + open: function () { + if(this.options.initial_view !== "search") { + return this.create_edit_record(); + } + var user_context = this.getSession().user_context; + + var _super = this._super.bind(this); + var context = pyeval.eval_domains_and_contexts({ + domains: [], + contexts: [user_context, this.context] + }).context; + var search_defaults = {}; + _.each(context, function (value_, key) { + var match = /^search_default_(.*)$/.exec(key); + if (match) { + search_defaults[match[1]] = value_; + } + }); + this.loadViews(this.dataset.model, this.dataset.get_context(), [[false, 'list'], [false, 'search']], {}) + .then(this.setup.bind(this, search_defaults)) + .then(function (fragment) { + _super().$el.append(fragment); + }); + return this; + }, + + setup: function (search_defaults, fields_views) { + var self = this; + var fragment = document.createDocumentFragment(); + + var searchDef = $.Deferred(); + + // Set the dialog's header and its search view + var $header = $('<div/>').addClass('o_modal_header').appendTo(fragment); + var $pager = $('<div/>').addClass('o_pager').appendTo($header); + var options = { + $buttons: $('<div/>').addClass('o_search_options').appendTo($header), + search_defaults: search_defaults, + }; + var searchview = new SearchView(this, this.dataset, fields_views.search, options); + searchview.prependTo($header).done(function () { + var d = searchview.build_search_data(); + d.domains = d.domains.concat([self.domain]); + if (self.initial_ids) { + d.domains.push([["id", "in", self.initial_ids]]); + self.initial_ids = undefined; + } + var searchData = self._process_search_data(d.domains, d.contexts, d.groupbys); + searchDef.resolve(searchData); + }); + + return $.when(searchDef).then(function (searchResult) { + // Set the list view + var listView = new ListView(fields_views.list.arch, fields_views.list.fields, _.extend({ + context: searchResult.context, + domain: searchResult.domain, + groupBy: searchResult.groupBy, + modelName: self.dataset.model, + hasSelectors: !self.options.disable_multiple_selection, + }, self.options.list_view_options)); + listView.setController(SelectCreateListController); + return listView.createController(self); + }).then(function (controller) { + self.list_controller = controller; + // Set the dialog's buttons + var buttons = [{ + text: _t("Cancel"), + classes: "btn-default o_form_button_cancel", + close: true, + }]; + if (!self.options.no_create) { + buttons.unshift({ + text: _t("Create"), + classes: "btn-primary", + click: self.create_edit_record.bind(self) + }); + } + if (!self.options.disable_multiple_selection) { + buttons.unshift({ + text: _t("Select"), + classes: "btn-primary o_select_button", + disabled: true, + close: true, + click: function () { + self.on_selected(self.list_controller.getSelectedIds()); + }, + }); + } + self.set_buttons(buttons); + return self.list_controller.appendTo(fragment); + }).then(function () { + searchview.toggle_visibility(true); + self.list_controller.do_show(); + self.list_controller.renderPager($pager); + return fragment; + }); + }, + _process_search_data: function (domains, contexts, groupbys) { + var user_context = this.getSession().user_context; + contexts = [user_context].concat(contexts); + var results = pyeval.eval_domains_and_contexts({ + domains: domains || [], + contexts: contexts || [], + group_by_seq: groupbys || [] + }); + return { + context: results.context, + domain: results.domain, + groupBy: results.group_by, + }; + }, + create_edit_record: function () { + var self = this; + var dialog = new FormViewDialog(this, _.extend({}, this.options, { + on_saved: function (record) { + self.on_selected([record.res_id]); + }, + })).open(); + dialog.on('closed', this, this.close.bind(this)); + }, +}); + +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 00000000000..f936787ba1a --- /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); + +}); |
