summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2017-03-05 10:05:56 +0100
committerAaron Bohy <aab@odoo.com>2017-04-11 19:44:38 +0200
commit905e01921f3c3ef43ace6fc537d1d8c0d280002c (patch)
tree265c43ab21c5401a5d9f0f3d64d86f1ac417451c /addons/web/static/src/js/views
parent54d4c73af4ad05c7640d28c1892247c9538b8017 (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')
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js498
-rw-r--r--addons/web/static/src/js/views/abstract_model.js69
-rw-r--r--addons/web/static/src/js/views/abstract_renderer.js119
-rw-r--r--addons/web/static/src/js/views/abstract_view.js181
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js178
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js1996
-rw-r--r--addons/web/static/src/js/views/basic/basic_renderer.js115
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js171
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js267
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js620
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_quick_create.js140
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_renderer.js450
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_view.js129
-rw-r--r--addons/web/static/src/js/views/field_manager_mixin.js164
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js642
-rw-r--r--addons/web/static/src/js/views/form/form_renderer.js603
-rw-r--r--addons/web/static/src/js/views/form/form_view.js44
-rw-r--r--addons/web/static/src/js/views/form_common.js1115
-rw-r--r--addons/web/static/src/js/views/form_relational_widgets.js1726
-rw-r--r--addons/web/static/src/js/views/form_upgrade_widgets.js101
-rw-r--r--addons/web/static/src/js/views/form_view.js1653
-rw-r--r--addons/web/static/src/js/views/form_widgets.js1803
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js128
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js188
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js334
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js69
-rw-r--r--addons/web/static/src/js/views/graph_view.js144
-rw-r--r--addons/web/static/src/js/views/graph_widget.js340
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js268
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js358
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js126
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_quick_create.js140
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js403
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js255
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js83
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js353
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js649
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js583
-rw-r--r--addons/web/static/src/js/views/list/list_view.js58
-rw-r--r--addons/web/static/src/js/views/list_common.js405
-rw-r--r--addons/web/static/src/js/views/list_view.js2075
-rw-r--r--addons/web/static/src/js/views/list_view_editable.js866
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js368
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js704
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js142
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js103
-rw-r--r--addons/web/static/src/js/views/pivot_view.js892
-rw-r--r--addons/web/static/src/js/views/search_filters.js366
-rw-r--r--addons/web/static/src/js/views/search_inputs.js762
-rw-r--r--addons/web/static/src/js/views/search_menus.js483
-rw-r--r--addons/web/static/src/js/views/search_view.js613
-rw-r--r--addons/web/static/src/js/views/tree_view.js249
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js376
-rw-r--r--addons/web/static/src/js/views/view_registry.js44
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>&nbsp;</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('&nbsp;'));
+ }
+ 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 + '">&nbsp;</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 : '&nbsp;';
- },
- /**
- * 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 += '&nbsp;' + currency.symbol;
- } else {
- value = currency.symbol + '&nbsp;' + 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);
+
+});