summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2015-03-09 16:34:27 +0100
committerGéry Debongnie <ged@odoo.com>2015-03-18 09:01:49 +0100
commite8a00bc50da435d3859118ca127d20a2eb9a0cab (patch)
tree039f6ec40b37392e76c8830a8e518ac40f7e7fec /addons/web/static/src/js/views
parent9036084f065c7aca3b2199903f4da67db3501ba7 (diff)
[REF] web: add module system to the web client
This commit introduces a new module system for the javascript code. Instead of using global (namespaced) variables everywhere (like openerp.web.whatever, ...), each file declare one (or more) module with its dependencies. Each module will be loaded after its dependencies, and will be given a reference to the return values. This change is a massive departure from the way the client worked previously. It will sadly break some/most history, but I am sure that the future git ecosystem will allow you to easily browse the history past this revision (one can dream). This commit feature: * main namespace is now odoo instead of openerp * a way to define modules and dependencies (inspired by requirejs) * many files have been splitted in simpler subfiles * basic support for debugging in odoo.__DEBUG__ * no global variable (i.e. nothing in window.openerp) * many bug fixes, many new bugs Future vision: this modularization should simplify future code development, simplify debugging and will allow a serious testing effort
Diffstat (limited to 'addons/web/static/src/js/views')
-rw-r--r--addons/web/static/src/js/views/form_common.js1135
-rw-r--r--addons/web/static/src/js/views/form_relational_widgets.js2028
-rw-r--r--addons/web/static/src/js/views/form_view.js1587
-rw-r--r--addons/web/static/src/js/views/form_widgets.js1590
-rw-r--r--addons/web/static/src/js/views/graph_view.js128
-rw-r--r--addons/web/static/src/js/views/graph_widget.js303
-rw-r--r--addons/web/static/src/js/views/list_common.js402
-rw-r--r--addons/web/static/src/js/views/list_view.js2100
-rw-r--r--addons/web/static/src/js/views/list_view_editable.js843
-rw-r--r--addons/web/static/src/js/views/pivot_view.js800
-rw-r--r--addons/web/static/src/js/views/search_filters.js311
-rw-r--r--addons/web/static/src/js/views/search_inputs.js736
-rw-r--r--addons/web/static/src/js/views/search_menus.js437
-rw-r--r--addons/web/static/src/js/views/search_view.js945
-rw-r--r--addons/web/static/src/js/views/tree_view.js280
15 files changed, 13625 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/form_common.js b/addons/web/static/src/js/views/form_common.js
new file mode 100644
index 00000000000..c1f679fa4f3
--- /dev/null
+++ b/addons/web/static/src/js/views/form_common.js
@@ -0,0 +1,1135 @@
+odoo.define('web.form_common', ['web.core', 'web.data', 'web.Dialog', 'web.ListView', 'web.pyeval', 'web.SearchView', 'web.session', 'web.utils', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+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);
+ 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]),
+ 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: 'oe_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: 'oe_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: 'oe_m2o_dropdown_option'
+ });
+ }
+ else if (values.length === 0) {
+ values.push({
+ label: _t("No results to show..."),
+ action: function() {},
+ classname: 'oe_m2o_dropdown_option'
+ });
+ }
+
+ 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;
+ var pop = new SelectCreatePopup(this);
+ pop.select_element(
+ self.field.relation,
+ {
+ 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
+ },
+ self.build_domain(),
+ new data.CompoundContext(self.build_context(), context || {})
+ );
+ pop.on("elements_selected", self, function(element_ids) {
+ self.add_id(element_ids[0]);
+ self.focus();
+ });
+ },
+ /**
+ * 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('oe_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(.oe_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(.oe_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"] || "");
+ },
+ 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: 500, 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();
+ });
+ },
+ renderElement: function() {
+ var self = this;
+ this._super();
+ if (this.field.translate && this.view) {
+ this.$el.addClass('oe_form_field_translatable');
+ this.$el.find('.oe_field_translate').click(this.on_translate);
+ }
+ this.$label = this.view ? this.view.$el.find('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.render_value();
+ },
+ /**
+ * Private. Do not use.
+ */
+ _set_required: function() {
+ this.$el.toggleClass('oe_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() {
+ if (this.field.translate) {
+ this.$el.find('.oe_field_translate').toggle(this.field_manager.get('actual_mode') !== "create");
+ }
+ if (!this.disable_utility_classes) {
+ if (this.field_manager.get('display_invalid_fields')) {
+ this.$el.toggleClass('oe_form_invalid', !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.dataset.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,
+ minHeight: height
+ });
+ },
+ commit_value: function() {
+ return $.when();
+ },
+});
+
+/**
+ * Class with everything which is common between FormOpenPopup and SelectCreatePopup.
+ */
+var AbstractFormPopup = Widget.extend({
+ template: "AbstractFormPopup.render",
+ /**
+ * options:
+ * -readonly: only applicable when not in creation mode, default to false
+ * - alternative_form_view
+ * - view_id
+ * - write_function
+ * - read_function
+ * - create_function
+ * - parent_view
+ * - child_name
+ * - form_view_options
+ */
+ init_popup: function(model, row_id, domain, context, options) {
+ this.row_id = row_id;
+ this.model = model;
+ this.domain = domain || [];
+ this.context = context || {};
+ this.options = options;
+ _.defaults(this.options, {});
+ },
+ init_dataset: function() {
+ var self = this;
+ this.created_elements = [];
+ this.dataset = new data.ProxyDataSet(this, this.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;
+ },
+ display_popup: function() {
+ var self = this;
+ this.renderElement();
+ var dialog = new Dialog(this, {
+ dialogClass: 'oe_act_window',
+ title: this.options.title || "",
+ }, this.$el).open();
+ dialog.on('closing', this, function (e){
+ self.check_exit(true);
+ });
+ this.$buttonpane = dialog.$buttons;
+ this.start();
+ },
+ setup_form_view: function() {
+ var self = this;
+ if (this.row_id) {
+ this.dataset.ids = [this.row_id];
+ this.dataset.index = 0;
+ } else {
+ this.dataset.index = null;
+ }
+ var options = _.clone(self.options.form_view_options) || {};
+ if (this.row_id !== null) {
+ options.initial_mode = this.options.readonly ? "view" : "edit";
+ }
+ _.extend(options, {
+ $buttons: this.$buttonpane,
+ });
+ var FormView = core.view_registry.get('form');
+ this.view_form = new FormView(this, this.dataset, this.options.view_id || false, options);
+ if (this.options.alternative_form_view) {
+ this.view_form.set_embedded_view(this.options.alternative_form_view);
+ }
+ this.view_form.appendTo(this.$(".oe_popup_form").show());
+ this.view_form.on("form_view_loaded", self, function() {
+ var multi_select = self.row_id === null && ! self.options.disable_multiple_selection;
+ self.$buttonpane.html(QWeb.render("AbstractFormPopup.buttons", {
+ multi_select: multi_select,
+ readonly: self.row_id !== null && self.options.readonly,
+ }));
+ var $snbutton = self.$buttonpane.find(".oe_abstractformpopup-form-save-new");
+ $snbutton.click(function() {
+ $.when(self.view_form.save()).done(function() {
+ self.view_form.reload_mutex.exec(function() {
+ self.view_form.on_button_new();
+ });
+ });
+ });
+ var $sbutton = self.$buttonpane.find(".oe_abstractformpopup-form-save");
+ $sbutton.click(function() {
+ $.when(self.view_form.save()).done(function() {
+ self.view_form.reload_mutex.exec(function() {
+ self.check_exit();
+ });
+ });
+ });
+ var $cbutton = self.$buttonpane.find(".oe_abstractformpopup-form-close");
+ $cbutton.click(function() {
+ self.view_form.trigger('on_button_cancel');
+ self.check_exit();
+ });
+ self.view_form.do_show();
+ });
+ },
+ select_elements: function(element_ids) {
+ this.trigger("elements_selected", element_ids);
+ },
+ check_exit: function(no_destroy) {
+ if (this.created_elements.length > 0) {
+ this.select_elements(this.created_elements);
+ this.created_elements = [];
+ }
+ this.trigger('closed');
+ this.destroy();
+ },
+ destroy: function () {
+ this.trigger('closed');
+ if (this.$el.is(":data(bs.modal)")) {
+ this.$el.parents('.modal').modal('hide');
+ }
+ this._super();
+ },
+});
+
+/**
+ * Class to display a popup containing a form view.
+ */
+var FormOpenPopup = AbstractFormPopup.extend({
+ show_element: function(model, row_id, context, options) {
+ this.init_popup(model, row_id, [], context, options);
+ _.defaults(this.options, {
+ });
+ this.display_popup();
+ },
+ start: function() {
+ this._super();
+ this.init_dataset();
+ this.setup_form_view();
+ },
+});
+
+var SelectCreateListView = ListView.extend({
+ do_add_record: function () {
+ this.popup.new_object();
+ },
+ select_record: function(index) {
+ this.popup.select_elements([this.dataset.ids[index]]);
+ this.popup.destroy();
+ },
+ do_select: function(ids, records) {
+ this._super(ids, records);
+ this.popup.on_click_element(ids);
+ }
+});
+
+/**
+ * Class to display a popup to display a list to search a row. It also allows
+ * to switch to a form view to create a new row.
+ */
+var SelectCreatePopup = AbstractFormPopup.extend({
+ /**
+ * options:
+ * - initial_ids
+ * - initial_view: form or search (default search)
+ * - disable_multiple_selection
+ * - list_view_options
+ */
+ select_element: function(model, options, domain, context) {
+ this.init_popup(model, null, domain, context, options);
+ _.defaults(this.options, {
+ initial_view: "search",
+ });
+ this.initial_ids = this.options.initial_ids;
+ this.display_popup();
+ },
+ start: function() {
+ this.init_dataset();
+ if (this.options.initial_view == "search") {
+ 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_;
+ }
+ });
+ this.setup_search_view(search_defaults);
+ } else { // "form"
+ this.new_object();
+ }
+ },
+ setup_search_view: function(search_defaults) {
+ var self = this;
+ if (this.searchview) {
+ this.searchview.destroy();
+ }
+ var $buttons = this.$('.o-search-options');
+ this.searchview = new SearchView(this,
+ this.dataset, false, search_defaults, {$buttons: $buttons});
+ this.searchview.on('search_data', self, function(domains, contexts, groupbys) {
+ if (self.initial_ids) {
+ self.do_search(domains.concat([[["id", "in", self.initial_ids]], self.domain]),
+ contexts.concat(self.context), groupbys);
+ self.initial_ids = undefined;
+ } else {
+ self.do_search(domains.concat([self.domain]), contexts.concat(self.context), groupbys);
+ }
+ });
+ this.searchview.appendTo(this.$(".o-popup-search")).done(function() {
+ self.searchview.toggle_visibility(true);
+ self.view_list = new SelectCreateListView(self,
+ self.dataset, false,
+ _.extend({'deletable': false,
+ 'selectable': !self.options.disable_multiple_selection,
+ 'import_enabled': false,
+ '$buttons': self.$buttonpane,
+ 'disable_editable_mode': true,
+ '$pager': self.$('.oe_popup_list_pager'),
+ }, 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.appendTo(self.$(".oe_popup_list").show()).then(function() {
+ self.view_list.do_show();
+ }).then(function() {
+ if (self.options.initial_facet) {
+ self.searchview.query.reset([self.options.initial_facet], {
+ preventSearch: true,
+ });
+ }
+ self.searchview.do_search();
+ });
+ self.view_list.on("list_view_loaded", self, function() {
+ self.$buttonpane.html(QWeb.render("SelectCreatePopup.search.buttons", {widget:self}));
+ var $cbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-close");
+ $cbutton.click(function() {
+ self.destroy();
+ });
+ var $sbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-select");
+ $sbutton.click(function() {
+ self.select_elements(self.selected_ids);
+ self.destroy();
+ });
+ $cbutton = self.$buttonpane.find(".oe_selectcreatepopup-search-create");
+ $cbutton.click(function() {
+ self.new_object();
+ });
+ });
+ });
+ },
+ 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) {
+ var self = this;
+ this.selected_ids = ids || [];
+ if(this.selected_ids.length > 0) {
+ self.$buttonpane.find(".oe_selectcreatepopup-search-select").removeAttr('disabled');
+ } else {
+ self.$buttonpane.find(".oe_selectcreatepopup-search-select").attr('disabled', "disabled");
+ }
+ },
+ new_object: function() {
+ if (this.searchview) {
+ this.searchview.do_hide();
+ }
+ if (this.view_list) {
+ this.view_list.do_hide();
+ this.view_list.$el.parent().hide();
+ }
+ this.setup_form_view();
+ },
+});
+
+var DomainEditorPopup = SelectCreatePopup.extend({
+ select_element: function (model, options, domain, context) {
+ if (options.readonly) {
+ return this._super(model, options, domain, context);
+ }
+ options.initial_facet = {
+ category: _t("Custom Filter"),
+ icon: 'fa-star',
+ field: {
+ get_context: function () { return context; },
+ get_groupby: function () { return []; },
+ get_domain: function () { return domain; },
+ },
+ values: [{label: _t("Selected domain"), value: null}],
+ };
+ this._super(model, options, [], context);
+ },
+ get_domain: function (selected_ids) {
+ var group_domain = [],
+ domain;
+ if (this.$('input.oe_list_record_selector').prop('checked')) {
+ if (this.view_list.grouped) {
+ var group_domain = _.chain(_.values(this.view_list.groups.children))
+ .filter(function (child) { return child.records.length; })
+ .map(function (c) { return c.datagroup.domain;})
+ .value();
+ group_domain = _.flatten(group_domain, true);
+ group_domain = _.times(group_domain.length - 1, _.constant('|')).concat(group_domain);
+ }
+ var search_data = this.searchview.build_search_data();
+ domain = pyeval.sync_eval_domains_and_contexts({
+ domains: search_data.domains,
+ contexts: search_data.contexts,
+ group_by_seq: search_data.groupbys || []
+ }).domain;
+ }
+ else {
+ domain = [["id", "in", selected_ids]];
+ }
+ return this.dataset.domain.concat(group_domain).concat(domain || []);
+ },
+});
+
+return {
+ // mixins
+ FieldManagerMixin: FieldManagerMixin,
+ ReinitializeWidgetMixin: ReinitializeWidgetMixin,
+ ReinitializeFieldMixin: ReinitializeFieldMixin,
+ CompletionFieldMixin: CompletionFieldMixin,
+
+ // misc
+ FormWidget: FormWidget,
+ DefaultFieldManager: DefaultFieldManager,
+ InvisibilityChanger: InvisibilityChanger,
+ NotebookInvisibilityChanger: NotebookInvisibilityChanger,
+ commands: commands,
+ AbstractField: AbstractField,
+
+ // popup dialogs
+ AbstractFormPopup: AbstractFormPopup,
+ FormOpenPopup: FormOpenPopup,
+ SelectCreatePopup: SelectCreatePopup,
+ DomainEditorPopup: DomainEditorPopup,
+};
+
+}); \ No newline at end of file
diff --git a/addons/web/static/src/js/views/form_relational_widgets.js b/addons/web/static/src/js/views/form_relational_widgets.js
new file mode 100644
index 00000000000..19ca1903048
--- /dev/null
+++ b/addons/web/static/src/js/views/form_relational_widgets.js
@@ -0,0 +1,2028 @@
+odoo.define('web.form_relational', ['web.core', 'web.data', 'web.Dialog', 'web.form_common', 'web.FormView', 'web.ListView', 'web.Model', 'web.session', 'web.utils', 'web.ViewManager'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var common = require('web.form_common');
+var FormView = require('web.FormView');
+var ListView = require('web.ListView');
+var Model = require('web.Model');
+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',
+ });
+ },
+ start: function() {
+ var self = this;
+ var text = _.str.sprintf(_t("You are creating a new %s, are you sure it does not exist yet?"), self.name);
+ this.$("p").text( text );
+ this.$buttons.html(QWeb.render("M2ODialog.buttons"));
+ this.$("input").val(this.getParent().$input.val());
+ this.$buttons.find(".oe_form_m2o_qc_button").click(function(e){
+ if (self.$("input").val() !== ''){
+ self.getParent()._quick_create(self.$("input").val());
+ self.destroy();
+ } else{
+ e.preventDefault();
+ self.$("input").focus();
+ }
+ });
+ this.$buttons.find(".oe_form_m2o_sc_button").click(function(){
+ self.getParent()._search_create_popup("form", undefined, self.getParent()._create_context(self.$("input").val()));
+ self.destroy();
+ });
+ this.$buttons.find(".oe_form_m2o_cancel_button").click(function(){
+ self.destroy();
+ });
+ },
+});
+
+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.set({'value': false});
+ 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.$drop_down) {
+ this.$drop_down.off('click');
+ delete this.$drop_down;
+ }
+ if (this.$input) {
+ 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.$el.find("input");
+
+ this.init_error_displayer();
+
+ self.$input.on('focus', function() {
+ self.hide_error_displayer();
+ });
+
+ this.$drop_down = this.$el.find(".oe_m2o_drop_down_button");
+ this.$follow_button = $(".oe_m2o_cm_button", this.$el);
+
+ this.$follow_button.click(function(ev) {
+ ev.preventDefault();
+ if (!self.get('value')) {
+ self.focus();
+ return;
+ }
+ var pop = new common.FormOpenPopup(self);
+ 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){
+ pop.show_element(
+ self.field.relation,
+ self.get("value"),
+ self.build_context(),
+ {
+ title: _t("Open: ") + self.string,
+ view_id: view_id,
+ readonly: !self.can_write
+ }
+ );
+ 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.$drop_down.click(function() {
+ self.$input.focus();
+ 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;
+ }
+ if (used && self.get("value") === false && ! 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.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.AbstractFormPopup;
+ })) { 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();
+ },
+ html: true,
+ // disabled to solve a bug, but may cause others
+ //close: anyoneLoosesFocus,
+ minLength: 0,
+ delay: 200,
+ });
+ var appendTo = this.$input.parents('.oe-view-manager-content, .modal-dialog').last();
+ if (appendTo.length === 0) {
+ appendTo = '.oe_application > *';
+ }
+ this.$input.autocomplete({
+ appendTo: appendTo
+ });
+ // set position for list of suggestions box
+ this.$input.autocomplete( "option", "position", { my : "left top", at: "left bottom" } );
+ this.$input.autocomplete("widget").openerpClass();
+ // 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("");
+ 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"), _t("No value found for the field "+self.field.string+" for value "+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 self = this;
+ if (!this.get("effective_readonly")) {
+ this.$input.val(str.split("\n")[0]);
+ this.current_display = this.$input.val();
+ if (this.is_false()) {
+ this.$('.oe_m2o_cm_button').css({'display':'none'});
+ } else {
+ this.$('.oe_m2o_cm_button').css({'display':'inline'});
+ }
+ } else {
+ var lines = _.escape(str).split("\n");
+ var link = "";
+ var follow = "";
+ link = lines[0];
+ follow = _.rest(lines).join("<br />");
+ if (follow)
+ link += "<br />";
+ var $link = this.$el.find('.oe_form_uri')
+ .unbind('click')
+ .html(link);
+ if (! this.options.no_open)
+ $link.click(function () {
+ var context = self.build_context().eval();
+ var model_obj = new Model(self.field.relation);
+ model_obj.call('get_formview_action', [self.get("value"), context]).then(function(action){
+ self.do_action(action);
+ });
+ return false;
+ });
+ $(".oe_form_m2o_follow", this.$el).html(follow);
+ }
+ },
+ 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;
+ },
+ set_dimensions: function (height, width) {
+ this._super(height, width);
+ if (!this.get("effective_readonly") && this.$input)
+ this.$input.css('height', height);
+ }
+});
+
+var Many2OneButton = common.AbstractField.extend({
+ template: 'Many2OneButton',
+ init: function(field_manager, node) {
+ this._super.apply(this, arguments);
+ },
+ start: function() {
+ this._super.apply(this, arguments);
+ this.set_button();
+ },
+ set_button: function() {
+ var self = this;
+ if (this.$button) {
+ this.$button.remove();
+ }
+ this.string = '';
+ this.node.attrs.icon = this.get('value') ? '/web/static/src/img/icons/gtk-yes.png' : '/web/static/src/img/icons/gtk-no.png';
+ this.$button = $(QWeb.render('WidgetButton', {'widget': this}));
+ this.$button.addClass('oe_link').css({'padding':'4px'});
+ this.$el.append(this.$button);
+ this.$button.on('click', self.on_click);
+ },
+ on_click: function(ev) {
+ var self = this;
+ this.popup = new common.FormOpenPopup(this);
+ this.popup.show_element(
+ this.field.relation,
+ this.get('value'),
+ this.build_context(),
+ {title: this.string}
+ );
+ this.popup.on('create_completed', self, function(r) {
+ self.set_value(r);
+ });
+ },
+ set_value: function(value_) {
+ if (value_ instanceof Array) {
+ value_ = value_[0];
+ }
+ value_ = value_ || false;
+ this.set('value', value_);
+ this.set_button();
+ },
+});
+
+/**
+ * Abstract-ish ListView.List subclass adding an "Add an item" row to replace
+ * the big ugly button in the header.
+ *
+ * Requires the implementation of a ``is_readonly`` method (usually a proxy to
+ * the corresponding field's readonly or effective_readonly property) to
+ * decide whether the special row should or should not be inserted.
+ *
+ * Optionally an ``_add_row_class`` attribute can be set for the class(es) to
+ * set on the insertion row.
+ */
+var AddAnItemList = ListView.List.extend({
+ pad_table_to: function (count) {
+ if (!this.view.is_action_enabled('create') || this.is_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': this._add_row_class || ''
+ }).append(
+ $('<a>', {href: '#'}).text(_t("Add an item"))
+ .mousedown(function () {
+ // FIXME: needs to be an official API somehow
+ if (self.view.editor.is_editing()) {
+ self.view.__ignore_blur = true;
+ }
+ })
+ .click(function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ // 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;
+ }
+ self.view.ensure_saved().done(function () {
+ self.view.do_add_record();
+ });
+ }));
+
+ 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 FieldOne2Many = common.AbstractField.extend({
+ multi_selection: false,
+ disable_utility_classes: true,
+ init: function(field_manager, node) {
+ this._super(field_manager, node);
+ this.is_loaded = $.Deferred();
+ this.initial_is_loaded = this.is_loaded;
+ this.form_last_update = $.Deferred();
+ this.init_form_last_update = this.form_last_update;
+ this.is_started = false;
+ this.dataset = new One2ManyDataSet(this, this.field.relation);
+ this.dataset.o2m = this;
+ this.dataset.parent_view = this.view;
+ this.dataset.child_name = this.name;
+ var self = this;
+ this.dataset.on('dataset_changed', this, function() {
+ self.trigger_on_change();
+ });
+ this.set_value([]);
+ },
+ start: function() {
+ this._super.apply(this, arguments);
+ this.$el.addClass('oe_form_field oe_form_field_one2many');
+
+ var self = this;
+
+ self.load_views();
+ var destroy = function() {
+ self.is_loaded = self.is_loaded.then(function() {
+ 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", self, destroy);
+ this.is_started = true;
+ this.reload_current_view();
+ },
+ trigger_on_change: function() {
+ this.trigger('changed_value');
+ },
+ load_views: function() {
+ var self = this;
+
+ var modes = this.node.attrs.mode;
+ modes = !!modes ? modes.split(",") : ["tree"];
+ var views = [];
+ _.each(modes, function(mode) {
+ if (! _.include(["list", "tree", "graph", "kanban"], mode)) {
+ throw new Error(_.str.sprintf(_t("View type '%s' is not supported in One2Many."), mode));
+ }
+ var view = {
+ view_id: false,
+ view_type: mode == "tree" ? "list" : mode,
+ options: {}
+ };
+ if (self.field.views && self.field.views[mode]) {
+ view.embedded_view = self.field.views[mode];
+ }
+ if(view.view_type === "list") {
+ _.extend(view.options, {
+ 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 === "form") {
+ if (self.get("effective_readonly")) {
+ view.view_type = 'form';
+ }
+ _.extend(view.options, {
+ not_interactible_on_create: true,
+ });
+ } else if (view.view_type === "kanban") {
+ _.extend(view.options, {
+ 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 One2ManyViewManager(this, this.dataset, views, {});
+ this.viewmanager.o2m = self;
+ var once = $.Deferred().done(function() {
+ self.init_form_last_update.resolve();
+ });
+ var def = $.Deferred().done(function() {
+ self.initial_is_loaded.resolve();
+ });
+ this.viewmanager.on("controller_inited", self, function(view_type, controller) {
+ controller.o2m = 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 === "form") {
+ if (self.get("effective_readonly")) {
+ $(".oe_form_buttons", controller.$el).children().remove();
+ }
+ controller.on("load_record", self, function(){
+ once.resolve();
+ });
+ controller.on('pager_action_executed',self,self.save_any_view);
+ } else if (view_type == "graph") {
+ self.reload_current_view();
+ }
+ def.resolve();
+ });
+ this.viewmanager.on("switch_mode", self, function(n_mode) {
+ $.when(self.save_any_view()).done(function() {
+ if (n_mode === "list") {
+ $.async_when().done(function() {
+ self.reload_current_view();
+ });
+ }
+ });
+ });
+ $.async_when().done(function () {
+ self.viewmanager.appendTo(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") {
+ return view.controller.reload_content();
+ } else if (view.type === "form") {
+ if (self.dataset.index === null && self.dataset.ids.length >= 1) {
+ self.dataset.index = 0;
+ }
+ var act = function() {
+ return view.controller.do_show();
+ };
+ self.form_last_update = self.form_last_update.then(act, act);
+ return self.form_last_update;
+ } 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_) {
+ value_ = value_ || [];
+ var self = this;
+ var view = this.get_active_view();
+ this.dataset.reset_ids([]);
+ var ids;
+ if(value_.length >= 1 && value_[0] instanceof Array) {
+ ids = [];
+ _.each(value_, function(command) {
+ var obj = {values: command[2]};
+ switch (command[0]) {
+ case commands.CREATE:
+ obj.id = _.uniqueId(self.dataset.virtual_id_prefix);
+ obj.defaults = {};
+ self.dataset.to_create.push(obj);
+ self.dataset.cache.push(_.extend(_.clone(obj), {values: _.clone(command[2])}));
+ ids.push(obj.id);
+ return;
+ case commands.UPDATE:
+ obj.id = command[1];
+ self.dataset.to_write.push(obj);
+ self.dataset.cache.push(_.extend(_.clone(obj), {values: _.clone(command[2])}));
+ ids.push(obj.id);
+ return;
+ case commands.DELETE:
+ self.dataset.to_delete.push({id: command[1]});
+ return;
+ case commands.LINK_TO:
+ ids.push(command[1]);
+ return;
+ case commands.DELETE_ALL:
+ self.dataset.delete_all = true;
+ return;
+ }
+ });
+ this._super(ids);
+ this.dataset.set_ids(ids);
+ } else if (value_.length >= 1 && typeof(value_[0]) === "object") {
+ ids = [];
+ this.dataset.delete_all = true;
+ _.each(value_, function(command) {
+ var obj = {values: command};
+ obj.id = _.uniqueId(self.dataset.virtual_id_prefix);
+ obj.defaults = {};
+ self.dataset.to_create.push(obj);
+ self.dataset.cache.push(_.clone(obj));
+ ids.push(obj.id);
+ });
+ this._super(ids);
+ this.dataset.set_ids(ids);
+ } else {
+ this._super(value_);
+ this.dataset.reset_ids(value_);
+ }
+ if (this.dataset.index === null && this.dataset.ids.length > 0) {
+ this.dataset.index = 0;
+ }
+ this.trigger_on_change();
+ if (this.is_started && !this.no_rerender) {
+ return self.reload_current_view();
+ } else {
+ return $.when();
+ }
+ },
+ get_value: function() {
+ var self = this;
+ if (!this.dataset)
+ return [];
+ var val = this.dataset.delete_all ? [commands.delete_all()] : [];
+ val = val.concat(_.map(this.dataset.ids, function(id) {
+ var alter_order = _.detect(self.dataset.to_create, function(x) {return x.id === id;});
+ if (alter_order) {
+ return commands.create(alter_order.values);
+ }
+ alter_order = _.detect(self.dataset.to_write, function(x) {return x.id === id;});
+ if (alter_order) {
+ return commands.update(alter_order.id, alter_order.values);
+ }
+ return commands.link_to(id);
+ }));
+ return val.concat(_.map(
+ this.dataset.to_delete, function(x) {
+ return commands['delete'](x.id);}));
+ },
+ commit_value: function() {
+ return this.save_any_view();
+ },
+ save_any_view: function() {
+ var view = this.get_active_view();
+ if (view) {
+ if (this.viewmanager.active_view.type === "form") {
+ if (view.controller.is_initialized.state() !== 'resolved') {
+ return $.when(false);
+ }
+ return $.when(view.controller.save());
+ } else if (this.viewmanager.active_view.type === "list") {
+ return $.when(view.controller.ensure_saved());
+ }
+ }
+ return $.when(false);
+ },
+ 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;
+ },
+});
+
+var One2ManyDataSet = data.BufferedDataSet.extend({
+ get_context: function() {
+ this.context = this.o2m.build_context();
+ return this.context;
+ }
+});
+
+var One2ManyListView = ListView.extend({
+ _template: 'One2Many.listview',
+ init: function (parent, dataset, view_id, options) {
+ this._super(parent, dataset, view_id, _.extend(options || {}, {
+ GroupsType: One2ManyGroups,
+ ListType: One2ManyList
+ }));
+ this.on('edit:after', this, this.proxy('_after_edit'));
+ this.on('save:before cancel:before', this, this.proxy('_before_unedit'));
+
+ this.records
+ .bind('add', this.proxy("changed_records"))
+ .bind('remove', this.proxy("changed_records"));
+ this.on('save:after', this, this.proxy("changed_records"));
+ },
+ start: function () {
+ var ret = this._super();
+ this.$el
+ .off('mousedown.handleButtons')
+ .on('mousedown.handleButtons', 'table button, div a.oe_m2o_cm_button', this.proxy('_button_down'));
+ return ret;
+ },
+ changed_records: function () {
+ this.o2m.trigger_on_change();
+ },
+ is_valid: function () {
+ var self = this;
+ if (!this.fields_view || !this.editable()){
+ return true;
+ }
+ if (_.isEmpty(this.records.records)){
+ return true;
+ }
+ var current_values = {};
+ _.each(this.editor.form.fields, function(field){
+ field._inhibit_on_change_flag = true;
+ field.no_rerender = true;
+ current_values[field.name] = field.get('value');
+ });
+ var valid = _.every(this.records.records, function(record){
+ _.each(self.editor.form.fields, function(field){
+ field.set_value(record.attributes[field.name]);
+ });
+ return _.every(self.editor.form.fields, function(field){
+ field.process_modifiers();
+ field._check_css_flags();
+ return field.is_valid();
+ });
+ });
+ _.each(this.editor.form.fields, function(field){
+ field.set('value', current_values[field.name]);
+ field._inhibit_on_change_flag = false;
+ field.no_rerender = false;
+ });
+ return valid;
+ },
+ do_add_record: function () {
+ if (this.editable()) {
+ this._super.apply(this, arguments);
+ } else {
+ var self = this;
+ var pop = new common.SelectCreatePopup(this);
+ pop.select_element(
+ self.o2m.field.relation,
+ {
+ title: _t("Create: ") + self.o2m.string,
+ initial_view: "form",
+ alternative_form_view: self.o2m.field.views ? self.o2m.field.views.form : undefined,
+ create_function: function(data, options) {
+ return self.o2m.dataset.create(data, options).done(function(r) {
+ self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r]));
+ self.o2m.dataset.trigger("dataset_changed", r);
+ });
+ },
+ read_function: function() {
+ return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments);
+ },
+ parent_view: self.o2m.view,
+ child_name: self.o2m.name,
+ form_view_options: {'not_interactible_on_create':true}
+ },
+ self.o2m.build_domain(),
+ self.o2m.build_context()
+ );
+ pop.on("elements_selected", self, function() {
+ self.o2m.reload_current_view();
+ });
+ }
+ },
+ do_activate_record: function(index, id) {
+ var self = this;
+ var pop = new common.FormOpenPopup(self);
+ pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), {
+ title: _t("Open: ") + self.o2m.string,
+ write_function: function(id, data) {
+ return self.o2m.dataset.write(id, data, {}).done(function() {
+ self.o2m.reload_current_view();
+ });
+ },
+ alternative_form_view: self.o2m.field.views ? self.o2m.field.views.form : undefined,
+ parent_view: self.o2m.view,
+ child_name: self.o2m.name,
+ read_function: function() {
+ return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments);
+ },
+ form_view_options: {'not_interactible_on_create':true},
+ readonly: !this.is_action_enabled('edit') || self.o2m.get("effective_readonly")
+ });
+ },
+ do_button_action: function (name, id, callback) {
+ if (!_.isNumber(id)) {
+ core.bus.trigger('display_notification_warning',
+ _t("Action Button"),
+ _t("The o2m record must be saved before an action can be used"));
+ return;
+ }
+ var parent_form = this.o2m.view;
+ var self = this;
+ this.ensure_saved().then(function () {
+ if (parent_form)
+ return parent_form.save();
+ else
+ return $.when();
+ }).done(function () {
+ var ds = self.o2m.dataset;
+ var cached_records = _.any([ds.to_create, ds.to_delete, ds.to_write], function(value) {
+ return value.length;
+ });
+ if (!self.o2m.options.reload_on_button && !cached_records) {
+ self.handle_button(name, id, callback);
+ }else {
+ self.handle_button(name, id, function(){
+ self.o2m.view.reload();
+ });
+ }
+ });
+ },
+
+ _after_edit: function () {
+ this.__ignore_blur = false;
+ this.editor.form.on('blurred', this, this._on_form_blur);
+
+ // The form's blur thing may be jiggered during the edition setup,
+ // potentially leading to the o2m 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_form_blur);
+ },
+ _button_down: function () {
+ // If a button is clicked (usually some sort of action button), it's
+ // the button's responsibility to ensure the editable list is in the
+ // correct state -> ignore form blurring
+ this.__ignore_blur = true;
+ },
+ /**
+ * Handles blurring of the nested form (saves the currently edited row),
+ * unless the flag to ignore the event is set to ``true``
+ *
+ * Makes the internal form go away
+ */
+ _on_form_blur: function () {
+ if (this.__ignore_blur) {
+ this.__ignore_blur = false;
+ return;
+ }
+ // FIXME: why isn't there an API for this?
+ if (this.editor.form.$el.hasClass('oe_form_dirty')) {
+ this.ensure_saved();
+ return;
+ }
+ this.cancel_edition();
+ },
+ keypress_ENTER: function () {
+ // blurring caused by hitting the [Return] key, should skip the
+ // autosave-on-blur and let the handler for [Return] do its thing (save
+ // the current row *anyway*, then create a new one/edit the next one)
+ this.__ignore_blur = true;
+ this._super.apply(this, arguments);
+ },
+ 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.o2m.get('effective_readonly')) {
+ this._super.apply(this, arguments);
+ }
+ }
+});
+
+var One2ManyList = AddAnItemList.extend({
+ _add_row_class: 'oe_form_field_one2many_list_row_add',
+ is_readonly: function () {
+ return this.view.o2m.get('effective_readonly');
+ },
+});
+
+var One2ManyFormView = FormView.extend({
+ form_template: 'One2Many.formview',
+ load_form: function(data) {
+ this._super(data);
+ var self = this;
+ this.$buttons.find('button.oe_form_button_create').click(function() {
+ self.save().done(self.on_button_new);
+ });
+ },
+ do_notify_change: function() {
+ if (this.dataset.parent_view) {
+ this.dataset.parent_view.do_notify_change();
+ } else {
+ this._super.apply(this, arguments);
+ }
+ }
+});
+
+var One2ManyViewManager = ViewManager.extend({
+ template: 'One2Many.viewmanager',
+ init: function(parent, dataset, views, flags) {
+ this._super(parent, dataset, views, _.extend({}, flags, {$sidebar: false}));
+ this.registry = core.view_registry.extend({
+ list: One2ManyListView,
+ form: One2ManyFormView,
+ });
+ this.__ignore_blur = false;
+ },
+ switch_mode: function(mode, unused) {
+ if (mode !== 'form') {
+ return this._super(mode, unused);
+ }
+ var self = this;
+ var id = self.o2m.dataset.index !== null ? self.o2m.dataset.ids[self.o2m.dataset.index] : null;
+ var pop = new common.FormOpenPopup(this);
+ pop.show_element(self.o2m.field.relation, id, self.o2m.build_context(), {
+ title: _t("Open: ") + self.o2m.string,
+ create_function: function(data, options) {
+ return self.o2m.dataset.create(data, options).done(function(r) {
+ self.o2m.dataset.set_ids(self.o2m.dataset.ids.concat([r]));
+ self.o2m.dataset.trigger("dataset_changed", r);
+ });
+ },
+ write_function: function(id, data, options) {
+ return self.o2m.dataset.write(id, data, {}).done(function() {
+ self.o2m.reload_current_view();
+ });
+ },
+ alternative_form_view: self.o2m.field.views ? self.o2m.field.views.form : undefined,
+ parent_view: self.o2m.view,
+ child_name: self.o2m.name,
+ read_function: function() {
+ return self.o2m.dataset.read_ids.apply(self.o2m.dataset, arguments);
+ },
+ form_view_options: {'not_interactible_on_create':true},
+ readonly: self.o2m.get("effective_readonly")
+ });
+ pop.on("elements_selected", self, function() {
+ self.o2m.reload_current_view();
+ });
+ },
+});
+
+var FieldMany2ManyTags = common.AbstractField.extend(common.CompletionFieldMixin, common.ReinitializeFieldMixin, {
+ template: "FieldMany2ManyTags",
+ tag_template: "FieldMany2ManyTag",
+ init: function() {
+ this._super.apply(this, arguments);
+ common.CompletionFieldMixin.init.call(this);
+ this.set({"value": []});
+ this._display_orderer = new utils.DropMisordered();
+ this._drop_shown = false;
+ },
+ initialize_texttext: function(){
+ var self = this;
+ return {
+ plugins : 'tags arrow autocomplete',
+ autocomplete: {
+ render: function(suggestion) {
+ return $('<span class="text-label"/>').
+ data('index', suggestion.index).html(suggestion.label);
+ }
+ },
+ ext: {
+ autocomplete: {
+ selectFromDropdown: function() {
+ this.trigger('hideDropdown');
+ var index = Number(this.selectedSuggestionElement().children().children().data('index'));
+ var data = self.search_result[index];
+ if (data.id) {
+ self.add_id(data.id);
+ } else {
+ self.ignore_blur = true;
+ data.action();
+ }
+ this.trigger('setSuggestions', {result : []});
+ },
+ },
+ tags: {
+ isTagAllowed: function(tag) {
+ return !!tag.name;
+
+ },
+ removeTag: function(tag) {
+ var id = tag.data("id");
+ self.set({"value": _.without(self.get("value"), id)});
+ },
+ renderTag: function(stuff) {
+ return $.fn.textext.TextExtTags.prototype.renderTag.
+ call(this, stuff).data("id", stuff.id);
+ },
+ },
+ itemManager: {
+ itemToString: function(item) {
+ return item.name;
+ },
+ },
+ core: {
+ onSetInputData: function(e, data) {
+ if (data === '') {
+ this._plugins.autocomplete._suggestions = null;
+ }
+ this.input().val(data);
+ },
+ },
+ },
+ };
+ },
+ initialize_content: function() {
+ if (this.get("effective_readonly"))
+ return;
+ var self = this;
+ self.ignore_blur = false;
+ self.$text = this.$("textarea");
+ self.$text.textext(self.initialize_texttext()).bind('getSuggestions', function(e, data) {
+ var _this = this;
+ var str = !!data ? data.query || '' : '';
+ self.get_search_result(str).done(function(result) {
+ self.search_result = result;
+ $(_this).trigger('setSuggestions', {result : _.map(result, function(el, i) {
+ return _.extend(el, {index:i});
+ })});
+ });
+ }).bind('hideDropdown', function() {
+ self._drop_shown = false;
+ }).bind('showDropdown', function() {
+ self._drop_shown = true;
+ });
+ self.tags = self.$text.textext()[0].tags();
+ self.$text
+ .focusin(function () {
+ self.trigger('focused');
+ self.ignore_blur = false;
+ })
+ .focusout(function() {
+ self.$text.trigger("setInputData", "");
+ if (!self.ignore_blur) {
+ self.trigger('blurred');
+ }
+ }).keydown(function(e) {
+ if (e.which === $.ui.keyCode.TAB && self._drop_shown) {
+ self.$text.textext()[0].autocomplete().selectFromDropdown();
+ }
+ });
+ },
+ // WARNING: duplicated in 4 other M2M widgets
+ set_value: function(value_) {
+ value_ = value_ || [];
+ if (value_.length >= 1 && value_[0] instanceof Array) {
+ // value_ is a list of m2m commands. We only process
+ // LINK_TO and REPLACE_WITH in this context
+ var val = [];
+ _.each(value_, function (command) {
+ if (command[0] === commands.LINK_TO) {
+ val.push(command[1]); // (4, id[, _])
+ } else if (command[0] === commands.REPLACE_WITH) {
+ val = command[2]; // (6, _, ids)
+ }
+ });
+ value_ = val;
+ }
+ this._super(value_);
+ },
+ is_false: function() {
+ return _(this.get("value")).isEmpty();
+ },
+ get_value: function() {
+ var tmp = [commands.replace_with(this.get("value"))];
+ return tmp;
+ },
+ get_search_blacklist: function() {
+ return this.get("value");
+ },
+ map_tag: function(data){
+ return _.map(data, function(el) {return {name: el[1], id:el[0]};});
+ },
+ get_render_data: function(ids){
+ var self = this;
+ var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context());
+ return dataset.name_get(ids);
+ },
+ render_tag: function(data) {
+ var self = this;
+ if (! self.get("effective_readonly")) {
+ self.tags.containerElement().children().remove();
+ self.$('textarea').css("padding-left", "3px");
+ self.tags.addTags(self.map_tag(data));
+ } else {
+ self.$el.html(QWeb.render(self.tag_template, {elements: data}));
+ }
+ },
+ render_value: function() {
+ var self = this;
+ var dataset = new data.DataSetStatic(this, this.field.relation, self.build_context());
+ var values = self.get("value");
+ var handle_names = function(data) {
+ if (self.isDestroyed())
+ return;
+ var indexed = {};
+ _.each(data, function(el) {
+ indexed[el[0]] = el;
+ });
+ data = _.map(values, function(el) { return indexed[el]; });
+ self.render_tag(data);
+ };
+ if (! values || values.length > 0) {
+ return this._display_orderer.add(self.get_render_data(values)).done(handle_names);
+ } else {
+ handle_names([]);
+ }
+ },
+ add_id: function(id) {
+ this.set({'value': _.uniq(this.get('value').concat([id]))});
+ },
+ focus: function () {
+ var input = this.$text && this.$text[0];
+ return input ? input.focus() : false;
+ },
+ set_dimensions: function (height, width) {
+ this._super(height, width);
+ this.$("textarea").css({
+ width: width,
+ minHeight: height
+ });
+ },
+ _search_create_popup: function() {
+ this.ignore_blur = true;
+ return common.CompletionFieldMixin._search_create_popup.apply(this, arguments);
+ },
+});
+
+/**
+ widget options:
+ - reload_on_button: Reload the whole form view if click on a button in a list view.
+ If you see this options, do not use it, it's basically a dirty hack to make one
+ precise o2m to behave the way we want.
+*/
+var FieldMany2Many = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ multi_selection: false,
+ disable_utility_classes: true,
+ init: function(field_manager, node) {
+ this._super(field_manager, node);
+ this.is_loaded = $.Deferred();
+ this.dataset = new Many2ManyDataSet(this, this.field.relation);
+ this.dataset.m2m = this;
+ var self = this;
+ this.dataset.on('unlink', self, function(ids) {
+ self.dataset_changed();
+ });
+ this.set_value([]);
+ this.list_dm = new utils.DropMisordered();
+ this.render_value_dm = new utils.DropMisordered();
+ },
+ initialize_content: function() {
+ var self = this;
+
+ this.$el.addClass('oe_form_field oe_form_field_many2many');
+
+ this.list_view = new Many2ManyListView(this, this.dataset, false, {
+ 'addable': null,
+ 'deletable': this.get("effective_readonly") ? false : true,
+ 'selectable': this.multi_selection,
+ 'sortable': false,
+ 'reorderable': false,
+ 'import_enabled': false,
+ });
+ var embedded = (this.field.views || {}).tree;
+ if (embedded) {
+ this.list_view.set_embedded_view(embedded);
+ }
+ this.list_view.m2m_field = this;
+ var loaded = $.Deferred();
+ this.list_view.on("list_view_loaded", this, function() {
+ loaded.resolve();
+ });
+ this.list_view.appendTo(this.$el);
+
+ var old_def = self.is_loaded;
+ self.is_loaded = $.Deferred().done(function() {
+ old_def.resolve();
+ });
+ this.list_dm.add(loaded).then(function() {
+ self.is_loaded.resolve();
+ });
+ },
+ destroy_content: function() {
+ this.list_view.destroy();
+ this.list_view = undefined;
+ },
+ // WARNING: duplicated in 4 other M2M widgets
+ set_value: function(value_) {
+ value_ = value_ || [];
+ if (value_.length >= 1 && value_[0] instanceof Array) {
+ // value_ is a list of m2m commands. We only process
+ // LINK_TO and REPLACE_WITH in this context
+ var val = [];
+ _.each(value_, function (command) {
+ if (command[0] === commands.LINK_TO) {
+ val.push(command[1]); // (4, id[, _])
+ } else if (command[0] === commands.REPLACE_WITH) {
+ val = command[2]; // (6, _, ids)
+ }
+ });
+ value_ = val;
+ }
+ this._super(value_);
+ },
+ get_value: function() {
+ return [commands.replace_with(this.get('value'))];
+ },
+ is_false: function () {
+ return _(this.get("value")).isEmpty();
+ },
+ render_value: function() {
+ var self = this;
+ this.dataset.set_ids(this.get("value"));
+ this.render_value_dm.add(this.is_loaded).then(function() {
+ return self.list_view.reload_content();
+ });
+ },
+ dataset_changed: function() {
+ this.internal_set_value(this.dataset.ids);
+ },
+});
+
+var Many2ManyDataSet = data.DataSetStatic.extend({
+ get_context: function() {
+ this.context = this.m2m.build_context();
+ return this.context;
+ }
+});
+
+/**
+ * @class
+ * @extends instance.web.ListView
+ */
+var Many2ManyListView = ListView.extend(/** @lends instance.web.form.Many2ManyListView# */{
+ init: function (parent, dataset, view_id, options) {
+ this._super(parent, dataset, view_id, _.extend(options || {}, {
+ ListType: Many2ManyList,
+ }));
+ },
+ do_add_record: function () {
+ var pop = new common.SelectCreatePopup(this);
+ pop.select_element(
+ this.model,
+ {
+ title: _t("Add: ") + this.m2m_field.string,
+ alternative_form_view: this.m2m_field.field.views ? this.m2m_field.field.views.form : undefined,
+ no_create: this.m2m_field.options.no_create,
+ },
+ new data.CompoundDomain(this.m2m_field.build_domain(), ["!", ["id", "in", this.m2m_field.dataset.ids]]),
+ this.m2m_field.build_context()
+ );
+ var self = this;
+ pop.on("elements_selected", self, function(element_ids) {
+ var reload = false;
+ _(element_ids).each(function (id) {
+ if(! _.detect(self.dataset.ids, function(x) {return x == id;})) {
+ self.dataset.set_ids(self.dataset.ids.concat([id]));
+ self.m2m_field.dataset_changed();
+ reload = true;
+ }
+ });
+ if (reload) {
+ self.reload_content();
+ }
+ });
+ },
+ do_activate_record: function(index, id) {
+ var self = this;
+ var pop = new common.FormOpenPopup(this);
+ pop.show_element(this.dataset.model, id, this.m2m_field.build_context(), {
+ title: _t("Open: ") + this.m2m_field.string,
+ alternative_form_view: this.m2m_field.field.views ? this.m2m_field.field.views.form : undefined,
+ readonly: this.getParent().get("effective_readonly")
+ });
+ pop.on('write_completed', self, self.reload_content);
+ },
+ do_button_action: function(name, id, callback) {
+ var self = this;
+ var _sup = _.bind(this._super, this);
+ if (! this.m2m_field.options.reload_on_button) {
+ return _sup(name, id, callback);
+ } else {
+ return this.m2m_field.view.save().then(function() {
+ return _sup(name, id, function() {
+ self.m2m_field.view.reload();
+ });
+ });
+ }
+ },
+ is_action_enabled: function () { return true; },
+});
+var Many2ManyList = AddAnItemList.extend({
+ _add_row_class: 'oe_form_field_many2many_list_row_add',
+ is_readonly: function () {
+ return this.view.m2m_field.get('effective_readonly');
+ }
+});
+
+var FieldMany2ManyKanban = common.AbstractField.extend(common.CompletionFieldMixin, {
+ disable_utility_classes: true,
+ init: function(field_manager, node) {
+ this._super(field_manager, node);
+ common.CompletionFieldMixin.init.call(this);
+ this.is_loaded = $.Deferred();
+ this.initial_is_loaded = this.is_loaded;
+
+ var self = this;
+ this.dataset = new Many2ManyDataSet(this, this.field.relation);
+ this.dataset.m2m = this;
+ this.dataset.on('unlink', self, function(ids) {
+ self.dataset_changed();
+ });
+ },
+ start: function() {
+ this._super.apply(this, arguments);
+
+ var self = this;
+
+ self.load_view();
+ self.on("change:effective_readonly", self, function() {
+ self.is_loaded = self.is_loaded.then(function() {
+ self.kanban_view.destroy();
+ return $.when(self.load_view()).done(function() {
+ self.render_value();
+ });
+ });
+ });
+ },
+ // WARNING: duplicated in 4 other M2M widgets
+ set_value: function(value_) {
+ value_ = value_ || [];
+ if (value_.length >= 1 && value_[0] instanceof Array) {
+ // value_ is a list of m2m commands. We only process
+ // LINK_TO and REPLACE_WITH in this context
+ var val = [];
+ _.each(value_, function (command) {
+ if (command[0] === commands.LINK_TO) {
+ val.push(command[1]); // (4, id[, _])
+ } else if (command[0] === commands.REPLACE_WITH) {
+ val = command[2]; // (6, _, ids)
+ }
+ });
+ value_ = val;
+ }
+ this._super(value_);
+ },
+ get_value: function() {
+ return [commands.replace_with(this.get('value'))];
+ },
+ load_view: function() {
+ var self = this;
+ var Many2ManyKanbanView = core.view_registry.get('many2many_kanban');
+ this.kanban_view = new Many2ManyKanbanView(this, this.dataset, false, {
+ 'create_text': _t("Add"),
+ 'creatable': self.get("effective_readonly") ? false : true,
+ 'quick_creatable': self.get("effective_readonly") ? false : true,
+ 'read_only_mode': self.get("effective_readonly") ? true : false,
+ 'confirm_on_delete': false,
+ });
+ var embedded = (this.field.views || {}).kanban;
+ if (embedded) {
+ this.kanban_view.set_embedded_view(embedded);
+ }
+ this.kanban_view.m2m = this;
+ var loaded = $.Deferred();
+ this.kanban_view.on("kanban_view_loaded",self,function() {
+ self.initial_is_loaded.resolve();
+ loaded.resolve();
+ });
+ this.kanban_view.on('switch_mode', this, this.open_popup);
+ $.async_when().done(function () {
+ self.kanban_view.appendTo(self.$el);
+ });
+ return loaded;
+ },
+ render_value: function() {
+ var self = this;
+ this.dataset.set_ids(this.get("value"));
+ this.is_loaded = this.is_loaded.then(function() {
+ return self.kanban_view.do_search(self.build_domain(), self.dataset.get_context(), []);
+ });
+ },
+ dataset_changed: function() {
+ this.set({'value': this.dataset.ids});
+ },
+ open_popup: function(type, unused) {
+ if (type !== "form")
+ return;
+ var self = this;
+ var pop;
+ if (this.dataset.index === null) {
+ pop = new common.SelectCreatePopup(this);
+ pop.select_element(
+ this.field.relation,
+ {
+ title: _t("Add: ") + this.string
+ },
+ new data.CompoundDomain(this.build_domain(), ["!", ["id", "in", this.dataset.ids]]),
+ this.build_context()
+ );
+ pop.on("elements_selected", self, function(element_ids) {
+ _.each(element_ids, function(one_id) {
+ if(! _.detect(self.dataset.ids, function(x) {return x == one_id;})) {
+ self.dataset.set_ids([].concat(self.dataset.ids, [one_id]));
+ self.dataset_changed();
+ self.render_value();
+ }
+ });
+ });
+ } else {
+ var id = self.dataset.ids[self.dataset.index];
+ pop = new common.FormOpenPopup(this);
+ pop.show_element(self.field.relation, id, self.build_context(), {
+ title: _t("Open: ") + self.string,
+ write_function: function(id, data, options) {
+ return self.dataset.write(id, data, {}).done(function() {
+ self.render_value();
+ });
+ },
+ alternative_form_view: self.field.views ? self.field.views.form : undefined,
+ parent_view: self.view,
+ child_name: self.name,
+ readonly: self.get("effective_readonly")
+ });
+ }
+ },
+ add_id: function(id) {
+ this.quick_create.add_id(id);
+ },
+});
+
+/**
+ * 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 = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ template: "FieldBinaryFileUploader",
+ init: function(field_manager, node) {
+ this._super(field_manager, node);
+ this.field_manager = field_manager;
+ this.node = node;
+ 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.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));
+ },
+ initialize_content: function() {
+ this.$el.on('change', 'input.oe_form_binary_file', this.on_file_change );
+ },
+ // WARNING: duplicated in 4 other M2M widgets
+ set_value: function(value_) {
+ value_ = value_ || [];
+ if (value_.length >= 1 && value_[0] instanceof Array) {
+ // value_ is a list of m2m commands. We only process
+ // LINK_TO and REPLACE_WITH in this context
+ var val = [];
+ _.each(value_, function (command) {
+ if (command[0] === commands.LINK_TO) {
+ val.push(command[1]); // (4, id[, _])
+ } else if (command[0] === commands.REPLACE_WITH) {
+ val = command[2]; // (6, _, ids)
+ }
+ });
+ value_ = val;
+ }
+ this._super(value_);
+ },
+ get_value: function() {
+ return [commands.replace_with(this.get("value"))];
+ },
+ get_file_url: function (attachment) {
+ return this.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'datas_fname', id: attachment.id});
+ },
+ read_name_values : function () {
+ var self = this;
+ // don't reset know values
+ var ids = this.get('value');
+ var _value = _.filter(ids, function (id) { return typeof self.data[id] == 'undefined'; } );
+ // send request for get_name
+ if (_value.length) {
+ return this.ds_file.call('read', [_value, ['id', 'name', 'datas_fname']]).then(function (datas) {
+ _.each(datas, function (data) {
+ data.no_unlink = true;
+ data.url = self.session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'datas_fname', id: data.id});
+ self.data[data.id] = data;
+ });
+ return ids;
+ });
+ } else {
+ return $.when(ids);
+ }
+ },
+ render_value: function () {
+ var self = this;
+ this.read_name_values().then(function (ids) {
+ var render = $(QWeb.render('FieldBinaryFileUploader.files', {'widget': self, 'values': ids}));
+ render.on('click', '.oe_delete', _.bind(self.on_file_delete, self));
+ self.$('.oe_placeholder_files, .oe_attachments').replaceWith( render );
+
+ // reinit input type file
+ var $input = self.$('input.oe_form_binary_file');
+ $input.after($input.clone(true)).remove();
+ self.$(".oe_fileupload").show();
+
+ });
+ },
+ on_file_change: function (event) {
+ event.stopPropagation();
+ var self = this;
+ var $target = $(event.target);
+ if ($target.val() !== '') {
+ var filename = $target.val().replace(/.*[\\\/]/,'');
+ // don't uplode more of one file in same time
+ if (self.data[0] && self.data[0].upload ) {
+ return false;
+ }
+ for (var id in this.get('value')) {
+ // if the files exits, delete the file before upload (if it's a new file)
+ if (self.data[id] && (self.data[id].filename || self.data[id].name) == filename && !self.data[id].no_unlink ) {
+ self.ds_file.unlink([id]);
+ }
+ }
+
+ // block UI or not
+ if(this.node.attrs.blockui>0) {
+ framework.blockUI();
+ }
+
+ // TODO : unactivate send on wizard and form
+
+ // submit file
+ this.$('form.oe_form_binary_form').submit();
+ this.$(".oe_fileupload").hide();
+ // add file on data result
+ this.data[0] = {
+ 'id': 0,
+ 'name': filename,
+ 'filename': filename,
+ 'url': '',
+ 'upload': true
+ };
+ }
+ },
+ on_file_loaded: function (event, result) {
+ var files = this.get('value');
+
+ // unblock UI
+ if(this.node.attrs.blockui>0) {
+ framework.unblockUI();
+ }
+
+ if (result.error || !result.id ) {
+ this.do_warn( _t('Uploading Error'), result.error);
+ delete this.data[0];
+ } else {
+ if (this.data[0] && this.data[0].filename == result.filename && this.data[0].upload) {
+ delete this.data[0];
+ this.data[result.id] = {
+ 'id': result.id,
+ 'name': result.name,
+ 'filename': result.filename,
+ 'url': this.get_file_url(result)
+ };
+ } else {
+ this.data[result.id] = {
+ 'id': result.id,
+ 'name': result.name,
+ 'filename': result.filename,
+ 'url': this.get_file_url(result)
+ };
+ }
+ var values = _.clone(this.get('value'));
+ values.push(result.id);
+ this.set({'value': values});
+ }
+ this.render_value();
+ },
+ on_file_delete: function (event) {
+ event.stopPropagation();
+ var file_id=$(event.target).data("id");
+ if (file_id) {
+ var files = _.filter(this.get('value'), function (id) {return id != file_id;});
+ if(!this.data[file_id].no_unlink) {
+ this.ds_file.unlink([file_id]);
+ }
+ this.set({'value': files});
+ }
+ },
+});
+
+/*
+ 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 = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ className: "oe_form_many2many_checkboxes",
+ init: function() {
+ this._super.apply(this, arguments);
+ this.set("value", {});
+ 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.$().html(QWeb.render("FieldMany2ManyCheckBoxes", {widget: this, selected: this.get("value")}));
+ 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").each(function() {
+ var elem = $(this);
+ new_value[elem.data("record-id")] = elem.is(":checked") ? true : undefined;
+ });
+ if (! _.isEqual(new_value, this.get("value")))
+ this.internal_set_value(new_value);
+ },
+ // WARNING: (mostly) duplicated in 4 other M2M widgets
+ set_value: function(value_) {
+ value_ = value_ || [];
+ if (value_.length >= 1 && value_[0] instanceof Array) {
+ // value_ is a list of m2m commands. We only process
+ // LINK_TO and REPLACE_WITH in this context
+ var val = [];
+ _.each(value_, function (command) {
+ if (command[0] === commands.LINK_TO) {
+ val.push(command[1]); // (4, id[, _])
+ } else if (command[0] === commands.REPLACE_WITH) {
+ val = command[2]; // (6, _, ids)
+ }
+ });
+ value_ = val;
+ }
+ var formatted = {};
+ _.each(value_, function(el) {
+ formatted[JSON.stringify(el)] = true;
+ });
+ this._super(formatted);
+ },
+ get_value: function() {
+ var value = _.filter(_.keys(this.get("value")), function(el) {
+ return this.get("value")[el];
+ }, this);
+ value = _.map(value, function(el) {
+ return JSON.parse(el);
+ });
+ return [commands.replace_with(value)];
+ },
+});
+
+/**
+ This field can be applied on many2many and one2many. It is a read-only field that will display a single link whose name is
+ "<number of linked records> <label of the field>". When the link is clicked, it will redirect to another act_window
+ action on the model of the relation and show only the linked records.
+
+ Widget options:
+
+ * views: The views to display in the act_window action. Must be a list of tuples whose first element is the id of the view
+ to display (or False to take the default one) and the second element is the type of the view. Defaults to
+ [[false, "tree"], [false, "form"]] .
+*/
+var X2ManyCounter = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ className: "oe_form_x2many_counter",
+ init: function() {
+ this._super.apply(this, arguments);
+ this.set("value", []);
+ _.defaults(this.options, {
+ "views": [[false, "tree"], [false, "form"]],
+ });
+ },
+ render_value: function() {
+ var text = _.str.sprintf("%d %s", this.val().length, this.string);
+ this.$().html(QWeb.render("X2ManyCounter", {text: text}));
+ this.$("a").click(_.bind(this.go_to, this));
+ },
+ go_to: function() {
+ return this.view.recursive_save().then(_.bind(function() {
+ var val = this.val();
+ var context = {};
+ if (this.field.type === "one2many") {
+ context["default_" + this.field.relation_field] = this.view.datarecord.id;
+ }
+ var domain = [["id", "in", val]];
+ return this.do_action({
+ type: 'ir.actions.act_window',
+ name: this.string,
+ res_model: this.field.relation,
+ views: this.options.views,
+ target: 'current',
+ context: context,
+ domain: domain,
+ });
+ }, this));
+ },
+ val: function() {
+ var value = this.get("value") || [];
+ if (value.length >= 1 && value[0] instanceof Array) {
+ value = value[0][2];
+ }
+ return value;
+ }
+});
+
+
+
+core.form_widget_registry
+ .add('many2one', FieldMany2One)
+ .add('many2onebutton', Many2OneButton)
+ .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)
+ .add('x2many_counter', X2ManyCounter);
+
+return {
+ FieldMany2ManyTags: FieldMany2ManyTags,
+};
+
+});
+
+odoo.define('web_kanban.Many2ManyKanbanView', ['web.core', 'web_kanban.KanbanView', 'web.Widget'], function (require) {
+ "use strict";
+ // This code has a dependency on the addon web_kanban. This is a weird dependency issue. To fix it,
+ // we should either move this code into web_kanban, or move web_kanban into the web client.
+
+ var core = require('web.core'),
+ KanbanView = require('web_kanban.KanbanView'),
+ Widget = require('web.Widget');
+
+ var Many2ManyQuickCreate = Widget.extend({
+ template: 'Many2ManyKanban.quick_create',
+
+ /**
+ * close_btn: If true, the widget will display a "Close" button able to trigger
+ * a "close" event.
+ */
+ init: function(parent, dataset, context, buttons) {
+ this._super(parent);
+ this.m2m = this.getParent().view.m2m;
+ this.m2m.quick_create = this;
+ this._dataset = dataset;
+ this._buttons = buttons || false;
+ this._context = context || {};
+ },
+ start: function () {
+ var self = this;
+ self.$text = this.$el.find('input').css("width", "200px");
+ self.$text.textext({
+ plugins : 'arrow autocomplete',
+ autocomplete: {
+ render: function(suggestion) {
+ return $('<span class="text-label"/>').
+ data('index', suggestion.index).html(suggestion.label);
+ }
+ },
+ ext: {
+ autocomplete: {
+ selectFromDropdown: function() {
+ $(this).trigger('hideDropdown');
+ var index = Number(this.selectedSuggestionElement().children().children().data('index'));
+ var data = self.search_result[index];
+ if (data.id) {
+ self.add_id(data.id);
+ } else {
+ data.action();
+ }
+ },
+ },
+ itemManager: {
+ itemToString: function(item) {
+ return item.name;
+ },
+ },
+ },
+ }).bind('getSuggestions', function(e, data) {
+ var _this = this;
+ var str = !!data ? data.query || '' : '';
+ self.m2m.get_search_result(str).done(function(result) {
+ self.search_result = result;
+ $(_this).trigger('setSuggestions', {result : _.map(result, function(el, i) {
+ return _.extend(el, {index:i});
+ })});
+ });
+ });
+ self.$text.focusout(function() {
+ self.$text.val("");
+ });
+ },
+ focus: function() {
+ this.$text[0].focus();
+ },
+ add_id: function(id) {
+ var self = this;
+ self.$text.val("");
+ self.trigger('added', id);
+ this.m2m.dataset_changed();
+ },
+ });
+
+ var Many2ManyKanbanView = KanbanView.extend({
+ get_quick_create_class: function () {
+ return Many2ManyQuickCreate;
+ },
+ _is_quick_create_enabled: function() {
+ return this._super() && ! this.group_by;
+ },
+ });
+
+ core.view_registry.add('many2many_kanban', Many2ManyKanbanView);
+
+});
diff --git a/addons/web/static/src/js/views/form_view.js b/addons/web/static/src/js/views/form_view.js
new file mode 100644
index 00000000000..ac7ea1d7888
--- /dev/null
+++ b/addons/web/static/src/js/views/form_view.js
@@ -0,0 +1,1587 @@
+odoo.define('web.FormView', ['web.core', 'web.crash_manager', 'web.data', 'web.Dialog', 'web.form_common', 'web.Model', 'web.Sidebar', 'web.utils', 'web.View'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var data = require('web.data');
+var Dialog = require('web.Dialog');
+var common = require('web.form_common');
+var Model = require('web.Model');
+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;
+var Class = core.Class;
+
+/** @namespace */
+// instance.web.form = instance.web.form || {};
+
+/**
+ * Properties:
+ * - actual_mode: always "view", "edit" or "create". Read-only property. Determines
+ * the mode used by the view.
+ */
+var FormView = View.extend(common.FieldManagerMixin, {
+ /**
+ * Indicates that this view is not searchable, and thus that no search
+ * view should be displayed (if there is one active).
+ */
+ searchable: false,
+ template: "FormView",
+ display_name: _lt('Form'),
+ view_type: "form",
+ /**
+ * @constructs instance.web.FormView
+ * @extends instance.web.View
+ *
+ * @param {instance.web.Session} session the current openerp session
+ * @param {instance.web.DataSet} dataset the dataset this view will work with
+ * @param {String} view_id the identifier of the OpenERP view object
+ *
+ * @property {instance.web.Registry} registry=instance.web.form.widgets widgets registry for this form view instance
+ */
+ init: function(parent, dataset, view_id, options) {
+ var self = this;
+ this._super(parent);
+ this.ViewManager = parent;
+ this.set_default_options(options);
+ this.dataset = dataset;
+ this.model = dataset.model;
+ this.view_id = view_id || false;
+ this.fields_view = {};
+ this.fields = {};
+ this.fields_order = [];
+ this.datarecord = {};
+ this._onchange_specs = {};
+ 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 = [];
+ _.defaults(this.options, {
+ "not_interactible_on_create": false,
+ "initial_mode": "view",
+ "disable_autofocus": false,
+ "footer_to_buttons": false,
+ });
+ this.is_initialized = $.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);
+ self.set({actual_mode: self.options.initial_mode});
+ this.has_been_loaded.done(function() {
+ self._build_onchange_specs();
+ self.on("change:actual_mode", self, self.check_actual_mode);
+ self.check_actual_mode();
+ self.on("change:actual_mode", self, self.init_pager);
+ self.init_pager();
+ });
+ self.on("load_record", self, self.load_record);
+ core.bus.on('clear_uncommitted_changes', this, function(e) {
+ if (!this.can_be_discarded()) {
+ e.preventDefault();
+ }
+ });
+ },
+ view_loading: function(r) {
+ return this.load_form(r);
+ },
+ destroy: function() {
+ _.each(this.get_widgets(), function(w) {
+ w.off('focused blurred');
+ w.destroy();
+ });
+ if (this.$el) {
+ this.$el.off('.formBlur');
+ }
+ this._super();
+ },
+ load_form: function(data) {
+ var self = this;
+ if (!data) {
+ throw new Error(_t("No data provided."));
+ }
+ if (this.arch) {
+ throw "Form view does not support multiple calls to load_form";
+ }
+ this.fields_order = [];
+ this.fields_view = data;
+
+ 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(data);
+ var $dest = this.$el.hasClass("oe_form_container") ? this.$el : this.$el.find('.oe_form_container');
+ this.rendering_engine.render_to($dest);
+
+ this.$el.on('mousedown.formBlur', function () {
+ self.__clicked_inside = true;
+ });
+
+ this.$buttons = $(QWeb.render("FormView.buttons", {'widget':self}));
+ if (this.options.$buttons) {
+ this.$buttons.appendTo(this.options.$buttons);
+ } else {
+ this.$el.find('.oe_form_buttons').replaceWith(this.$buttons);
+ }
+ this.$buttons.on('click', '.oe_form_button_create',
+ this.guard_active(this.on_button_create));
+ this.$buttons.on('click', '.oe_form_button_edit',
+ this.guard_active(this.on_button_edit));
+ this.$buttons.on('click', '.oe_form_button_save',
+ this.guard_active(this.on_button_save));
+ this.$buttons.on('click', '.oe_form_button_cancel',
+ this.guard_active(this.on_button_cancel));
+ if (this.options.footer_to_buttons) {
+ this.$el.find('footer').appendTo(this.$buttons);
+ }
+
+ this.$sidebar = this.options.$sidebar || this.$el.find('.oe_form_sidebar');
+ if (!this.sidebar && this.options.$sidebar) {
+ this.sidebar = new Sidebar(this);
+ this.sidebar.appendTo(this.$sidebar);
+ if (this.fields_view.toolbar) {
+ this.sidebar.add_toolbar(this.fields_view.toolbar);
+ }
+ this.sidebar.add_items('other', _.compact([
+ self.is_action_enabled('delete') && { label: _t('Delete'), callback: self.on_button_delete },
+ self.is_action_enabled('create') && { label: _t('Duplicate'), callback: self.on_button_duplicate }
+ ]));
+ }
+
+ this.has_been_loaded.resolve();
+
+ // Add bounce effect on button 'Edit' when click on readonly page view.
+ this.$el.find(".oe_form_group_row,.oe_form_field,label,h1,.oe_title,.oe_notebook_page, .oe_list_content").on('click', function (e) {
+ if(self.get("actual_mode") == "view") {
+ var $button = self.options.$buttons.find(".oe_form_button_edit");
+ $button.openerpBounce();
+ e.stopPropagation();
+ core.bus.trigger('click', e);
+ }
+ });
+ //bounce effect on red button when click on statusbar.
+ this.$el.find(".oe_form_field_status:not(.oe_form_status_clickable)").on('click', function (e) {
+ if((self.get("actual_mode") == "view")) {
+ var $button = self.$el.find(".oe_highlight:not(.oe_form_invisible)").css({'float':'left','clear':'none'});
+ $button.openerpBounce();
+ e.stopPropagation();
+ }
+ });
+ this.trigger('form_view_loaded', data);
+ return $.when();
+ },
+ 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 {Object} [options]
+ * @param {Boolean} [mode=undefined] If specified, switch the form to specified mode. Can be "edit" or "view".
+ * @param {Boolean} [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 || {};
+ if (this.sidebar) {
+ this.sidebar.$el.show();
+ }
+ if (this.$buttons) {
+ this.$buttons.show();
+ }
+ this.$el.show().css({
+ opacity: '0',
+ filter: 'alpha(opacity = 0)'
+ });
+ this.$el.add(this.$buttons).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');
+ return self.dataset.read_index(fields, {
+ context: { 'bin_size': true, 'future_display_name' : 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);
+ self.$el.css({
+ opacity: '1',
+ filter: 'alpha(opacity = 100)'
+ });
+ core.bus.trigger('form_view_shown', self);
+ });
+ },
+ do_hide: function () {
+ if (this.sidebar) {
+ this.sidebar.$el.hide();
+ }
+ if (this.$pager) {
+ this.$pager.hide();
+ }
+ this._super();
+ },
+ 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._actualize_mode();
+ this.set({ 'title' : record.id ? record.display_name : _t("New") });
+
+ _(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);
+ });
+ return $.when.apply(null, set_values).then(function() {
+ if (!record.id) {
+ // trigger onchanges
+ self.do_onchange(null);
+ }
+ self.on_form_changed();
+ self.rendering_engine.init_fields();
+ self.is_initialized.resolve();
+ self.do_update_pager(record.id === null || record.id === undefined);
+ 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.add(self.$buttons).removeClass('oe_form_dirty');
+ self.autofocus();
+ });
+ },
+ /**
+ * 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', r);
+ });
+ }
+ return self.trigger('load_record', {});
+ },
+ on_form_changed: function() {
+ this.trigger("view_content_has_changed");
+ },
+ do_notify_change: function() {
+ this.$el.add(this.$buttons).addClass('oe_form_dirty');
+ },
+ execute_pager_action: function(action) {
+ if (this.can_be_discarded()) {
+ switch (action) {
+ case 'first':
+ this.dataset.index = 0;
+ break;
+ case 'previous':
+ this.dataset.previous();
+ break;
+ case 'next':
+ this.dataset.next();
+ break;
+ case 'last':
+ this.dataset.index = this.dataset.ids.length - 1;
+ break;
+ }
+ var def = this.reload();
+ this.trigger('pager_action_executed');
+ return def;
+ }
+ return $.when();
+ },
+ init_pager: function() {
+ var self = this;
+ if (this.$pager)
+ this.$pager.remove();
+ if (this.get("actual_mode") === "create")
+ return;
+ this.$pager = $(QWeb.render("FormView.pager", {'widget':self}));
+ if (this.options.$pager) {
+ this.$pager.appendTo(this.options.$pager);
+ } else {
+ this.$el.find('.oe_form_pager').replaceWith(this.$pager);
+ }
+ this.$pager.on('click','a[data-pager-action]',function() {
+ var $el = $(this);
+ if ($el.attr("disabled"))
+ return;
+ var action = $el.data('pager-action');
+ var def = $.when(self.execute_pager_action(action));
+ $el.attr("disabled");
+ def.always(function() {
+ $el.removeAttr("disabled");
+ });
+ });
+ this.do_update_pager();
+ },
+ do_update_pager: function(hide_index) {
+ this.$pager.toggle(this.dataset.ids.length > 1);
+ if (hide_index) {
+ $(".oe_form_pager_state", this.$pager).html("");
+ } else {
+ $(".oe_form_pager_state", this.$pager).html(_.str.sprintf(_t("%d / %d"), this.dataset.index + 1, this.dataset.ids.length));
+ }
+ },
+
+ _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);
+ _.each(field.field.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;
+ 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(new Model(self.dataset.model).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;
+ });
+
+ if (!_.isEmpty(result.value)) {
+ this._internal_set_values(result.value);
+ }
+ // FIXME XXX a list of warnings?
+ if (!_.isEmpty(result.warning)) {
+ new Dialog(this, {
+ size: 'medium',
+ title:result.warning.title,
+ buttons: [
+ {text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }}
+ ]
+ }, QWeb.render("CrashManager.warning", result.warning)).open();
+ }
+
+ return $.Deferred().resolve();
+ } 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 iterate() {
+
+ var mutex = new utils.Mutex();
+ _.each(self.fields, function(field) {
+ self.onchanges_mutex.def.then(function(){
+ mutex.exec(function(){
+ return field.commit_value();
+ });
+ });
+ });
+
+ var args = _.toArray(arguments);
+ return $.when.apply(null, [mutex.def, self.onchanges_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");
+ },
+ /**
+ * 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");
+ },
+ /**
+ * 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";
+ }
+ this.render_value_defs = [];
+ this.set({actual_mode: mode});
+ },
+ check_actual_mode: function(source, options) {
+ var self = this;
+ if(this.get("actual_mode") === "view") {
+ self.$el.removeClass('oe_form_editable').addClass('oe_form_readonly');
+ self.$buttons.find('.oe_form_buttons_edit').hide();
+ self.$buttons.find('.oe_form_buttons_view').show();
+ self.$sidebar.show();
+ } else {
+ self.$el.removeClass('oe_form_readonly').addClass('oe_form_editable');
+ self.$buttons.find('.oe_form_buttons_edit').show();
+ self.$buttons.find('.oe_form_buttons_view').hide();
+ self.$sidebar.hide();
+ 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;
+ }
+ }
+ }
+ }
+ },
+ on_button_save: function(e) {
+ var self = this;
+ $(e.target).attr("disabled", true);
+ return this.save().done(function(result) {
+ self.trigger("save", result);
+ self.reload().then(function() {
+ self.to_view_mode();
+ core.bus.trigger('do_reload_needaction');
+ core.bus.trigger('form_view_saved', self);
+ });
+ }).always(function(){
+ $(e.target).attr("disabled", false);
+ });
+ },
+ on_button_cancel: function(event) {
+ var self = this;
+ if (this.can_be_discarded()) {
+ if (this.get('actual_mode') === 'create') {
+ this.trigger('history_back');
+ } else {
+ this.to_view_mode();
+ $.when.apply(null, this.render_value_defs).then(function(){
+ self.trigger('load_record', self.datarecord);
+ });
+ }
+ }
+ this.trigger('on_button_cancel');
+ return false;
+ },
+ on_button_new: function() {
+ var self = this;
+ this.to_edit_mode();
+ return $.when(this.has_been_loaded).then(function() {
+ if (self.can_be_discarded()) {
+ return self.load_defaults();
+ }
+ });
+ },
+ 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.execute_pager_action('next');
+ } else {
+ self.do_action('history_back');
+ }
+ def.resolve();
+ });
+ } else {
+ $.async_when().done(function () {
+ def.reject();
+ });
+ }
+ });
+ return def.promise();
+ },
+ can_be_discarded: function() {
+ if (this.$el.is('.oe_form_dirty')) {
+ if (!confirm(_t("Warning, the record has been modified, your changes will be discarded.\n\nAre you sure you want to leave this page ?"))) {
+ return false;
+ }
+ this.$el.removeClass('oe_form_dirty');
+ }
+ return true;
+ },
+ /**
+ * 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;
+ try {
+ var form_invalid = false,
+ values = {},
+ first_invalid_field = null,
+ readonly_values = {};
+ for (var f in self.fields) {
+ if (!self.fields.hasOwnProperty(f)) { continue; }
+ f = self.fields[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);
+ }
+ }
+ }
+ if (form_invalid) {
+ self.set({'display_invalid_fields': true});
+ first_invalid_field.focus();
+ self.on_invalid();
+ return $.Deferred().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) {
+ 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) {
+ return self.record_saved(r);
+ }, null);
+ }
+ return save_deferral;
+ }
+ } catch (e) {
+ console.error(e);
+ return $.Deferred().reject();
+ }
+ },
+ 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.do_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');
+ return self.dataset.read_index(fields,
+ {
+ context: {
+ 'bin_size': true,
+ 'future_display_name': 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());
+ },
+ open_defaults_dialog: function () {
+ var self = this;
+ var display = function (field, value) {
+ var FieldSelection = core.form_widget_registry.get('selection'),
+ 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"),
+ args: {
+ fields: fields,
+ conditions: conditions
+ },
+ buttons: [
+ {text: _t("Close"), click: function () { d.close(); }},
+ {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('oe_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.dataset.model,
+ field_to_set,
+ self.fields[field_to_set].get_value(),
+ all_users,
+ true,
+ condition || false
+ ]).done(function () { d.close(); });
+ }}
+ ]
+ });
+ 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) {
+ return data.compute_domain(expression, this.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());
+ },
+});
+
+/**
+ * Interface to be implemented by rendering engines for the form view.
+ */
+var FormRenderingEngineInterface = Class.extend({
+ set_fields_view: function(fields_view) {},
+ set_fields_registry: function(fields_registry) {},
+ render_to: function($el) {},
+});
+
+/**
+ * Default rendering engine for the form view.
+ *
+ * It is necessary to set the view using set_view() before usage.
+ */
+var FormRenderingEngine = FormRenderingEngineInterface.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 $('<div class="oe_form"/>').append(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.appendTo(this.$target);
+
+ 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')));
+ }
+ 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('.oe_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('oe_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;
+ $group.children('field').each(function() {
+ self.preprocess_field($(this));
+ });
+ var $new_group = this.render_element('FormRenderingGroup', $group.getAttributes());
+ var $table;
+ if ($new_group.first().is('table.oe_form_group')) {
+ $table = $new_group;
+ } else if ($new_group.filter('table.oe_form_group').length) {
+ $table = $new_group.filter('table.oe_form_group').first();
+ } else {
+ $table = $new_group.find('table.oe_form_group').first();
+ }
+
+ var $tr, $td,
+ cols = parseInt($group.attr('col') || 2, 10),
+ row_cols = cols;
+
+ var children = [];
+ $group.children().each(function() {
+ var $child = $(this);
+ var colspan = parseInt($child.attr('colspan') || 1, 10);
+ var tagName = $child[0].tagName.toLowerCase();
+ var $td = $('<td/>').addClass('oe_form_group_cell').attr('colspan', colspan);
+ var newline = tagName === 'newline';
+
+ // Note FME: those classes are used in layout debug mode
+ if ($tr && row_cols > 0 && (newline || row_cols < colspan)) {
+ $tr.addClass('oe_form_group_row_incomplete');
+ if (newline) {
+ $tr.addClass('oe_form_group_row_newline');
+ }
+ }
+ if (newline) {
+ $tr = null;
+ return;
+ }
+ if (!$tr || row_cols < colspan) {
+ $tr = $('<tr/>').addClass('oe_form_group_row').appendTo($table);
+ row_cols = cols;
+ } else if (tagName==='group') {
+ // When <group> <group/><group/> </group>, we need a spacing between the two groups
+ $td.addClass('oe_group_right');
+ }
+ row_cols -= colspan;
+
+ // invisibility transfer
+ var field_modifiers = JSON.parse($child.attr('modifiers') || '{}');
+ var invisible = field_modifiers.invisible;
+ self.handle_common_properties($td, $("<dummy>").attr("modifiers", JSON.stringify({invisible: invisible})));
+
+ $tr.append($td.append($child));
+ children.push($child[0]);
+ });
+ if (row_cols && $td) {
+ $td.attr('colspan', parseInt($td.attr('colspan'), 10) + row_cols);
+ }
+ $group.before($new_group).remove();
+
+ $table.find('> tbody > tr').each(function() {
+ var to_compute = [],
+ row_cols = cols,
+ total = 100;
+ $(this).children().each(function() {
+ var $td = $(this),
+ $child = $td.children(':first');
+ if ($child.attr('cell-class')) {
+ $td.addClass($child.attr('cell-class'));
+ }
+ switch ($child[0].tagName.toLowerCase()) {
+ case 'separator':
+ break;
+ case 'label':
+ if ($child.attr('for')) {
+ $td.attr('width', '1%').addClass('oe_form_group_cell_label');
+ row_cols-= $td.attr('colspan') || 1;
+ total--;
+ }
+ break;
+ default:
+ var width = _.str.trim($child.attr('width') || ''),
+ iwidth = parseInt(width, 10);
+ if (iwidth) {
+ if (width.substr(-1) === '%') {
+ total -= iwidth;
+ width = iwidth + '%';
+ } else {
+ // Absolute width
+ $td.css('min-width', width + 'px');
+ }
+ $td.attr('width', width);
+ $child.removeAttr('width');
+ row_cols-= $td.attr('colspan') || 1;
+ } else {
+ to_compute.push($td);
+ }
+
+ }
+ });
+ if (row_cols) {
+ var unit = Math.floor(total / row_cols);
+ if (!$(this).is('.oe_form_group_row_incomplete')) {
+ _.each(to_compute, function($td) {
+ var width = parseInt($td.attr('colspan'), 10) * unit;
+ $td.attr('width', width + '%');
+ total -= width;
+ });
+ }
+ }
+ });
+ _.each(children, function(el) {
+ self.process($(el));
+ });
+ this.handle_common_properties($new_group, $group);
+ 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(.oe_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('oe-field-input-') : undefined,
+ };
+ var align = parseFloat(dict.align);
+ if (isNaN(align) || align === 1) {
+ align = 'right';
+ } else if (align === 0) {
+ align = 'left';
+ } else {
+ align = 'center';
+ }
+ dict.align = align;
+ 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));
+ }
+});
+
+core.view_registry.add('form', FormView);
+
+return FormView;
+
+});
+
diff --git a/addons/web/static/src/js/views/form_widgets.js b/addons/web/static/src/js/views/form_widgets.js
new file mode 100644
index 00000000000..69150e4f199
--- /dev/null
+++ b/addons/web/static/src/js/views/form_widgets.js
@@ -0,0 +1,1590 @@
+odoo.define('web.form_widgets', ['web.core', 'web.crash_manager', 'web.data', 'web.datepicker', 'web.Dialog', 'web.form_common', 'web.formats', 'web.framework', 'web.Model', 'web.pyeval', 'web.session', 'web.utils'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var data = require('web.data');
+var datepicker = require('web.datepicker');
+var Dialog = require('web.Dialog');
+var common = require('web.form_common');
+var formats = require('web.formats');
+var framework = require('web.framework');
+var Model = require('web.Model');
+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.is_stat_button = /\boe_stat_button\b/.test(node.attrs['class']);
+ this.icon_class = node.attrs.icon && "stat_button_icon fa " + node.attrs.icon + " fa-fw";
+ 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 && (! /\//.test(this.node.attrs.icon))) {
+ this.node.attrs.icon = '/web/static/src/img/icons/' + this.node.attrs.icon + '.png';
+ }
+ },
+ 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;
+ this.force_disabled = true;
+ this.check_disable();
+ this.execute_action().always(function() {
+ self.force_disabled = false;
+ self.check_disable();
+ });
+ },
+ execute_action: function() {
+ var self = this;
+ var exec_action = function() {
+ if (self.node.attrs.confirm) {
+ var def = $.Deferred();
+ var dialog = new Dialog(this, {
+ title: _t('Confirm'),
+ buttons: [
+ {text: _t("Cancel"), click: function() {
+ this.parents('.modal').modal('hide');
+ }
+ },
+ {text: _t("Ok"), click: function() {
+ var self2 = this;
+ self.on_confirmed().always(function() {
+ self2.parents('.modal').modal('hide');
+ });
+ }
+ }
+ ],
+ }, $('<div/>').text(self.node.attrs.confirm)).open();
+ dialog.on("closing", 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();
+ }
+ });
+ },
+ check_disable: function() {
+ var disabled = (this.force_disabled || !this.view.is_interactible_record());
+ this.$el.prop('disabled', disabled);
+ this.$el.css('color', disabled ? 'grey' : '');
+ }
+});
+
+var FieldChar = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ template: 'FieldChar',
+ widget_class: 'oe_form_field_char',
+ events: {
+ 'change input': '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() {
+ this.setupFocus(this.$('input'));
+ },
+ store_dom_value: function () {
+ if (!this.get('effective_readonly')
+ && this.$('input').length
+ && 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.get("effective_readonly")) {
+ this.$el.find('input').val(show_value);
+ } else {
+ if (this.password) {
+ show_value = new Array(show_value.length + 1).join('*');
+ }
+ this.$(".oe_form_char_content").text(show_value);
+ }
+ },
+ is_syntax_valid: function() {
+ if (!this.get("effective_readonly") && this.$("input").size() > 0) {
+ try {
+ this.parse_value(this.$('input').val(), '');
+ return true;
+ } 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() {
+ var input = this.$('input:first')[0];
+ return input ? input.focus() : false;
+ },
+ set_dimensions: function (height, width) {
+ this._super(height, width);
+ this.$('input').css({
+ height: height,
+ width: width
+ });
+ }
+});
+
+var KanbanSelection = FieldChar.extend({
+ init: function (field_manager, node) {
+ this._super(field_manager, node);
+ },
+ start: function () {
+ var self = this;
+ this.states = [];
+ this._super.apply(this, arguments);
+ // hook on form view content changed: recompute the states, because it may be related to the current stage
+ this.getParent().on('view_content_has_changed', self, function () {
+ self.render_value();
+ });
+ },
+ prepare_dropdown_selection: function() {
+ var self = this;
+ var _data = [];
+ var fetch_stage;
+ var selection = this.field.selection || [];
+ var stage_id = _.isArray(this.view.datarecord.stage_id) ? this.view.datarecord.stage_id[0] : this.view.datarecord.stage_id;
+ var legend_field = this.options && this.options.states_legend_field || false;
+ var fields_to_read = _.map(
+ this.options && this.options.states_legend || {},
+ function (value, key, list) { return value; });
+ if (legend_field && fields_to_read && stage_id) {
+ fetch_stage = new data.DataSet(
+ this,
+ self.view.fields[legend_field].field.relation).read_ids([stage_id],
+ fields_to_read);
+ }
+ else { fetch_stage = $.Deferred().resolve(false); }
+ return $.when(fetch_stage).then(function (stage_data) {
+ _.map(selection, function(res) {
+ var value = {
+ 'name': res[0],
+ 'tooltip': res[1],
+ 'state_name': res[1],
+ };
+ if (stage_data && stage_data[0][self.options.states_legend[res[0]]]) {
+ value['state_name'] = stage_data[0][self.options.states_legend[res[0]]];
+ value['tooltip'] = stage_data[0][self.options.states_legend[res[0]]];
+ }
+ if (res[0] == 'normal') { value['state_class'] = 'oe_kanban_status'; }
+ else if (res[0] == 'done') { value['state_class'] = 'oe_kanban_status oe_kanban_status_green'; }
+ else { value['state_class'] = 'oe_kanban_status oe_kanban_status_red'; }
+ _data.push(value);
+ });
+ return _data;
+ });
+ },
+ render_value: function() {
+ var self = this;
+ var dd_fetched = this.prepare_dropdown_selection();
+ return $.when(dd_fetched).then(function (states) {
+ self.states = states;
+ self.$el.html(QWeb.render("KanbanSelection", {'widget': self}));
+ self.$el.find('li').on('click', self.set_kanban_selection.bind(self));
+ });
+ },
+ /* 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;
+ 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 Priority = FieldChar.extend({
+ init: function (field_manager, node) {
+ this._super(field_manager, node);
+ },
+ prepare_priority: function() {
+ var self = this;
+ var selection = this.field.selection || [];
+ var init_value = selection && selection[0][0] || 0;
+ var data = _.map(selection.slice(1), function(element, index) {
+ var value = {
+ 'value': element[0],
+ 'name': element[1],
+ 'click_value': element[0],
+ };
+ if (index === 0 && self.get('value') === element[0]) {
+ value['click_value'] = init_value;
+ }
+ return value;
+ });
+ return data;
+ },
+ render_value: function() {
+ this.priorities = this.prepare_priority();
+ this.$el.html(QWeb.render("Priority", {'widget': this}));
+ if (!this.get('readonly')){
+ this.$el.find('li').on('click', this.set_priority.bind(this));
+ }
+ },
+ /* 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_priority: function (ev) {
+ var self = this;
+ 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 FieldID = FieldChar.extend({
+ process_modifiers: function () {
+ this._super();
+ this.set({ readonly: true });
+ },
+});
+
+var FieldEmail = FieldChar.extend({
+ template: 'FieldEmail',
+ initialize_content: function() {
+ this._super();
+ var $button = this.$el.find('button');
+ $button.click(this.on_button_clicked);
+ this.setupFocus($button);
+ },
+ render_value: function() {
+ if (!this.get("effective_readonly")) {
+ this._super();
+ } else {
+ this.$el.find('a')
+ .attr('href', 'mailto:' + this.get('value'))
+ .text(this.get('value') || '');
+ }
+ },
+ on_button_clicked: function() {
+ if (!this.get('value') || !this.is_syntax_valid()) {
+ this.do_warn(_t("E-mail Error"), _t("Can't send email to invalid e-mail address"));
+ } else {
+ location.href = 'mailto:' + this.get('value');
+ }
+ }
+});
+
+var FieldUrl = FieldChar.extend({
+ template: 'FieldUrl',
+ initialize_content: function() {
+ this._super();
+ var $button = this.$el.find('button');
+ $button.click(this.on_button_clicked);
+ this.setupFocus($button);
+ },
+ render_value: function() {
+ if (!this.get("effective_readonly")) {
+ this._super();
+ } else {
+ 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.find('a').attr('href', tmp).text(text);
+ }
+ },
+ on_button_clicked: function() {
+ if (!this.get('value')) {
+ this.do_warn(_t("Resource Error"), _t("This resource is empty"));
+ } else {
+ var url = $.trim(this.get('value'));
+ if(/^www\./i.test(url))
+ url = 'http://'+url;
+ window.open(url);
+ }
+ }
+});
+
+var FieldFloat = FieldChar.extend({
+ is_field_number: true,
+ widget_class: 'oe_form_field_float',
+ 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;
+ }
+ },
+ set_value: function(value_) {
+ if (value_ === false || value_ === undefined) {
+ // As in GTK client, floats default to 0
+ value_ = 0;
+ }
+ if (this.digits !== undefined && this.digits.length === 2) {
+ value_ = utils.round_decimals(value_, this.digits[1]);
+ }
+ this._super.apply(this, [value_]);
+ },
+ focus: function () {
+ var $input = this.$('input:first');
+ return $input.length ? $input.select() : false;
+ }
+});
+
+var FieldCharDomain = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ render_value: function() {
+ var self = this;
+ this.$el.html(QWeb.render("FieldCharDomain", {widget: this}));
+ if (this.get('value')) {
+ var model = this.options.model || this.field_manager.get_field_value(this.options.model_field);
+ var domain = pyeval.eval('domain', this.get('value'));
+ var ds = new data.DataSetStatic(self, model, self.build_context());
+ ds.call('search_count', [domain]).then(function (results) {
+ $('.oe_domain_count', self.$el).text(results + ' records selected');
+ if (self.get('effective_readonly')) {
+ $('button span', self.$el).text(' See selection');
+ }
+ else {
+ $('button span', self.$el).text(' Change selection');
+ }
+ });
+ } else {
+ $('.oe_domain_count', this.$el).text('0 record selected');
+ $('button span', this.$el).text(' Select records');
+ }
+ this.$('.select_records').on('click', self.on_click);
+ },
+ on_click: function(event) {
+ event.preventDefault();
+ var self = this;
+ var model = this.options.model || this.field_manager.get_field_value(this.options.model_field);
+
+ var options = {
+ title: this.get('effective_readonly') ? 'Selected records' : 'Select records...',
+ readonly: this.get('effective_readonly'),
+ disable_multiple_selection: this.get('effective_readonly'),
+ no_create: this.get('effective_readonly'),
+ };
+ var domain = this.get('value');
+ var popup = new common.DomainEditorPopup(this);
+ popup.select_element(model, options, domain, {});
+ popup.on("elements_selected", self, function(selected_ids) {
+ if (!self.get('effective_readonly')) {
+ self.set_value(popup.get_domain(selected_ids));
+ }
+ });
+ },
+});
+
+var FieldDatetime = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ template: "FieldDatetime",
+ build_widget: function() {
+ return new datepicker.DateTimeWidget(this);
+ },
+ destroy_content: function() {
+ if (this.datewidget) {
+ this.datewidget.destroy();
+ this.datewidget = undefined;
+ }
+ },
+ initialize_content: function() {
+ if (!this.get("effective_readonly")) {
+ this.datewidget = this.build_widget();
+ this.datewidget.on('datetime_changed', this, _.bind(function() {
+ this.internal_set_value(this.datewidget.get_value());
+ }, this));
+ this.datewidget.appendTo(this.$el);
+ this.setupFocus(this.datewidget.$input);
+ }
+ },
+ render_value: function() {
+ if (!this.get("effective_readonly")) {
+ this.datewidget.set_value(this.get('value'));
+ } else {
+ this.$el.text(formats.format_value(this.get('value'), this, ''));
+ }
+ },
+ is_syntax_valid: function() {
+ if (!this.get("effective_readonly") && this.datewidget) {
+ return this.datewidget.is_valid_();
+ }
+ return true;
+ },
+ is_false: function() {
+ return this.get('value') === '' || this._super();
+ },
+ focus: function() {
+ var input = this.datewidget && this.datewidget.$input[0];
+ return input ? input.focus() : false;
+ },
+ set_dimensions: function (height, width) {
+ this._super(height, width);
+ if (!this.get("effective_readonly")) {
+ this.datewidget.$input.css('height', height);
+ }
+ }
+});
+
+var FieldDate = FieldDatetime.extend({
+ template: "FieldDate",
+ build_widget: function() {
+ return new datepicker.DateWidget(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 textarea': 'store_dom_value',
+ },
+ initialize_content: function() {
+ if (! this.get("effective_readonly")) {
+ this.$textarea = this.$el.find('textarea');
+ this.auto_sized = false;
+ this.default_height = this.$textarea.css('height');
+ if (this.default_height === '0px') this.default_height = '90px';
+ if (this.get("effective_readonly")) {
+ this.$textarea.attr('disabled', 'disabled');
+ }
+ this.setupFocus(this.$textarea);
+ } else {
+ this.$textarea = undefined;
+ }
+ },
+ commit_value: function () {
+ if (! this.get("effective_readonly") && this.$textarea) {
+ this.store_dom_value();
+ }
+ return this._super();
+ },
+ store_dom_value: function () {
+ this.internal_set_value(formats.parse_value(this.$textarea.val(), this));
+ },
+ render_value: function() {
+ if (! this.get("effective_readonly")) {
+ var show_value = formats.format_value(this.get('value'), this, '');
+ if (show_value === '') {
+ this.$textarea.css('height', parseInt(this.default_height, 10)+"px");
+ }
+ this.$textarea.val(show_value);
+ if (! this.auto_sized) {
+ this.auto_sized = true;
+ this.$textarea.autosize();
+ } else {
+ this.$textarea.trigger("autosize");
+ }
+ } else {
+ var txt = this.get("value") || '';
+ this.$(".oe_form_text_content").text(txt);
+ }
+ },
+ is_syntax_valid: function() {
+ if (!this.get("effective_readonly") && this.$textarea) {
+ try {
+ formats.parse_value(this.$textarea.val(), this, '');
+ return true;
+ } catch(e) {
+ return false;
+ }
+ }
+ return true;
+ },
+ is_false: function() {
+ return this.get('value') === '' || this._super();
+ },
+ focus: function($el) {
+ var input = !this.get("effective_readonly") && this.$textarea && this.$textarea[0];
+ return input ? input.focus() : false;
+ },
+ set_dimensions: function (height, width) {
+ this._super(height, width);
+ if (!this.get("effective_readonly") && this.$textarea) {
+ this.$textarea.css({
+ width: width,
+ minHeight: height
+ });
+ }
+ },
+});
+
+/**
+ * FieldTextHtml Widget
+ * Intended for FieldText widgets meant to display HTML content. This
+ * widget will instantiate the CLEditor (see cleditor in static/src/lib)
+ * To find more information about CLEditor configutation: go to
+ * http://premiumsoftware.net/cleditor/docs/GettingStarted.html
+ */
+var FieldTextHtml = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ template: 'FieldTextHtml',
+ init: function() {
+ this._super.apply(this, arguments);
+ },
+ initialize_content: function() {
+ var self = this;
+ if (! this.get("effective_readonly")) {
+ self._updating_editor = false;
+ this.$textarea = this.$el.find('textarea');
+ var width = ((this.node.attrs || {}).editor_width || 'calc(100% - 4px)');
+ var height = ((this.node.attrs || {}).editor_height || 250);
+ this.$textarea.cleditor({
+ width: width, // width not including margins, borders or padding
+ height: height, // height not including margins, borders or padding
+ controls: // controls to add to the toolbar
+ "bold italic underline strikethrough " +
+ "| removeformat | bullets numbering | outdent " +
+ "indent | link unlink | source",
+ bodyStyle: // style to assign to document body contained within the editor
+ "margin:4px; color:#4c4c4c; font-size:13px; font-family:'Lucida Grande',Helvetica,Verdana,Arial,sans-serif; cursor:text"
+ });
+ this.$cleditor = this.$textarea.cleditor()[0];
+ this.$cleditor.change(function() {
+ if (! self._updating_editor) {
+ self.$cleditor.updateTextArea();
+ self.internal_set_value(self.$textarea.val());
+ }
+ });
+ if (this.field.translate) {
+ var $img = $('<img class="oe_field_translate oe_input_icon" src="/web/static/src/img/icons/terp-translate.png" width="16" height="16" border="0"/>')
+ .click(this.on_translate);
+ this.$cleditor.$toolbar.append($img);
+ }
+ }
+ },
+ render_value: function() {
+ if (! this.get("effective_readonly")) {
+ this.$textarea.val(this.get('value') || '');
+ this._updating_editor = true;
+ this.$cleditor.updateFrame();
+ this._updating_editor = false;
+ } else {
+ this.$el.html(this.get('value'));
+ }
+ },
+});
+
+var FieldBoolean = common.AbstractField.extend({
+ template: 'FieldBoolean',
+ start: function() {
+ var self = this;
+ this.$checkbox = $("input", this.$el);
+ this.setupFocus(this.$checkbox);
+ this.$el.click(_.bind(function() {
+ this.internal_set_value(this.$checkbox.is(':checked'));
+ }, this));
+ var check_readonly = function() {
+ self.$checkbox.prop('disabled', self.get("effective_readonly"));
+ self.click_disabled_boolean();
+ };
+ this.on("change:effective_readonly", this, check_readonly);
+ check_readonly.call(this);
+ this._super.apply(this, arguments);
+ },
+ render_value: function() {
+ this.$checkbox[0].checked = this.get('value');
+ },
+ focus: function() {
+ var input = this.$checkbox && this.$checkbox[0];
+ return input ? input.focus() : false;
+ },
+ click_disabled_boolean: function(){
+ var $disabled = this.$el.find('input[type=checkbox]:disabled');
+ $disabled.each(function (){
+ $(this).next('div').remove();
+ $(this).closest("span").append($('<div class="boolean"></div>'));
+ });
+ }
+});
+
+/**
+ The progressbar field expect a float from 0 to 100.
+*/
+var FieldProgressBar = common.AbstractField.extend({
+ template: 'FieldProgressBar',
+ render_value: function() {
+ this.$el.progressbar({
+ value: this.get('value') || 0,
+ disabled: this.get("effective_readonly")
+ });
+ var formatted_value = formats.format_value(this.get('value') || 0, { type : 'float' });
+ this.$('span').html(formatted_value + '%');
+ }
+});
+
+/**
+ The PercentPie field expect a float from 0 to 100.
+*/
+var FieldPercentPie = common.AbstractField.extend({
+ template: 'FieldPercentPie',
+
+ render_value: function() {
+ var value = this.get('value'),
+ formatted_value = Math.round(value || 0) + '%',
+ svg = this.$('svg')[0];
+
+ svg.innerHTML = "";
+ nv.addGraph(function() {
+ var width = 42, height = 42;
+ var chart = nv.models.pieChart()
+ .width(width)
+ .height(height)
+ .margin({top: 0, right: 0, bottom: 0, left: 0})
+ .donut(true)
+ .showLegend(false)
+ .showLabels(false)
+ .tooltips(false)
+ .color(['#7C7BAD','#DDD'])
+ .donutRatio(0.62);
+
+ d3.select(svg)
+ .datum([{'x': 'value', 'y': value}, {'x': 'complement', 'y': 100 - value}])
+ .transition()
+ .call(chart)
+ .attr('style', 'width: ' + width + 'px; height:' + height + 'px;');
+
+ d3.select(svg)
+ .append("text")
+ .attr({x: width/2, y: height/2 + 3, 'text-anchor': 'middle'})
+ .style({"font-size": "10px", "font-weight": "bold"})
+ .text(formatted_value);
+
+ return chart;
+ });
+
+ }
+});
+
+/**
+ The FieldBarChart expectsa list of values (indeed)
+*/
+var FieldBarChart = common.AbstractField.extend({
+ template: 'FieldBarChart',
+
+ render_value: function() {
+ var value = JSON.parse(this.get('value'));
+ var svg = this.$('svg')[0];
+ svg.innerHTML = "";
+ nv.addGraph(function() {
+ var width = 34, height = 34;
+ var chart = nv.models.discreteBarChart()
+ .x(function (d) { return d.tooltip; })
+ .y(function (d) { return d.value; })
+ .width(width)
+ .height(height)
+ .margin({top: 0, right: 0, bottom: 0, left: 0})
+ .tooltips(false)
+ .showValues(false)
+ .transitionDuration(350)
+ .showXAxis(false)
+ .showYAxis(false);
+
+ d3.select(svg)
+ .datum([{key: 'values', values: value}])
+ .transition()
+ .call(chart)
+ .attr('style', 'width: ' + (width + 4) + 'px; height: ' + (height + 8) + 'px;');
+
+ nv.utils.windowResize(chart.update);
+
+ return chart;
+ });
+
+ }
+});
+
+
+var FieldSelection = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ template: 'FieldSelection',
+ events: {
+ 'change select': '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
+ var ischanging = false;
+ var $select = this.$el.find('select')
+ .change(function () { ischanging = true; })
+ .click(function () { ischanging = false; })
+ .keyup(function (e) {
+ if (e.which !== 13 || !ischanging) { return; }
+ e.stopPropagation();
+ ischanging = false;
+ });
+ this.setupFocus($select);
+ },
+ commit_value: function () {
+ this.store_dom_value();
+ return this._super();
+ },
+ store_dom_value: function () {
+ if (!this.get('effective_readonly') && this.$('select').length) {
+ var val = JSON.parse(this.$('select').val());
+ this.internal_set_value(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.$().html(QWeb.render("FieldSelectionSelect", {widget: this, values: values}));
+ this.$("select").val(JSON.stringify(found[0]));
+ } else {
+ this.$el.text(found[1]);
+ }
+ },
+ focus: function() {
+ var input = this.$('select:first')[0];
+ return input ? input.focus() : false;
+ },
+ set_dimensions: function (height, width) {
+ this._super(height, width);
+ this.$('select').css({
+ height: height,
+ width: width
+ });
+ }
+});
+
+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.on("change:effective_readonly", this, this.render_value);
+ 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');
+ return value instanceof Array ? value[0] : value;
+ },
+ render_value: function () {
+ var self = this;
+ this.$el.toggleClass("oe_readonly", this.get('effective_readonly'));
+ this.$("input").filter(function () {return this.value == self.get_value();}).prop("checked", true);
+ this.$(".oe_radio_readonly").text(this.get('value') ? this.get('value')[1] : "");
+ }
+});
+
+var FieldReference = common.AbstractField.extend(common.ReinitializeFieldMixin, {
+ template: 'FieldReference',
+ 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;
+ var fm = new common.DefaultFieldManager(this);
+ this.fm = fm;
+ 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(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.$(".oe_form_view_reference_selection"));
+ 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(fm, { attrs: {
+ name: 'Referenced Document',
+ modifiers: JSON.stringify({readonly: this.get('effective_readonly')}),
+ }});
+ this.m2o.on("change:value", this, this.data_changed);
+ this.m2o.appendTo(this.$(".oe_form_view_reference_m2o"));
+ 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.$el.toggle(!!this.get('value')[0]);
+ this.reference_ready = true;
+ },
+});
+
+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;
+ this.max_upload_size = 25 * 1024 * 1024; // 25Mo
+ if (!this.useFileAPI) {
+ this.fileupload_id = _.uniqueId('oe_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() {
+ var self= this;
+ this.$el.find('input.oe_form_binary_file').change(this.on_file_change);
+ this.$el.find('button.oe_form_binary_file_save').click(this.on_save_as);
+ this.$el.find('.oe_form_binary_file_clear').click(this.on_clear);
+ this.$el.find('.oe_form_binary_file_edit').click(function(event){
+ self.$el.find('input.oe_form_binary_file').click();
+ });
+ },
+ 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 (file.size > this.max_upload_size) {
+ var msg = _t("The selected file exceed the maximum file size of %s.");
+ core.bus.trigger('display_notification_warning', _t("File upload"), _.str.sprintf(msg, utils.human_size(this.max_upload_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.$el.find('form.oe_form_binary_form input[name=session_id]').val(this.session.session_id);
+ this.$el.find('form.oe_form_binary_form').submit();
+ }
+ this.$el.find('.oe_form_binary_progress').show();
+ this.$el.find('.oe_form_binary').hide();
+ }
+ },
+ on_file_uploaded: function(size, name, content_type, file_base64) {
+ 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.filename = name;
+ this.on_file_uploaded_and_valid.apply(this, arguments);
+ }
+ this.$el.find('.oe_form_binary_progress').hide();
+ this.$el.find('.oe_form_binary').show();
+ },
+ on_file_uploaded_and_valid: function(size, name, content_type, 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/binary/saveas_ajax',
+ data: {data: JSON.stringify({
+ model: this.view.dataset.model,
+ id: (this.view.datarecord.id || ''),
+ field: this.name,
+ filename_field: (filename_fieldname || ''),
+ data: utils.is_bin_size(value) ? null : value,
+ filename: filename_field ? filename_field.get('value') : null,
+ context: this.view.dataset.get_context()
+ })},
+ complete: framework.unblockUI,
+ error: c.rpc_error.bind(c)
+ });
+ ev.stopPropagation();
+ return false;
+ }
+ },
+ set_filename: function(value) {
+ var filename = this.node.attrs.filename;
+ if (filename) {
+ var tmp = {};
+ tmp[filename] = value;
+ this.field_manager.set_values(tmp);
+ }
+ },
+ on_clear: function() {
+ if (this.get('value') !== false) {
+ this.binary_value = false;
+ this.internal_set_value(false);
+ }
+ return false;
+ }
+});
+
+var FieldBinaryFile = FieldBinary.extend({
+ template: 'FieldBinaryFile',
+ initialize_content: function() {
+ this._super();
+ if (this.get("effective_readonly")) {
+ var self = this;
+ this.$el.find('a').click(function(ev) {
+ if (self.get('value')) {
+ self.on_save_as(ev);
+ }
+ return false;
+ });
+ }
+ },
+ render_value: function() {
+ var show_value;
+ if (!this.get("effective_readonly")) {
+ if (this.node.attrs.filename) {
+ show_value = this.view.datarecord[this.node.attrs.filename] || '';
+ } else {
+ show_value = (this.get('value') !== null && this.get('value') !== undefined && this.get('value') !== false) ? this.get('value') : '';
+ }
+ this.$el.find('input').eq(0).val(show_value);
+ } else {
+ this.$el.find('a').toggle(!!this.get('value'));
+ if (this.get('value')) {
+ show_value = _t("Download");
+ if (this.view)
+ show_value += " " + (this.view.datarecord[this.node.attrs.filename] || '');
+ this.$el.find('a').text(show_value);
+ }
+ }
+ },
+ on_file_uploaded_and_valid: function(size, name, content_type, file_base64) {
+ this.binary_value = true;
+ this.internal_set_value(file_base64);
+ var show_value = name + " (" + utils.human_size(size) + ")";
+ this.$el.find('input').eq(0).val(show_value);
+ this.set_filename(name);
+ },
+ on_clear: function() {
+ this._super.apply(this, arguments);
+ this.$el.find('input').eq(0).val('');
+ this.set_filename('');
+ }
+});
+
+var FieldBinaryImage = FieldBinary.extend({
+ template: 'FieldBinaryImage',
+ placeholder: "/web/static/src/img/placeholder.png",
+ render_value: function() {
+ var self = this;
+ var url;
+ this.session = session;
+ if (this.get('value') && !utils.is_bin_size(this.get('value'))) {
+ url = 'data:image/png;base64,' + this.get('value');
+ } else if (this.get('value')) {
+ var id = JSON.stringify(this.view.datarecord.id || null);
+ var field = this.name;
+ if (this.options.preview_image)
+ field = this.options.preview_image;
+ url = session.url('/web/binary/image', {
+ model: this.view.dataset.model,
+ id: id,
+ field: field,
+ t: (new Date().getTime()),
+ });
+ } else {
+ url = this.placeholder;
+ }
+ var $img = $(QWeb.render("FieldBinaryImage-img", { widget: this, url: url }));
+ $($img).click(function(e) {
+ if(self.view.get("actual_mode") == "view") {
+ var $button = $(".oe_form_button_edit");
+ $button.openerpBounce();
+ e.stopPropagation();
+ }
+ });
+ this.$el.find('> img').remove();
+ this.$el.prepend($img);
+ $img.load(function() {
+ if (! self.options.size)
+ return;
+ $img.css("max-width", "" + self.options.size[0] + "px");
+ $img.css("max-height", "" + self.options.size[1] + "px");
+ });
+ $img.on('error', function() {
+ $img.attr('src', self.placeholder);
+ core.bus.trigger('display_notification_warning', _t("Image"), _t("Could not display the selected image."));
+ });
+ },
+ on_file_uploaded_and_valid: function(size, name, content_type, file_base64) {
+ this.internal_set_value(file_base64);
+ this.binary_value = true;
+ this.render_value();
+ this.set_filename(name);
+ },
+ on_clear: function() {
+ this._super.apply(this, arguments);
+ this.render_value();
+ this.set_filename('');
+ },
+ 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_
+ });
+ }
+ }
+});
+
+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.$el.on('click','li[data-id]',this.on_click_stage);
+ }
+ if (this.$el.parent().is('header')) {
+ this.$el.after('<div class="oe_clear"/>');
+ }
+ this._super();
+ },
+ 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 (fields) {
+ 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: function (ev) {
+ var self = this;
+ var $li = $(ev.currentTarget);
+ var val;
+ if (this.field.type == "many2one") {
+ val = parseInt($li.data("id"), 10);
+ }
+ else {
+ val = $li.data("id");
+ }
+ if (val != self.get('value')) {
+ this.view.recursive_save().done(function() {
+ var change = {};
+ change[self.name] = val;
+ self.view.dataset.write(self.view.datarecord.id, change).done(function() {
+ self.view.reload();
+ });
+ });
+ }
+ },
+});
+
+var FieldMonetary = FieldFloat.extend({
+ template: "FieldMonetary",
+ widget_class: 'oe_form_field_float oe_form_field_monetary',
+ init: function() {
+ this._super.apply(this, arguments);
+ this.set({"currency": false});
+ if (this.options.currency_field) {
+ this.field_manager.on("field_changed:" + this.options.currency_field, this, function() {
+ this.set({"currency": this.field_manager.get_field_value(this.options.currency_field)});
+ });
+ }
+ this.on("change:currency", this, this.get_currency_info);
+ this.get_currency_info();
+ this.ci_dm = new utils.DropMisordered();
+ },
+ start: function() {
+ var tmp = this._super();
+ this.on("change:currency_info", this, this.reinitialize);
+ return tmp;
+ },
+ get_currency_info: function() {
+ var self = this;
+ if (this.get("currency") === false) {
+ this.set({"currency_info": null});
+ return;
+ }
+ return this.ci_dm.add(self.alive(new Model("res.currency").query(["symbol", "position"])
+ .filter([["id", "=", self.get("currency")]]).first())).then(function(res) {
+ self.set({"currency_info": res});
+ });
+ },
+ parse_value: function(val, def) {
+ return formats.parse_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, def);
+ },
+ format_value: function(val, def) {
+ return formats.format_value(val, {type: "float", digits: (this.node.attrs || {}).digits || this.field.digits}, 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 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({
+ render_value: function () {
+ this.icon = this.get_value() ? 'gtk-yes.png' : 'gtk-normal.png';
+ this.$el.html(QWeb.render("FieldToggleBoolean", {'widget': this}));
+ this.$('.oe_toggle_button').on('click', this.set_toggle_button.bind(this));
+ },
+ set_toggle_button: function () {
+ var self = this;
+ var toggle_value = this.get_value() === false ? true: false;
+ 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();
+ },
+});
+
+
+/**
+ * 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('html', FieldTextHtml)
+ .add('char_domain', FieldCharDomain)
+ .add('date', FieldDate)
+ .add('datetime', FieldDatetime)
+ .add('selection', FieldSelection)
+ .add('radio', FieldRadio)
+ .add('reference', FieldReference)
+ .add('boolean', FieldBoolean)
+ .add('toggle_button', FieldToggleBoolean)
+ .add('float', FieldFloat)
+ .add('percentpie', FieldPercentPie)
+ .add('barchart', FieldBarChart)
+ .add('integer', FieldFloat)
+ .add('float_time', FieldFloat)
+ .add('progressbar', FieldProgressBar)
+ .add('image', FieldBinaryImage)
+ .add('binary', FieldBinaryFile)
+ .add('statusbar', FieldStatus)
+ .add('monetary', FieldMonetary)
+ .add('priority', Priority)
+ .add('kanban_state_selection', KanbanSelection)
+ .add('statinfo', StatInfo);
+
+
+/**
+ * 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 {
+ FieldChar: FieldChar,
+};
+
+});
diff --git a/addons/web/static/src/js/views/graph_view.js b/addons/web/static/src/js/views/graph_view.js
new file mode 100644
index 00000000000..26826b19aac
--- /dev/null
+++ b/addons/web/static/src/js/views/graph_view.js
@@ -0,0 +1,128 @@
+odoo.define('web.GraphView', ['web.core', 'web.GraphWidget', 'web.Model', 'web.View'], function (require) {
+"use strict";
+/*---------------------------------------------------------
+ * Odoo Graph view
+ *---------------------------------------------------------*/
+
+var core = require('web.core');
+var GraphWidget = require('web.GraphWidget');
+var Model = require('web.Model');
+var View = require('web.View');
+
+var _lt = core._lt;
+var _t = core._t;
+var QWeb = core.qweb;
+
+var GraphView = View.extend({
+ className: 'oe_graph',
+ display_name: _lt('Graph'),
+ view_type: 'graph',
+
+ init: function(parent, dataset, view_id, options) {
+ this._super(parent, dataset, view_id, options);
+
+ this.model = new Model(dataset.model, {group_by_no_leaf: true});
+ this.measures = [];
+ this.active_measure = '__count__';
+ this.initial_groupbys = [];
+ this.widget = undefined;
+ },
+ start: function () {
+ var load_fields = this.model.call('fields_get', [])
+ .then(this.prepare_fields.bind(this));
+
+ return $.when(this._super(), load_fields).then(this.render_buttons.bind(this));
+ },
+ render_buttons: function () {
+ var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
+ this.$buttons.html(QWeb.render('GraphView.buttons', context));
+ this.$measure_list = this.$buttons.find('.oe-measure-list');
+ this.update_measure();
+ this.$buttons.find('button').tooltip();
+ this.$buttons.click(this.on_button_click.bind(this));
+ },
+ update_measure: function () {
+ var self = this;
+ this.$measure_list.find('li').each(function (index, li) {
+ $(li).toggleClass('selected', $(li).data('field') === self.active_measure);
+ });
+ },
+ view_loading: function (fvg) {
+ var self = this;
+ fvg.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);
+ }
+ });
+ },
+ do_show: function () {
+ this.do_push_state({});
+ this.$el.show();
+ 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') {
+ self.measures[name] = field;
+ }
+ }
+ });
+ this.measures.__count__ = {string: _t("Quantity"), type: "integer"};
+ },
+ do_search: function (domain, context, group_by) {
+ if (!this.widget) {
+ this.initial_groupbys = context.graph_groupbys || this.initial_groupbys;
+ this.widget = new GraphWidget(this, this.dataset.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,
+ });
+ // append widget
+ this.$el.hide();
+ 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('oe-bar-mode')) {this.widget.set_mode('bar');}
+ if ($target.hasClass('oe-line-mode')) {this.widget.set_mode('line');}
+ if ($target.hasClass('oe-pie-mode')) {this.widget.set_mode('pie');}
+ if ($target.parents('.oe-measure-list').length) {
+ var parent = $target.parent(),
+ field = parent.data('field');
+ this.active_measure = field;
+ parent.toggleClass('selected');
+ event.stopPropagation();
+ this.update_measure();
+ this.widget.set_measure(this.active_measure);
+ }
+ },
+});
+
+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
new file mode 100644
index 00000000000..b06ab42f346
--- /dev/null
+++ b/addons/web/static/src/js/views/graph_widget.js
@@ -0,0 +1,303 @@
+odoo.define('web.GraphWidget', ['web.core', 'web.Model', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var Model = require('web.Model');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+return Widget.extend({
+ init: function (parent, model, options) {
+ this._super(parent);
+ this.context = options.context;
+ this.fields = options.fields;
+ this.fields.__count__ = _t("Quantity");
+ 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__";
+ },
+ 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 {
+ this['display_' + this.mode]();
+ }
+ },
+ display_bar: function () {
+ // prepare data for bar chart
+ var data, values,
+ measure = this.fields[this.measure].string;
+ // zero groupbys
+ if (this.groupbys.length === 0) {
+ data = [{
+ values: [{
+ x: this.ViewManager.title,
+ 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({
+ delay: 250,
+ transitionDuration: 10,
+ showLegend: true,
+ showXAxis: true,
+ showYAxis: true,
+ rightAlignYAxis: false,
+ stacked: true,
+ reduceXTicks: false,
+ // rotateLabels: 40,
+ showControls: (this.groupbys.length > 1)
+ });
+
+ chart(svg);
+ this.to_remove = chart.update;
+ nv.utils.onWindowResize(chart.update);
+ },
+ 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) {
+ return 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"),
+ }));
+ }
+ if (all_zero) {
+ return 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"),
+ }));
+ }
+ 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 chart = nv.models.pieChart();
+ chart.options({
+ delay: 250,
+ transitionDuration: 100,
+ color: d3.scale.category10().range(),
+ });
+
+ chart(svg);
+ this.to_remove = chart.update;
+ nv.utils.onWindowResize(chart.update);
+ },
+ 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: 50, right: 50},
+ useInteractiveGuideline: true,
+ showLegend: true,
+ showXAxis: true,
+ showYAxis: true,
+ });
+ chart.xAxis.tickValues(tickValues)
+ .tickFormat(tickFormat);
+
+ chart(svg);
+ this.to_remove = chart.update;
+ nv.utils.onWindowResize(chart.update);
+ },
+ destroy: function () {
+ nv.utils.offWindowResize(this.to_remove);
+ return this._super();
+ }
+});
+
+});
diff --git a/addons/web/static/src/js/views/list_common.js b/addons/web/static/src/js/views/list_common.js
new file mode 100644
index 00000000000..e2e9ec1eb41
--- /dev/null
+++ b/addons/web/static/src/js/views/list_common.js
@@ -0,0 +1,402 @@
+odoo.define('web.list_common', ['web.core'], 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
+ * @returns this
+ */
+ remove: function (record) {
+ 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--;
+ 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
new file mode 100644
index 00000000000..ef875e51679
--- /dev/null
+++ b/addons/web/static/src/js/views/list_view.js
@@ -0,0 +1,2100 @@
+
+odoo.define('web.ListView', ['web.core', 'web.data', 'web.DataExport', 'web.formats', 'web.list_common', 'web.Model', 'web.pyeval', 'web.session', 'web.Sidebar', 'web.utils', 'web.View'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var DataExport = require('web.DataExport');
+var formats = require('web.formats');
+var common = require('web.list_common');
+var Model = require('web.Model');
+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;
+
+var ListView = View.extend( /** @lends instance.web.ListView# */ {
+ _template: 'ListView',
+ display_name: _lt('List'),
+ 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,
+ },
+ view_type: 'tree',
+ events: {
+ 'click thead th.oe_sortable[data-id]': 'sort_by_column'
+ },
+ /**
+ * 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 instance.web.ListView
+ * @extends instance.web.View
+ *
+ * @param parent parent object
+ * @param {instance.web.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(parent, dataset, view_id, options) {
+ var self = this;
+ this._super(parent);
+ this.set_default_options(_.extend({}, this.defaults, options || {}));
+ this.dataset = dataset;
+ this.model = dataset.model;
+ this.view_id = view_id;
+ this.previous_colspan = null;
+ this.colors = null;
+ this.fonts = 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,
+ dataset.get_domain(),
+ dataset.get_context());
+ this.groups.datagroup.sort = this.dataset._sort;
+ }
+
+ this.page = 0;
+ 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;
+ },
+ view_loading: function(r) {
+ return this.load_list(r);
+ },
+ set_default_options: function (options) {
+ this._super(options);
+ _.defaults(this.options, {
+ GroupsType: ListView.Groups,
+ ListType: ListView.List
+ });
+ },
+
+ /**
+ * Retrieves the view's number of records per page (|| section)
+ *
+ * options > defaults > parent.action.limit > indefinite
+ *
+ * @returns {Number|null}
+ */
+ limit: function () {
+ if (this._limit === undefined) {
+ this._limit = (this.options.limit
+ || this.defaults.limit
+ || (this.getParent().action || {}).limit
+ || 80);
+ }
+ return this._limit;
+ },
+ /**
+ * 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);
+ }
+ });
+ },
+ /**
+ * View startup method, the default behavior is to set the ``oe_list``
+ * class on its root element and to perform an RPC load call.
+ *
+ * @returns {$.Deferred} loading promise
+ */
+ start: function() {
+ this.$el.addClass('oe_list');
+ return this._super();
+ },
+ /**
+ * Returns the style for the provided record in the current view (from the
+ * ``@colors`` and ``@fonts`` attributes)
+ *
+ * @param {Record} record record for the current row
+ * @returns {String} CSS style declaration
+ */
+ style_for: function (record) {
+ var len, style= '';
+
+ var context = _.extend({}, record.attributes, {
+ uid: session.uid,
+ current_date: moment().format('YYYY-MM-DD')
+ // TODO: time, datetime, relativedelta
+ });
+ var i;
+ var pair;
+ var expression;
+ if (this.fonts) {
+ for(i=0, len=this.fonts.length; i<len; ++i) {
+ pair = this.fonts[i];
+ var font = pair[0];
+ expression = pair[1];
+ if (py.PY_isTrue(py.evaluate(expression, context))) {
+ switch(font) {
+ case 'bold':
+ style += 'font-weight: bold;';
+ break;
+ case 'italic':
+ style += 'font-style: italic;';
+ break;
+ case 'underline':
+ style += 'text-decoration: underline;';
+ break;
+ }
+ }
+ }
+ }
+
+ if (!this.colors) { return style; }
+ for(i=0, len=this.colors.length; i<len; ++i) {
+ pair = this.colors[i];
+ var color = pair[0];
+ expression = pair[1];
+ if (py.PY_isTrue(py.evaluate(expression, context))) {
+ return style += 'color: ' + color + ';';
+ }
+ // TODO: handle evaluation errors
+ }
+ return style;
+ },
+ /**
+ * Called after loading the list view's description, sets up such things
+ * as the view table's columns, renders the table itself and hooks up the
+ * various table-level and row-level DOM events (action buttons, deletion
+ * buttons, selection of records, [New] button, selection of a given
+ * record, ...)
+ *
+ * Sets up the following:
+ *
+ * * Processes arch and fields to generate a complete field descriptor for each field
+ * * Create the table itself and allocate visible columns
+ * * Hook in the top-level (header) [New|Add] and [Delete] button
+ * * Sets up showing/hiding the top-level [Delete] button based on records being selected or not
+ * * Sets up event handlers for action buttons and per-row deletion button
+ * * Hooks global callback for clicking on a row
+ * * Sets up its sidebar, if any
+ *
+ * @param {Object} data wrapped fields_view_get result
+ * @param {Object} data.fields_view fields_view_get result (processed)
+ * @param {Object} data.fields_view.fields mapping of fields for the current model
+ * @param {Object} data.fields_view.arch current list view descriptor
+ * @param {Boolean} grouped Is the list view grouped
+ */
+ load_list: function(data) {
+ var self = this;
+ this.fields_view = data;
+ this.name = "" + this.fields_view.arch.attrs.string;
+
+ 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();
+ }
+
+ if (this.fields_view.arch.attrs.fonts) {
+ this.fonts = _(this.fields_view.arch.attrs.fonts.split(';')).chain().compact()
+ .map(function(font_pair) {
+ var pair = font_pair.split(':'),
+ font = pair[0],
+ expr = pair[1];
+ return [font, py.parse(py.tokenize(expr)), expr];
+ }).value();
+ }
+
+ this.setup_columns(this.fields_view.fields, this.grouped);
+
+ this.$el.html(QWeb.render(this._template, this));
+ this.$el.addClass(this.fields_view.arch.attrs['class']);
+
+ // Head hook
+ // Selecting records
+ this.$el.find('.oe_list_record_selector').click(function(){
+ self.$el.find('.oe_list_record_selector input').prop('checked',
+ self.$el.find('.oe_list_record_selector').prop('checked') || false);
+ var selection = self.groups.get_selection();
+ $(self.groups).trigger(
+ 'selected', [selection.ids, selection.records]);
+ });
+
+ // Add button
+ if (!this.$buttons) {
+ this.$buttons = $(QWeb.render("ListView.buttons", {'widget':self}));
+ if (this.options.$buttons) {
+ this.$buttons.appendTo(this.options.$buttons);
+ } else {
+ this.$el.find('.oe_list_buttons').replaceWith(this.$buttons);
+ }
+ this.$buttons.find('.oe_list_add')
+ .click(this.proxy('do_add_record'))
+ .prop('disabled', this.grouped);
+ }
+
+ // Pager
+ if (!this.$pager) {
+ this.$pager = $(QWeb.render("ListView.pager", {'widget':self})).hide();
+ if (this.options.$buttons) {
+ this.$pager.appendTo(this.options.$pager);
+ } else {
+ this.$el.find('.oe_list_pager').replaceWith(this.$pager);
+ }
+
+ this.$pager
+ .on('click', 'a[data-pager-action]', function () {
+ var $this = $(this);
+ var max_page_index = Math.ceil(self.dataset.size() / self.limit()) - 1;
+ switch ($this.data('pager-action')) {
+ case 'first':
+ self.page = 0;
+ break;
+ case 'last':
+ self.page = max_page_index;
+ break;
+ case 'next':
+ self.page += 1;
+ break;
+ case 'previous':
+ self.page -= 1;
+ break;
+ }
+ if (self.page < 0) {
+ self.page = max_page_index;
+ } else if (self.page > max_page_index) {
+ self.page = 0;
+ }
+ self.reload_content();
+ }).find('.oe_list_pager_state')
+ .click(function (e) {
+ e.stopPropagation();
+ var $this = $(this);
+
+ var $select = $('<select>')
+ .appendTo($this.empty())
+ .click(function (e) {e.stopPropagation();})
+ .append('<option value="80">80</option>' +
+ '<option value="200">200</option>' +
+ '<option value="500">500</option>' +
+ '<option value="2000">2000</option>' +
+ '<option value="NaN">' + _t("Unlimited") + '</option>')
+ .change(function () {
+ var val = parseInt($select.val(), 10);
+ self._limit = (isNaN(val) ? null : val);
+ self.page = 0;
+ self.reload_content();
+ }).blur(function() {
+ $(this).trigger('change');
+ })
+ .val(self._limit || 'NaN');
+ });
+ }
+
+ // Sidebar
+ if (!this.sidebar && this.options.$sidebar) {
+ this.sidebar = new Sidebar(this);
+ this.sidebar.appendTo(this.options.$sidebar);
+ this.sidebar.add_items('other', _.compact([
+ { label: _t("Export"), callback: this.on_sidebar_export },
+ self.is_action_enabled('delete') && { label: _t('Delete'), callback: this.do_delete_selected }
+ ]));
+ this.sidebar.add_toolbar(this.fields_view.toolbar);
+ this.sidebar.$el.hide();
+ }
+ //Sort
+ var default_order = this.fields_view.arch.attrs.default_order,
+ unsorted = !this.dataset._sort.length;
+ if (unsorted && default_order && !this.grouped) {
+ this.dataset.set_sort(default_order.split(','));
+ }
+
+ if(this.dataset._sort.length){
+ if(this.dataset._sort[0].indexOf('-') == -1){
+ this.$el.find('th[data-id=' + this.dataset._sort[0] + ']').addClass("sortdown");
+ }else {
+ this.$el.find('th[data-id=' + this.dataset._sort[0].split('-')[1] + ']').addClass("sortup");
+ }
+ }
+ this.trigger('list_view_loaded', data, this.grouped);
+ },
+ 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("sortdown") || $column.hasClass("sortup")) {
+ $column.toggleClass("sortup sortdown");
+ } else {
+ $column.addClass("sortdown");
+ }
+ $column.siblings('.oe_sortable').removeClass("sortup sortdown");
+
+ this.reload_content();
+ },
+ /**
+ * Configures the ListView pager based on the provided dataset's information
+ *
+ * Horrifying side-effect: sets the dataset's data on this.dataset?
+ *
+ * @param {instance.web.DataSet} dataset
+ */
+ configure_pager: function (dataset) {
+ this.dataset.ids = dataset.ids;
+ // Not exactly clean
+ if (dataset._length) {
+ this.dataset._length = dataset._length;
+ }
+
+ var total = dataset.size();
+ var limit = this.limit() || total;
+ this.$pager.find('.oe-pager-buttons').toggle(total > limit);
+ this.$pager.find('.oe_pager_value').toggle(total !== 0);
+ var spager = '-';
+ if (total) {
+ var range_start = this.page * limit + 1;
+ var range_stop = range_start - 1 + limit;
+ if (this.records.length) {
+ range_stop = range_start - 1 + this.records.length;
+ }
+ if (range_stop > total) {
+ range_stop = total;
+ }
+ spager = _.str.sprintf(_t("%d-%d of %d"), range_start, range_stop, total);
+ }
+
+ this.$pager.find('.oe_list_pager_state').text(spager);
+ },
+ /**
+ * 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', _t("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.$pager) {
+ this.$pager.show();
+ }
+ },
+ do_hide: function () {
+ if (this.sidebar) {
+ this.sidebar.$el.hide();
+ }
+ if (this.$pager) {
+ this.$pager.hide();
+ }
+ this._super();
+ },
+ /**
+ * Reloads the list view based on the current settings (dataset & al)
+ *
+ * @deprecated
+ * @param {Boolean} [grouped] Should the list be displayed grouped
+ * @param {Object} [context] context to send the server while loading the view
+ */
+ reload_view: function (grouped, context) {
+ return this.load_view(context);
+ },
+ /**
+ * re-renders the content of the list view
+ *
+ * @returns {$.Deferred} promise to content reloading
+ */
+ reload_content: synchronized(function () {
+ var self = this;
+ self.$el.find('.oe_list_record_selector').prop('checked', false);
+ this.records.reset();
+ var reloaded = $.Deferred();
+ reloaded.then(function () {
+ if (!self.grouped) self.$pager.show();
+ });
+ this.$el.find('.oe_list_content').append(
+ 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.compute_aggregates();
+ reloaded.resolve();
+ }));
+ this.do_push_state({
+ page: this.page,
+ 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}
+ ).done(function (records) {
+ var values = records[0];
+ if (!values) {
+ self.records.remove(record);
+ return;
+ }
+ _.each(values, function (value, key) {
+ if (fields[key] && fields[key].type === 'many2many')
+ record.set(key + '__display', false, {silent: true});
+ record.set(key, value, {silent: true});
+ });
+ record.trigger('change', record);
+ });
+ },
+
+ do_load_state: function(state, warm) {
+ var reload = false;
+ if (state.page && this.page !== state.page) {
+ this.page = state.page;
+ 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.page = 0;
+ 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;
+
+ return this.alive(this.load_view(context)).then(
+ this.proxy('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));
+ });
+ if (self.records.length === 0 && self.dataset.size() > 0) {
+ //Trigger previous manually to navigate to previous page,
+ //If all records are deleted on current page.
+ self.$pager.find('ul li:first a').trigger('click');
+ } else if (self.dataset.size() == self.limit()) {
+ //Reload listview to update current page with next page records
+ //because pager going to be hidden if dataset.size == limit
+ self.reload();
+ } else {
+ self.configure_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.$('.oe_list_record_selector').prop('checked', false);
+ }
+
+ if (!ids.length) {
+ this.dataset.index = 0;
+ if (this.sidebar) {
+ this.sidebar.$el.hide();
+ }
+ this.compute_aggregates();
+ return;
+ }
+
+ this.dataset.index = _(this.dataset.ids).indexOf(ids[0]);
+ if (this.sidebar) {
+ this.options.$sidebar.show();
+ this.sidebar.$el.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.dataset.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."));
+ }
+ },
+ /**
+ * 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 = {};
+ _(columns).each(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.$el.find('.oe_list_footer');
+ _(this.aggregate_columns).each(function (column) {
+ if (!column['function']) {
+ return;
+ }
+
+ $footer_cells.filter(_.str.sprintf('[data-field=%s]', column.id))
+ .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.$('.oe_list_record_selector').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.$el.find('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.$el.find('.oe_list_padding').remove();
+ if (this.previous_colspan) {
+ this.$el
+ .find('thead tr:first th')
+ .attr('colspan', this.previous_colspan);
+ this.previous_colspan = null;
+ }
+ },
+ no_result: function () {
+ this.$el.find('.oe_view_nocontent').remove();
+ if (this.groups.group_by
+ || !this.options.action
+ || !this.options.action.help) {
+ return;
+ }
+ this.$el.find('table:first').hide();
+ this.$el.prepend(
+ $('<div class="oe_view_nocontent">').html(this.options.action.help)
+ );
+ var $buttons = this.$buttons;
+ this.$el.find('.oe_view_nocontent').click(function() {
+ $buttons.width($buttons.width() + 1).openerpBounce();
+ });
+ }
+});
+core.view_registry.add('list', ListView);
+
+ListView.List = Class.extend( /** @lends instance.web.ListView.List# */{
+ /**
+ * 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') + ']');
+ }
+ $row.replaceWith(self.render_record(record));
+ },
+ '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('th.oe_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.oe_list_record_delete button', 'click', function (e) {
+ e.stopPropagation();
+ var $row = $(e.target).closest('tr');
+ $(self).trigger('deleted', [[self.row_id($row)]]);
+ })
+ .delegate('td.oe_list_field_cell 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) {
+ var command = value[0];
+ // 1. an array of m2m commands (usually (6, false, ids))
+ if (command[0] !== 6) {
+ throw new Error(_.str.sprintf( _t("Unknown m2m command %s"), command[0]));
+ }
+ ids = command[2];
+ } else {
+ // 2. an array of ids
+ ids = value;
+ }
+ new Model(column.relation)
+ .call('name_get', [ids, this.dataset.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);
+ });
+ // temp empty value
+ record.set(column.id, false);
+ }
+ }
+ return column.format(record.toForm().data, {
+ model: this.dataset.model,
+ id: record.get('id')
+ });
+ },
+ render: function () {
+ var self = this;
+ this.$current.empty().append(
+ QWeb.render('ListView.rows', _.extend({
+ render_cell: function () {
+ return self.render_cell.apply(self, arguments); }
+ }, this)));
+ 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('<th class="oe_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="oe_list_record_delete"><button type="button" style="visibility: hidden"> </button></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('th.oe_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 list display
+ */
+ on_records_reset: function () {
+ _(this.record_callbacks).each(function (callback, event) {
+ this.records.unbind(event, callback);
+ }, this);
+ if (!this.$current) { return; }
+ this.$current.remove();
+ },
+ 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( /** @lends instance.web.ListView.Groups# */{
+ 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.page = 0;
+
+ 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;
+ },
+ make_paginator: function () {
+ var self = this;
+ var $prev = $('<button type="button" data-pager-action="previous">&lt;</button>')
+ .click(function (e) {
+ e.stopPropagation();
+ if (self.page > 0) {
+ self.page -= 1;
+ } else {
+ self.page = self.max_page_index;
+ }
+
+ self.$row.closest('tbody').next()
+ .replaceWith(self.render());
+ });
+ var $next = $('<button type="button" data-pager-action="next">&gt;</button>')
+ .click(function (e) {
+ e.stopPropagation();
+ if (self.page < self.max_page_index) {
+ self.page += 1;
+ } else {
+ self.page = 0;
+ }
+
+ self.$row.closest('tbody').next()
+ .replaceWith(self.render());
+ });
+ this.$row.children().last()
+ .addClass('oe_list_group_pagination')
+ .append($prev)
+ .append('<span class="oe_list_pager_state"></span>')
+ .append($next);
+ },
+ open: function (point_insertion) {
+ this.render().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.make_paginator();
+ }
+ },
+ close: function () {
+ this.$row.children().last().find('button').remove();
+ this.$row.children().last().find('span').remove();
+ this.records.reset();
+ },
+ /**
+ * 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="oe_group_header">');
+ if (group.openable && group.length) {
+ $row.click(function (e) {
+ if (!$row.data('open')) {
+ $row.data('open', true)
+ .find('span.ui-icon')
+ .removeClass('ui-icon-triangle-1-e')
+ .addClass('ui-icon-triangle-1-s');
+ child.open(self.point_insertion(e.currentTarget));
+ } else {
+ $row.removeData('open')
+ .find('span.ui-icon')
+ .removeClass('ui-icon-triangle-1-s')
+ .addClass('ui-icon-triangle-1-e');
+ 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="oe_list_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;
+ 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(_t("%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="ui-icon ui-icon-triangle-1-e" style="float: left;">');
+ } else {
+ // Kinda-ugly hack: jquery-ui has no "empty" icon, so set
+ // wonky background position to ensure nothing is displayed
+ // there but the rest of the behavior is ui-icon's
+ $group_column.prepend('<span class="ui-icon" style="float: left; background-position: 150px 150px">');
+ }
+ }
+ 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,
+ limit = view.limit(),
+ page = this.datagroup.openable ? this.page : view.page;
+
+ var fields = _.pluck(_.select(this.columns, function(x) {return x.tag == "field";}), 'name');
+ var options = { offset: page * limit, limit: limit, context: {bin_size: true} };
+ //TODO xmo: investigate why we need to put the setTimeout
+ return $.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) {
+ view.configure_pager(dataset);
+ } else {
+ if (dataset.size() == records.length) {
+ // only one page
+ self.$row.find('td.oe_list_group_pagination').find('button').remove();
+ self.$row.find('td.oe_list_group_pagination').find('span').remove();
+ } else {
+ self.max_page_index = Math.ceil(dataset.size() / limit) - 1;
+ self.$row
+ .find('.oe_list_pager_state')
+ .text(_.str.sprintf(_t("%(page)d/%(page_count)d"), {
+ page: page + 1,
+ page_count: self.max_page_index + 1
+ }))
+ .end()
+ .find('button[data-pager-action=previous]')
+ .toggleClass('disabled', page === 0)
+ .end()
+ .find('button[data-pager-action=next]')
+ .toggleClass('disabled', page === self.max_page_index);
+ }
+ }
+
+ self.records.add(records, {silent: true});
+ list.render();
+ if (_.isEmpty(records)) {
+ view.no_result();
+ }
+ return list;
+ });
+ });
+ },
+ setup_resequence_rows: function (list, dataset) {
+ // drag and drop enabled if list is not sorted (unless it is sorted by
+ // sequence (ASC)), and there is a visible column with @widget=handle
+ // or "sequence" column in the view.
+ if ((dataset.sort && dataset.sort() && dataset.sort() !== 'sequence'
+ && dataset.sort() !== 'sequence ASC')
+ || !_(this.columns).any(function (column) {
+ return column.widget === 'handle'
+ || column.name === 'sequence'; })) {
+ return;
+ }
+ var sequence_field = _(this.columns).find(function (c) {
+ return c.widget === 'handle';
+ });
+ var seqname = sequence_field ? sequence_field.name : 'sequence';
+
+ // 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', '.oe_list_field_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);
+ var to = target_id ? list.records.indexOf(target) + 1 : 0;
+ list.records.add(to_move, { at: to });
+
+ // 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 fct = function (dataset, id, seq) {
+ $.async_when().done(function () {
+ var attrs = {};
+ attrs[seqname] = seq;
+ dataset.write(id, attrs);
+ });
+ };
+ 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
+ // FIXME: breaks on o2ms (e.g. Accounting > Financial
+ // Accounting > Taxes > Taxes, child tax accounts)
+ // when synchronous (without setTimeout)
+ fct(dataset, record.get('id'), seq);
+ record.set(seqname, seq);
+ }
+ }
+ });
+ },
+ render: function (post_render) {
+ var self = this;
+ var $el = $('<tbody>');
+ this.elements = [$el[0]];
+
+ this.datagroup.list(
+ _(this.view.visible_columns).chain()
+ .filter(function (column) { return column.tag === 'field';})
+ .pluck('name').value(),
+ function (groups) {
+ // page count is irrelevant on grouped page, replace by limit
+ self.view.$pager.find('.oe-pager-buttons').hide();
+ self.view.$pager.find('.oe_list_pager_state').text(self.view._limit ? self.view._limit : '∞');
+ $el[0].appendChild(
+ self.render_groups(groups));
+ if (post_render) { post_render(); }
+ }, function (dataset) {
+ 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');
+ });
+ });
+ return $el;
+ },
+ /**
+ * 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.children = {};
+ $(this.elements).remove();
+ },
+ 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);
+ $.when(query).done(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;
+ ifRecords(ds);
+ return;
+ }
+ // 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) {
+ 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') {
+ return {};
+ }
+ var aggregation_func = this['group_operator'] || 'sum';
+ if (!(aggregation_func in this)) {
+ return {};
+ }
+ var C = function (fn, label) {
+ this['function'] = fn;
+ this.label = label;
+ };
+ C.prototype = this;
+ return new C(aggregation_func, this[aggregation_func]);
+ },
+ /**
+ *
+ * @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;
+ }
+ return this._format(row_data, options);
+ },
+ /**
+ * 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));
+ }
+});
+
+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('<input type="checkbox" %s readonly="readonly"/>',
+ 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");
+ 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/binary/saveas', {model: options.model, field: this.id, id: options.id});
+ 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'}));
+ }
+ return _.template('<a href="<%-href%>"><%-text%></a> (<%-size%>)', {
+ text: text,
+ href: download_url,
+ size: utils.binary_to_binsize(value),
+ });
+ }
+});
+
+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;
+ },
+ /**
+ * Return styling hooks for a drag handle
+ *
+ * @private
+ */
+ _format: function (row_data, options) {
+ return '<i class="oe_list_handle fa fa-circle"/>';
+ }
+});
+
+var ColumnMany2OneButton = Column.extend({
+ _format: function (row_data, options) {
+ this.has_value = !!row_data[this.id].value;
+ this.icon = this.has_value ? 'gtk-yes' : 'gtk-no';
+ 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 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 ? 'gtk-yes' : 'gtk-normal';
+ this.string = has_value ? _t(button_tips ? button_tips['active']: ''): _t(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);
+
+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
new file mode 100644
index 00000000000..c4262fbce97
--- /dev/null
+++ b/addons/web/static/src/js/views/list_view_editable.js
@@ -0,0 +1,843 @@
+odoo.define('web.ListEditor', ['web.core', 'web.FormView', 'web.list_common', 'web.ListView', 'web.utils', 'web.Widget'], 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 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;
+
+ this.form = new (this.options.formView)(
+ this, this.delegate.dataset, false, {
+ initial_mode: 'edit',
+ disable_autofocus: true,
+ $buttons: $(),
+ $pager: $()
+ });
+ },
+ start: function () {
+ var self = this;
+ var _super = this._super();
+ this.form.embedded_view = this._validate_view(
+ this.delegate.edition_view(this));
+ var form_ready = this.form.appendTo(this.$el).done(
+ self.form.proxy('do_hide'));
+ return $.when(_super, form_ready);
+ },
+ _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 (!/\boe_form_container\b/.test(arch.attrs['class'])) {
+ throw new Error("Editor delegate's #edition_view must have the" +
+ " class 'oe_form_container' on its root" +
+ " element");
+ }
+
+ return edition_view;
+ },
+
+ /**
+ *
+ * @param {String} [state] either ``new`` or ``edit``
+ * @return {Boolean}
+ */
+ is_editing: function (state) {
+ if (!this.record) {
+ return false;
+ }
+ switch(state) {
+ case null: case undefined:
+ return true;
+ case 'new': return !this.record.id;
+ case 'edit': return !!this.record.id;
+ }
+ throw new Error("is_editing's state filter must be either `new` or" +
+ " `edit` if provided");
+ },
+ edit: function (record, configureField, options) {
+ // TODO: specify sequence of edit calls
+ var self = this;
+ var form = self.form;
+ var loaded = record
+ ? form.trigger('load_record', _.extend({}, record))
+ : form.load_defaults();
+ return $.when(loaded).then(function () {
+ return form.do_show({reload: false});
+ }).then(function () {
+ self.record = form.datarecord;
+ _(form.fields).each(function (field, name) {
+ configureField(name, field);
+ });
+ return form;
+ });
+ },
+ save: function () {
+ var self = this;
+ return this.form
+ .save(this.delegate.prepends_on_create())
+ .then(function (result) {
+ var created = result.created && !self.record.id;
+ if (created) {
+ self.record.id = result.result;
+ }
+ return self.cancel();
+ });
+ },
+ cancel: function (force) {
+ if (!(force || this.form.can_be_discarded())) {
+ return $.Deferred().reject({
+ message: _t("The form's data can not be discarded")}).promise();
+ }
+ var record = this.record;
+ this.record = null;
+ this.form.do_hide();
+ return $.when(record);
+ },
+});
+
+// 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 = this.make_editor();
+ // 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('oe_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('oe_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, _super = self._super, 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 () {
+ var self = this;
+ if (this.editable()) {
+ this.$el.find('table:first').show();
+ this.$el.find('.oe_view_nocontent').remove();
+ this.start_edition();
+ } else {
+ this._super();
+ }
+ },
+ load_list: function (data, grouped) {
+ var self = this;
+ // tree/@editable takes priority on everything else if present.
+ var result = this._super(data, grouped);
+
+ // 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();
+ // Editor is not restartable due to formview not being restartable
+ this.editor = this.make_editor();
+
+ if (this.editable()) {
+ this.$el.addClass('oe_list_editable');
+ // FIXME: any hook available to ensure this is only done once?
+ this.$buttons
+ .off('click', '.oe_list_save')
+ .on('click', '.oe_list_save', this.proxy('save_edition'))
+ .off('click', '.oe_list_discard')
+ .on('click', '.oe_list_discard', function (e) {
+ e.preventDefault();
+ self.cancel_edition();
+ });
+ var editor_ready = this.editor.prependTo(this.$el)
+ .done(this.proxy('setup_events'));
+
+ return $.when(result, editor_ready);
+ } else {
+ this.$el.removeClass('oe_list_editable');
+ }
+
+ return result;
+ },
+ /**
+ * Builds a new editor object
+ *
+ * @return {instance.web.list.Editor}
+ */
+ make_editor: function () {
+ return new Editor(this);
+ },
+ do_button_action: function (name, id, callback) {
+ var self = this, args = arguments;
+ this.ensure_saved().done(function (done) {
+ if (!id && done.created) {
+ id = done.record.get('id');
+ }
+ self.handle_button(name, id, callback);
+ });
+ },
+ /**
+ * Ensures the editable list is saved (saves any pending edition if
+ * needed, or tries to)
+ *
+ * Returns a deferred to the end of the saving.
+ *
+ * @returns {$.Deferred}
+ */
+ ensure_saved: function () {
+ return this.save_edition();
+ },
+ /**
+ * 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.ensure_saved().then(function(){
+ return $.when.apply(null, 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);
+ 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('oe_edition');
+ self.resize_fields();
+ var focus_field = options && options.focus_field ? options.focus_field : undefined;
+ if (!focus_field || !fields[focus_field]){
+ focus_field = _.find(self.editor.form.fields_order, function(field){ return fields[field] && fields[field].$el.is(':visible:has(input)'); });
+ }
+ if (focus_field && fields[focus_field]) fields[focus_field].$el.find('input').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);
+ }
+ });
+ });
+ },
+ 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());
+ field.$el.position({
+ my: 'left top',
+ at: 'left top',
+ of: $cell
+ });
+ if (field.get('effective_readonly')) {
+ field.$el.addClass('oe_readonly');
+ }
+ if(field.widget == "handle")
+ field.$el.addClass('oe_list_field_handle');
+ },
+ /**
+ * @return {jQuery.Deferred}
+ */
+ save_edition: function () {
+ 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.handle_onwrite(record)
+ .then(function () {
+ return self.reload_record(record); })
+ .then(function () {
+ return { created: created, record: record }; });
+ });
+ });
+ });
+ },
+ /**
+ * @param {Boolean} [force=false] discards the data even if the form has been edited
+ * @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) {
+ // Record removed by third party during edition
+ return;
+ }
+ 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': 'oe_form_container',
+ 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(); }
+ return this.dataset.call(on_write_callback, [source_record.get('id')])
+ .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();
+ }
+ var record = self.records[next_record](
+ saveInfo.record, {wraparound: true});
+ return self.start_edition(record, options);
+ });
+ },
+ keypress_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('edit')) { 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});
+
+ },
+ keydown_UP: function (e) {
+ var self = this;
+ return this._key_move_record(e, 'pred', function (el, cursor) {
+ return self._at_start(cursor, el);
+ });
+ },
+ keydown_DOWN: function (e) {
+ var self = this;
+ return this._key_move_record(e, 'succ', function (el, cursor) {
+ return self._at_end(cursor, el);
+ });
+ },
+
+ 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) {
+ 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 && last_field.$el.has(e.target).length) {
+ e.preventDefault();
+ return this._next();
+ }
+ 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);
+ }
+ 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(".oe_readonly").data('field')
+ });
+ },
+ /**
+ * 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') + ']');
+ if ($row.length) {
+ return $row;
+ }
+ return null;
+ }
+});
+
+return Editor;
+
+});
diff --git a/addons/web/static/src/js/views/pivot_view.js b/addons/web/static/src/js/views/pivot_view.js
new file mode 100644
index 00000000000..8ca6e2976fb
--- /dev/null
+++ b/addons/web/static/src/js/views/pivot_view.js
@@ -0,0 +1,800 @@
+odoo.define('web.PivotView', ['web.core', 'web.crash_manager', 'web.formats', 'web.framework', 'web.Model', 'web.session', 'web.Sidebar', 'web.utils', 'web.View'], function (require) {
+"use strict";
+/*---------------------------------------------------------
+ * Odoo Pivot Table view
+ *---------------------------------------------------------*/
+
+var core = require('web.core');
+var crash_manager = require('web.crash_manager');
+var formats = require('web.formats');
+var framework = require('web.framework');
+var Model = require('web.Model');
+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 total = _t("Total");
+
+var PivotView = View.extend({
+ template: 'PivotView',
+ display_name: _lt('Pivot'),
+ view_type: 'pivot',
+ events: {
+ 'click .oe-opened': 'on_open_header_click',
+ 'click .oe-closed': 'on_closed_header_click',
+ 'click .o-field-menu': 'on_field_menu_selection',
+ 'click td': 'on_cell_click',
+ 'click .measure-row': 'on_measure_row_click',
+ },
+
+ init: function(parent, dataset, view_id, options) {
+ this._super(parent, dataset, view_id, options);
+ this.model = new Model(dataset.model, {group_by_no_leaf: true});
+
+ this.$sidebar = options.$sidebar;
+ this.fields = {};
+ this.measures = {};
+ this.groupable_fields = {};
+ this.ready = false; // will be ready after the first do_search
+ this.data_loaded = $.Deferred();
+ this.title = options.title;
+
+ 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.last_header_selected = null;
+ this.sorted_column = {};
+ },
+ start: function () {
+ var self = this;
+ this.$table_container = this.$('.o-pivot-table');
+
+ var load_fields = this.model.call('fields_get', [])
+ .then(this.prepare_fields.bind(this));
+
+ if (this.$sidebar) {
+ this.rpc('/web/pivot/check_xlwt').then(function (result) {
+ if (result) {
+ self.sidebar = new Sidebar(self);
+ self.sidebar.appendTo(self.$sidebar);
+ self.sidebar.add_items('other', [{
+ label: _t("Download xls"),
+ callback: self.download_table.bind(self)
+ }]);
+ }
+ });
+ }
+ return $.when(this._super(), load_fields).then(this.render_buttons.bind(this));
+ },
+ render_buttons: function () {
+ var self = this;
+ var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
+ this.$buttons.html(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');
+ });
+
+ var another_ctx = {fields: _.chain(this.groupable_fields).pairs().sortBy(function(f){return f[1].string;}).value()};
+ this.$field_selection = this.$('.o-field-selection');
+ this.$field_selection.html(QWeb.render('PivotView.FieldSelection', another_ctx));
+ core.bus.on('click', self, function () {
+ self.$field_selection.find('ul').first().hide();
+ });
+ this.$buttons.find('button').tooltip();
+ },
+ view_loading: function (fvg) {
+ var self = this;
+ this.title = this.title || fvg.arch.attrs.string;
+ this.enable_linking = !fvg.arch.attrs.disable_linking;
+ this.$el.toggleClass('oe-enable-linking', this.enable_linking);
+ fvg.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.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 ((!self.active_measures.length) || fvg.arch.attrs.display_quantity){
+ self.active_measures.push('__count__');
+ }
+ },
+ 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') {
+ self.measures[name] = field;
+ }
+ if (_.contains(groupable_types, field.type)) {
+ self.groupable_fields[name] = field;
+ }
+ }
+ });
+ this.measures.__count__ = {string: "Quantity", 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 : 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;
+ if (this.sidebar) {
+ this.sidebar.$el.show();
+ }
+ this.do_push_state({});
+ this.data_loaded.done(function () {
+ self.display_table();
+ self.$el.show();
+ });
+ return this._super();
+ },
+ do_hide: function () {
+ if (this.sidebar) {
+ this.sidebar.$el.hide();
+ }
+ this._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('oe-pivot-flip')) { return this.flip();}
+ if ($target.hasClass('oe-pivot-expand-all')) {return this.expand_all();}
+ if ($target.parents('.oe-measure-list').length) {
+ var parent = $target.parent(),
+ field = parent.data('field');
+ parent.toggleClass('selected');
+ event.stopPropagation();
+ return this.toggle_measure(field);
+ }
+ if ($target.hasClass('oe-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 id = $(event.target).data('id'),
+ header = this.headers[id],
+ 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 {
+ var $test = $(event.target);
+ var pos = $test.position();
+ this.last_header_selected = id;
+ var $menu = this.$field_selection.find('ul').first();
+ $menu.css('top', pos.top + $test.parent().height() - 2);
+ $menu.css('left', pos.left + event.offsetX);
+ $menu.show();
+ event.stopPropagation();
+ }
+ },
+ on_cell_click: function (event) {
+ var $target = $(event.target);
+ if ($target.hasClass('oe-closed')
+ || $target.hasClass('oe-opened')
+ || $target.hasClass('oe-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');
+
+ return this.do_action({
+ type: 'ir.actions.act_window',
+ name: this.title,
+ res_model: this.model.name,
+ views: [[false, 'list'], [false, 'form']],
+ view_type : "list",
+ view_mode : "list",
+ target: 'current',
+ context: context,
+ domain: this.domain.concat(row_domain, col_domain),
+ });
+ },
+ 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-sorted-asc'));
+ this.display_table();
+ },
+ sort_rows: function (col_id, measure, descending) {
+ console.log('order', 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) {
+ var field = $(event.target).parent().data('field'),
+ interval = $(event.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 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;
+ },
+ make_header: function (data_pt, root, i, j, parent_header) {
+ 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;
+ 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);
+ },
+ display_table: function () {
+ if (!this.active_measures.length || !this.has_data) {
+ return this.$table_container.empty().append(QWeb.render('PivotView.nodata'));
+ }
+ var $fragment = $(document.createDocumentFragment()),
+ $table = $('<table>')
+ .addClass('table table-hover table-condensed')
+ .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.$table_container.empty().append($fragment);
+ this.$table_container.find('.oe-opened,.oe-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.groupable_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 (cell.total) {
+ $cell.addClass('oe-total');
+ }
+ if (i > 0) {
+ $cell.attr('title', groupby_labels[i-1]);
+ }
+ if (cell.expanded !== undefined) {
+ $cell.addClass(cell.expanded ? 'oe-opened' : 'oe-closed');
+ $cell.data('id', cell.id);
+ }
+ if (cell.measure) {
+ $cell.addClass('measure-row text-muted')
+ .text(this.measures[cell.measure].string)
+ .toggleClass('oe-total', cell.is_bold);
+ $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-sorted o-sorted-' + this.sorted_column.order);
+ }
+ }
+ $row.append($cell);
+ }
+ $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.groupable_fields[gb.split(':')[0]].string;
+ });
+ var measure_types = this.active_measures.map(function (name) {
+ return self.measures[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 ? 'oe-opened' : 'oe-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]});
+ $cell = $('<td>')
+ .data('id', rows[i].id)
+ .data('col_id', rows[i].col_ids[Math.floor(j / nbr_measures)])
+ .toggleClass('oe-empty', !value)
+ .text(value);
+ if (((j >= length - this.active_measures.length) && display_total) || i === 0){
+ $cell.css('font-weight', 'bold');
+ }
+ $row.append($cell);
+ }
+ $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.title = this.measures[this.active_measures[0]].string;
+ 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,
+ };
+ session.get_file({
+ url: '/web/pivot/export_xls',
+ data: {data: JSON.stringify(table)},
+ complete: framework.unblockUI,
+ error: crash_manager.rpc_error.bind(crash_manager)
+ });
+ },
+});
+
+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
new file mode 100644
index 00000000000..dfc0d0bf4bc
--- /dev/null
+++ b/addons/web/static/src/js/views/search_filters.js
@@ -0,0 +1,311 @@
+odoo.define('web.search_filters', ['web.core', 'web.datepicker', 'web.formats', 'web.Widget'], 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 .searchview_extended_prop_field': 'changed',
+ 'change .searchview_extended_prop_op': 'operator_changed',
+ 'click .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.$(".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) {
+ var $value = this.$('.searchview_extended_prop_value');
+ switch ($(e.target).val()) {
+ case '∃':
+ case '∄':
+ $value.hide();
+ break;
+ default:
+ $value.show();
+ }
+ },
+ /**
+ * 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.$('.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.$('.searchview_extended_prop_op'));
+ });
+ var $value_loc = this.$('.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.$('.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 this.make_domain(field.name, '!=', false);
+ case '∄': return this.make_domain(field.name, '=', false);
+ default: return this.make_domain(
+ field.name, operator.value, this.get_value());
+ }
+ },
+ make_domain: function (field, operator, value) {
+ return [field, operator, value];
+ },
+ /**
+ * 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({
+ template: 'SearchView.extended_search.proposition.char',
+ 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({
+ template: 'SearchView.extended_search.proposition.empty',
+ 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 or equal than")},
+ {value: "<=", text: _lt("less or equal than")},
+ {value: "∃", text: _lt("is set")},
+ {value: "∄", text: _lt("is not set")}
+ ],
+ widget: function () { return datepicker.DateTimeWidget; },
+ get_value: function() {
+ return this.datewidget.get_value();
+ },
+ toString: function () {
+ return formats.format_value(this.get_value(), { type:"datetime" });
+ },
+ start: function() {
+ var ready = this._super();
+ this.datewidget = new (this.widget())(this);
+ this.datewidget.appendTo(this.$el);
+ return ready;
+ }
+});
+
+ExtendedSearchProposition.Date = ExtendedSearchProposition.DateTime.extend({
+ widget: function () { return datepicker.DateWidget; },
+ toString: function () {
+ return formats.format_value(this.get_value(), { type:"date" });
+ }
+});
+
+ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({
+ template: 'SearchView.extended_search.proposition.integer',
+ 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 or equal than")},
+ {value: "<=", text: _lt("less or equal than")},
+ {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 or equal than")},
+ {value: "<=", text: _lt("less or equal than")},
+ {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.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({
+ template: 'SearchView.extended_search.proposition.empty',
+ 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('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
new file mode 100644
index 00000000000..97678cc60c8
--- /dev/null
+++ b/addons/web/static/src/js/views/search_inputs.js
@@ -0,0 +1,736 @@
+odoo.define('web.search_inputs', ['web.core', 'web.data', 'web.formats', 'web.Model', 'web.pyeval', 'web.time', 'web.utils', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var data = require('web.data');
+var formats = require('web.formats');
+var Model = require('web.Model');
+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.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) {
+ 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) {
+ var m = moment(needle);
+ 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()]);
+ return this.model.call('name_search', [], {
+ name: needle,
+ args: (typeof this.attrs.domain === 'string') ? [] : this.attrs.domain,
+ 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];
+ }
+ return this.model.call('name_get', [value]).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,
+ 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.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("GroupBy"),
+ icon: this.icon,
+ 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('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
new file mode 100644
index 00000000000..56df4657c48
--- /dev/null
+++ b/addons/web/static/src/js/views/search_menus.js
@@ -0,0 +1,437 @@
+odoo.define('web.FavoriteMenu', ['web.core', 'web.Model','web.pyeval', 'web.session', 'web.Widget'], function (require) {
+"use strict";
+
+var core = require('web.core');
+var Model = require('web.Model');
+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 .oe-save-search a': function () {
+ this.toggle_save_menu();
+ },
+ 'click .oe-save-name button': 'save_favorite',
+ 'hidden.bs.dropdown': 'close_menus',
+ },
+ init: function (parent, query, target_model, action_id) {
+ this._super.apply(this,arguments);
+ this.searchview = parent;
+ this.query = query;
+ this.target_model = target_model;
+ this.model = new Model('ir.filters');
+ this.filters = {};
+ this.$filters = {};
+ this.action_id = action_id;
+ },
+ start: function () {
+ var self = this;
+ this.$save_search = this.$('.oe-save-search');
+ this.$save_name = this.$('.oe-save-name');
+ this.$inputs = this.$save_name.find('input');
+ this.$divider = this.$('.divider');
+ this.$inputs.eq(0).val(this.searchview.getParent().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'));
+ if (!this.action_id) {
+ this.prepare_dropdown_menu([]);
+ return $.when();
+ }
+ return this.model.call('get_filters', [this.target_model, this.action_id])
+ .done(this.proxy('prepare_dropdown_menu'));
+ },
+ prepare_dropdown_menu: function (filters) {
+ filters.map(this.append_filter.bind(this));
+ },
+ toggle_save_menu: function (is_open) {
+ this.$save_search
+ .toggleClass('closed-menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
+ .toggleClass('open-menu', is_open);
+ this.$save_name.toggle(is_open);
+ if (this.$save_search.hasClass('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.searchview.dataset.model,
+ context: results.context,
+ domain: results.domain,
+ is_default: default_filter,
+ action_id: this.action_id,
+ };
+ return this.model.call('create_or_replace', [filter]).done(function (id) {
+ filter.id = id;
+ self.toggle_save_menu(false);
+ self.$save_name.find('input').val('').prop('checked', false);
+ 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');
+ },
+ append_filter: function (filter) {
+ var self = this,
+ key = this.key_for(filter),
+ $filter;
+
+ this.$divider.show();
+ if (key in this.$filters) {
+ $filter = this.$filters[key];
+ } else {
+ this.filters[key] = filter;
+ $filter = $('<li></li>')
+ .insertBefore(this.$divider)
+ .toggleClass('oe_searchview_custom_default', filter.is_default)
+ .append($('<a>').text(filter.name));
+
+ this.$filters[key] = $filter;
+ this.$filters[key].addClass(filter.user_id ? 'oe_searchview_custom_private'
+ : 'oe_searchview_custom_public');
+ $('<span>')
+ .addClass('fa fa-trash-o remove-filter')
+ .click(function (event) {
+ event.stopImmediatePropagation();
+ self.remove_filter(filter, $filter, key);
+ })
+ .appendTo($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;
+ }
+ this.model.call('unlink', [filter.id]).done(function () {
+ $filter.remove();
+ delete self.$filters[key];
+ delete self.filters[key];
+ if (_.isEmpty(self.filters)) {
+ self.$divider.hide();
+ }
+ });
+ },
+});
+
+});
+
+odoo.define('web.FilterMenu', ['web.Widget', 'web.search_filters', 'web.search_inputs'], function (require) {
+
+var search_filters = require('web.search_filters'),
+ search_inputs = require('web.search_inputs'),
+ Widget = require('web.Widget');
+
+return Widget.extend({
+ template: 'SearchView.FilterMenu',
+ events: {
+ 'click .oe-add-filter': function () {
+ this.toggle_custom_filter_menu();
+ },
+ 'click li': function (event) {event.stopImmediatePropagation();},
+ 'hidden.bs.dropdown': function () {
+ this.toggle_custom_filter_menu(false);
+ },
+ 'click .oe-add-condition': 'append_proposition',
+ 'click .oe-apply-filter': 'commit_search',
+ },
+ init: function (parent, filters, fields_def) {
+ this._super(parent);
+ this.filters = filters || [];
+ this.searchview = parent;
+ this.propositions = [];
+ this.fields_def = fields_def.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;
+ });
+ },
+ start: function () {
+ var self = this;
+ this.$menu = this.$('.filters-menu');
+ this.$add_filter = this.$('.oe-add-filter');
+ this.$apply_filter = this.$('.oe-apply-filter');
+ this.$add_filter_menu = this.$('.oe-add-filter-menu');
+ _.each(this.filters, function (group) {
+ if (group.is_visible()) {
+ group.insertBefore(self.$add_filter);
+ $('<li class="divider">').insertBefore(self.$add_filter);
+ }
+ });
+ this.append_proposition().then(function (prop) {
+ prop.$el.hide();
+ });
+ },
+ update_max_height: function () {
+ var max_height = $(window).height() - this.$menu[0].getBoundingClientRect().top - 10;
+ this.$menu.css('max-height', max_height);
+ },
+ toggle_custom_filter_menu: function (is_open) {
+ this.$add_filter
+ .toggleClass('closed-menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
+ .toggleClass('open-menu', is_open);
+ this.$add_filter_menu.toggle(is_open);
+ if (this.$add_filter.hasClass('closed-menu') && (!this.propositions.length)) {
+ this.append_proposition();
+ }
+ this.$('.oe-filter-condition').toggle(is_open);
+ this.update_max_height();
+ },
+ append_proposition: function () {
+ var self = this;
+ return this.fields_def.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);
+ self.update_max_height();
+ 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', ['web.Widget', 'web.core', 'web.search_inputs'], function (require) {
+
+var Widget = require('web.Widget'),
+ core = require('web.core'),
+ search_inputs = require('web.search_inputs');
+
+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 .add-custom-group a': function () {
+ this.toggle_add_menu();
+ },
+ },
+ init: function (parent, groups, fields_def) {
+ this._super(parent);
+ this.groups = groups || [];
+ this.groupable_fields = {};
+ this.searchview = parent;
+ this.fields_def = fields_def.then(this.proxy('get_groupable_fields'));
+ },
+ start: function () {
+ var self = this;
+ this.$menu = this.$('.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('.add-custom-group');
+ this.fields_def.then(function () {
+ self.$menu.append(QWeb.render('GroupByMenuSelector', self));
+ self.$add_group_menu = self.$('.oe-add-group');
+ self.$group_selector = self.$('.oe-group-selector');
+ self.$('.oe-select-group').click(function () {
+ self.toggle_add_menu(false);
+ var field = self.$group_selector.find(':selected').data('name');
+ self.add_groupby_to_menu(field);
+ });
+ });
+ },
+ get_groupable_fields: function (fields) {
+ var self = this,
+ groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
+
+ _.each(fields, function (field, name) {
+ if (field.store && _.contains(groupable_types, field.type)) {
+ self.groupable_fields[name] = field;
+ }
+ });
+ },
+ toggle_add_menu: function (is_open) {
+ this.$add_group
+ .toggleClass('closed-menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
+ .toggleClass('open-menu', is_open);
+ this.$add_group_menu.toggle(is_open);
+ if (this.$add_group.hasClass('open-menu')) {
+ this.$group_selector.focus();
+ }
+ },
+ add_groupby_to_menu: function (field_name) {
+ var filter = new search_inputs.Filter({attrs:{
+ context:"{'group_by':'" + field_name + "''}",
+ name: this.groupable_fields[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
new file mode 100644
index 00000000000..a459dfe666b
--- /dev/null
+++ b/addons/web/static/src/js/views/search_view.js
@@ -0,0 +1,945 @@
+odoo.define('web.SearchView', ['web.AutoComplete', 'web.core', 'web.FavoriteMenu', 'web.FilterMenu', 'web.GroupByMenu', 'web.Model', 'web.pyeval', 'web.search_inputs', 'web.utils', 'web.Widget'], function (require) {
+"use strict";
+
+var AutoComplete = require('web.AutoComplete');
+var core = require('web.core');
+var FavoriteMenu = require('web.FavoriteMenu');
+var FilterMenu = require('web.FilterMenu');
+var GroupByMenu = require('web.GroupByMenu');
+var Model = require('web.Model');
+var pyeval = require('web.pyeval');
+var search_inputs = require('web.search_inputs');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+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.text(''); this.trigger('blurred', this); },
+ keydown: 'onKeydown',
+ paste: 'onPaste',
+ },
+ getSelection: function () {
+ this.el.normalize();
+ // get Text node
+ var root = this.el.childNodes[0];
+ if (!root || !root.textContent) {
+ // if input does not have a child node, or the child node is an
+ // empty string, then the selection can only be (0, 0)
+ return {start: 0, end: 0};
+ }
+ var range = window.getSelection().getRangeAt(0);
+ // In Firefox, depending on the way text is selected (drag, double- or
+ // triple-click) the range may start or end on the parent of the
+ // selected text node‽ Check for this condition and fixup the range
+ // note: apparently with C-a this can go even higher?
+ if (range.startContainer === this.el && range.startOffset === 0) {
+ range.setStart(root, 0);
+ }
+ if (range.endContainer === this.el && range.endOffset === 1) {
+ range.setEnd(root, root.length);
+ }
+ utils.assert(range.startContainer === root,
+ "selection should be in the input view");
+ utils.assert(range.endContainer === root,
+ "selection should be in the input view");
+ return {
+ start: range.startOffset,
+ end: range.endOffset
+ };
+ },
+ onKeydown: function (e) {
+ this.el.normalize();
+ var sel;
+ switch (e.which) {
+ // Do not insert newline, but let it bubble so searchview can use it
+ case $.ui.keyCode.ENTER:
+ e.preventDefault();
+ break;
+
+ // FIXME: may forget content if non-empty but caret at index 0, ok?
+ case $.ui.keyCode.BACKSPACE:
+ sel = this.getSelection();
+ if (sel.start === 0 && sel.start === sel.end) {
+ e.preventDefault();
+ var preceding = this.getParent().siblingSubview(this, -1);
+ if (preceding && (preceding instanceof FacetView)) {
+ preceding.model.destroy();
+ }
+ }
+ break;
+
+ // let left/right events propagate to view if caret is at input border
+ // and not a selection
+ case $.ui.keyCode.LEFT:
+ sel = this.getSelection();
+ if (sel.start !== 0 || sel.start !== sel.end) {
+ e.stopPropagation();
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ sel = this.getSelection();
+ var len = this.$el.text().length;
+ if (sel.start !== len || sel.start !== sel.end) {
+ e.stopPropagation();
+ }
+ break;
+ }
+ },
+ setCursorAtEnd: function () {
+ this.el.normalize();
+ var sel = window.getSelection();
+ sel.removeAllRanges();
+ var range = document.createRange();
+ // in theory, range.selectNodeContents should work here. In practice,
+ // MSIE9 has issues from time to time, instead of selecting the inner
+ // text node it would select the reference node instead (e.g. in demo
+ // data, company news, copy across the "Company News" link + the title,
+ // from about half the link to half the text, paste in search box then
+ // hit the left arrow key, getSelection would blow up).
+ //
+ // Explicitly selecting only the inner text node (only child node
+ // since we've normalized the parent) avoids the issue
+ range.selectNode(this.el.childNodes[0]);
+ range.collapse(false);
+ sel.addRange(range);
+ },
+ onPaste: function () {
+ this.el.normalize();
+ // In MSIE and Webkit, it is possible to get various representations of
+ // the clipboard data at this point e.g.
+ // window.clipboardData.getData('Text') and
+ // event.clipboardData.getData('text/plain') to ensure we have a plain
+ // text representation of the object (and probably ensure the object is
+ // pastable as well, so nobody puts an image in the search view)
+ // (nb: since it's not possible to alter the content of the clipboard
+ // — at least in Webkit — to ensure only textual content is available,
+ // using this would require 1. getting the text data; 2. manually
+ // inserting the text data into the content; and 3. cancelling the
+ // paste event)
+ //
+ // But Firefox doesn't support the clipboard API (as of FF18)
+ // although it correctly triggers the paste event (Opera does not even
+ // do that) => implement lowest-denominator system where onPaste
+ // triggers a followup "cleanup" pass after the data has been pasted
+ setTimeout(function () {
+ // Read text content (ignore pasted HTML)
+ var data = this.$el.text();
+ if (!data)
+ return;
+ // paste raw text back in
+ this.$el.empty().text(data);
+ this.el.normalize();
+ // Set the cursor at the end of the text, so the cursor is not lost
+ // in some kind of error-spawning limbo.
+ this.setCursorAtEnd();
+ }.bind(this), 0);
+ }
+});
+
+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).is('.oe_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.$('> span:last-child');
+ return $.when(this._super()).then(function () {
+ return $.when.apply(null, self.model.values.map(function (value) {
+ 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 = Widget.extend(/** @lends instance.web.SearchView# */{
+ template: "SearchView",
+ events: {
+ // focus last input if view itself is clicked
+ 'click': function (e) {
+ if (e.target === this.$('.oe_searchview_facets')[0]) {
+ this.$('.oe_searchview_input:last').focus();
+ }
+ },
+ // search button
+ 'click div.oe_searchview_search': function (e) {
+ e.stopImmediatePropagation();
+ this.do_search();
+ },
+ 'click .oe_searchview_unfold_drawer': function (e) {
+ e.stopImmediatePropagation();
+ $(e.target).toggleClass('fa-caret-down fa-caret-up');
+ localStorage.visible_search_menu = (localStorage.visible_search_menu !== 'true');
+ this.toggle_buttons();
+ },
+ 'keydown .oe_searchview_input, .oe_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;
+ }
+ },
+ 'autocompleteopen': function () {
+ this.$el.autocomplete('widget').css('z-index', 9999);
+ },
+ },
+ /**
+ * @constructs instance.web.SearchView
+ * @extends instance.web.Widget
+ *
+ * @param parent
+ * @param dataset
+ * @param view_id
+ * @param defaults
+ * @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(parent, dataset, view_id, defaults, options) {
+ this.options = _.defaults(options || {}, {
+ hidden: false,
+ disable_custom_filters: false,
+ });
+ this._super(parent);
+ this.query = undefined;
+ this.dataset = dataset;
+ this.view_id = view_id;
+ this.search_fields = [];
+ this.filters = [];
+ this.groupbys = [];
+ this.visible_filters = (localStorage.visible_search_menu === 'true');
+ this.input_subviews = []; // for user input in searchbar
+ this.defaults = defaults || {};
+ this.headless = this.options.hidden && _.isEmpty(this.defaults);
+ this.$buttons = this.options.$buttons;
+
+ this.filter_menu = undefined;
+ this.groupby_menu = undefined;
+ this.favorite_menu = undefined;
+ this.action_id = this.options && this.options.action && this.options.action.id;
+ },
+ start: function() {
+ if (this.headless) {
+ this.$el.hide();
+ }
+ this.toggle_visibility(false);
+ this.$facets_container = this.$('div.oe_searchview_facets');
+ 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'));
+ var load_view = this.dataset._model.fields_view_get({
+ view_id: this.view_id,
+ view_type: 'search',
+ context: this.dataset.get_context(),
+ });
+ this.$('.oe_searchview_unfold_drawer')
+ .toggleClass('fa-caret-down', !this.visible_filters)
+ .toggleClass('fa-caret-up', this.visible_filters);
+ return this.alive($.when(this._super(), this.alive(load_view).then(this.view_loaded.bind(this))));
+ },
+ view_loaded: function (r) {
+ var custom_filters_ready;
+ this.fields_view_get = r;
+ this.view_id = this.view_id || r.view_id;
+ this.prepare_search_inputs();
+ if (this.$buttons) {
+
+ var fields_def = new Model(this.dataset.model).call('fields_get', {
+ context: this.dataset.context
+ });
+
+ this.groupby_menu = new GroupByMenu(this, this.groupbys, fields_def);
+ this.filter_menu = new FilterMenu(this, this.filters, fields_def);
+ this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action_id);
+
+ this.filter_menu.appendTo(this.$buttons);
+ this.groupby_menu.appendTo(this.$buttons);
+ custom_filters_ready = this.favorite_menu.appendTo(this.$buttons);
+ }
+ return $.when(custom_filters_ready).then(this.proxy('set_default_filters'));
+ },
+ 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.defaults)) {
+ var inputs = this.search_fields.concat(this.filters, this.groupbys),
+ defaults = _.invoke(inputs, 'facet_for_defaults', this.defaults);
+ return $.when.apply(null, 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.$el.toggle(!this.headless && is_visible);
+ if (this.$buttons) {
+ this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
+ }
+ if (!this.headless && is_visible) {
+ this.$('div.oe_searchview_input').last().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'),
+ delay: 0,
+ get_search_string: function () {
+ return self.$('div.oe_searchview_input').text();
+ },
+ });
+ 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();
+ var input_index = _(this.input_subviews).indexOf(
+ this.subviewForRoot(
+ this.$('div.oe_searchview_input:focus')[0]));
+ this.query.add(ui.item.facet, {at: input_index / 2});
+ },
+ 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 = [];
+
+ var i = new InputView(this);
+ started.push(i.appendTo(this.$facets_container));
+ this.input_subviews.push(i);
+ this.query.each(function (facet) {
+ var f = new FacetView(this, facet);
+ started.push(f.appendTo(self.$facets_container));
+ self.input_subviews.push(f);
+
+ var i = new InputView(this);
+ started.push(i.appendTo(self.$facets_container));
+ self.input_subviews.push(i);
+ }, this);
+ _.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 () {
+ if (options && options.focus_input === false) return;
+ var input_to_focus;
+ // options.at: facet inserted at given index, focus next input
+ // otherwise just focus last input
+ if (!options || typeof options.at !== 'number') {
+ input_to_focus = _.last(self.input_subviews);
+ } else {
+ input_to_focus = self.input_subviews[(options.at + 1) * 2];
+ }
+ input_to_focus.$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_get.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,
+ field = self.fields_view_get.fields[attrs.name],
+ 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;
+
+});
+
+odoo.define('web.AutoComplete', ['web.Widget'], function (require) {
+"use strict";
+
+var Widget = require('web.Widget');
+
+return Widget.extend({
+ template: "SearchView.autocomplete",
+
+ // Parameters for autocomplete constructor:
+ //
+ // parent: this is used to detect keyboard events
+ //
+ // options.source: function ({term:query}, callback). This function will be called to
+ // obtain the search results corresponding to the query string. It is assumed that
+ // options.source will call callback with the results.
+ // options.delay: delay in millisecond before calling source. Useful if you don't want
+ // to make too many rpc calls
+ // options.select: function (ev, {item: {facet:facet}}). Autocomplete widget will call
+ // that function when a selection is made by the user
+ // options.get_search_string: function (). This function will be called by autocomplete
+ // to obtain the current search string.
+ init: function (parent, options) {
+ this._super(parent);
+ this.$input = parent.$el;
+ this.source = options.source;
+ this.delay = options.delay;
+ this.select = options.select;
+ this.get_search_string = options.get_search_string;
+
+ this.current_result = null;
+
+ this.searching = true;
+ this.search_string = null;
+ this.current_search = null;
+ },
+ start: function () {
+ var self = this;
+ this.$input.on('keyup', function (ev) {
+ if (ev.which === $.ui.keyCode.RIGHT) {
+ self.searching = true;
+ ev.preventDefault();
+ return;
+ }
+ // ENTER is caugth at KeyUp rather than KeyDown to avoid firing
+ // before all regular keystrokes have been processed
+ if (ev.which === $.ui.keyCode.ENTER) {
+ if (self.current_result && self.get_search_string().length) {
+ self.select_item(ev);
+ }
+ return;
+ }
+ var search_string = self.get_search_string();
+ if (self.search_string !== search_string) {
+ if (search_string.length) {
+ self.search_string = search_string;
+ setTimeout(function () { self.initiate_search(search_string);}, self.delay);
+ } else {
+ self.close();
+ }
+ }
+ });
+ this.$input.on('keypress', function (ev) {
+ self.search_string = self.get_search_string() + String.fromCharCode(ev.which);
+ if (self.search_string.length) {
+ self.searching = true;
+ var search_string = self.search_string;
+ setTimeout(function () { self.initiate_search(search_string);}, self.delay);
+ } else {
+ self.close();
+ }
+ });
+ this.$input.on('keydown', function (ev) {
+ switch (ev.which) {
+ // TAB and direction keys are handled at KeyDown because KeyUp
+ // is not guaranteed to fire.
+ // See e.g. https://github.com/aef-/jquery.masterblaster/issues/13
+ case $.ui.keyCode.TAB:
+ if (self.current_result && self.get_search_string().length) {
+ self.select_item(ev);
+ }
+ break;
+ case $.ui.keyCode.DOWN:
+ self.move('down');
+ self.searching = false;
+ ev.preventDefault();
+ break;
+ case $.ui.keyCode.UP:
+ self.move('up');
+ self.searching = false;
+ ev.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ self.searching = false;
+ var current = self.current_result;
+ if (current && current.expand && !current.expanded) {
+ self.expand();
+ self.searching = true;
+ }
+ ev.preventDefault();
+ break;
+ case $.ui.keyCode.ESCAPE:
+ self.close();
+ self.searching = false;
+ break;
+ }
+ });
+ },
+ initiate_search: function (query) {
+ if (query === this.search_string && query !== this.current_search) {
+ this.search(query);
+ }
+ },
+ search: function (query) {
+ var self = this;
+ this.current_search = query;
+ this.source({term:query}, function (results) {
+ if (results.length) {
+ self.render_search_results(results);
+ self.focus_element(self.$('li:first-child'));
+ } else {
+ self.close();
+ }
+ });
+ },
+ render_search_results: function (results) {
+ var self = this;
+ var $list = this.$('ul');
+ $list.empty();
+ var render_separator = false;
+ results.forEach(function (result) {
+ if (result.is_separator) {
+ if (render_separator)
+ $list.append($('<li>').addClass('oe-separator'));
+ render_separator = false;
+ } else {
+ var $item = self.make_list_item(result).appendTo($list);
+ result.$el = $item;
+ render_separator = true;
+ }
+ });
+ this.show();
+ },
+ make_list_item: function (result) {
+ var self = this;
+ var $li = $('<li>')
+ .hover(function () {self.focus_element($li);})
+ .mousedown(function (ev) {
+ if (ev.button === 0) { // left button
+ self.select(ev, {item: {facet: result.facet}});
+ self.close();
+ } else {
+ ev.preventDefault();
+ }
+ })
+ .data('result', result);
+ if (result.expand) {
+ var $expand = $('<span class="oe-expand">').text('▶').appendTo($li);
+ $expand.mousedown(function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (result.expanded)
+ self.fold();
+ else
+ self.expand();
+ });
+ result.expanded = false;
+ }
+ if (result.indent) $li.addClass('oe-indent');
+ $li.append($('<span>').html(result.label));
+ return $li;
+ },
+ expand: function () {
+ var self = this;
+ this.current_result.expand(this.get_search_string()).then(function (results) {
+ (results || [{label: '(no result)'}]).reverse().forEach(function (result) {
+ result.indent = true;
+ var $li = self.make_list_item(result);
+ self.current_result.$el.after($li);
+ });
+ self.current_result.expanded = true;
+ self.current_result.$el.find('span.oe-expand').html('▼');
+ });
+ },
+ fold: function () {
+ var $next = this.current_result.$el.next();
+ while ($next.hasClass('oe-indent')) {
+ $next.remove();
+ $next = this.current_result.$el.next();
+ }
+ this.current_result.expanded = false;
+ this.current_result.$el.find('span.oe-expand').html('▶');
+ },
+ focus_element: function ($li) {
+ this.$('li').removeClass('oe-selection-focus');
+ $li.addClass('oe-selection-focus');
+ this.current_result = $li.data('result');
+ },
+ select_item: function (ev) {
+ if (this.current_result.facet) {
+ this.select(ev, {item: {facet: this.current_result.facet}});
+ this.close();
+ }
+ },
+ show: function () {
+ this.$el.show();
+ },
+ close: function () {
+ this.current_search = null;
+ this.search_string = null;
+ this.searching = true;
+ this.$el.hide();
+ },
+ move: function (direction) {
+ var $next;
+ if (direction === 'down') {
+ $next = this.$('li.oe-selection-focus').nextAll(':not(.oe-separator)').first();
+ if (!$next.length) $next = this.$('li:first-child');
+ } else {
+ $next = this.$('li.oe-selection-focus').prevAll(':not(.oe-separator)').first();
+ if (!$next.length) $next = this.$('li:not(.oe-separator)').last();
+ }
+ this.focus_element($next);
+ },
+ is_expandable: function () {
+ return !!this.$('.oe-selection-focus .oe-expand').length;
+ },
+});
+});
diff --git a/addons/web/static/src/js/views/tree_view.js b/addons/web/static/src/js/views/tree_view.js
new file mode 100644
index 00000000000..7c3950a5407
--- /dev/null
+++ b/addons/web/static/src/js/views/tree_view.js
@@ -0,0 +1,280 @@
+odoo.define('web.TreeView', ['web.core', 'web.data', 'web.formats', 'web.pyeval', 'web.session', 'web.View'], 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(/** @lends instance.web.TreeView# */{
+ display_name: _lt('Tree'),
+ view_type: 'tree',
+ /**
+ * Indicates that this view is not searchable, and thus that no search
+ * view should be displayed (if there is one active).
+ */
+ searchable : false,
+ /**
+ * Genuine tree view (the one displayed as a tree, not the list)
+ *
+ * @constructs instance.web.TreeView
+ * @extends instance.web.View
+ *
+ * @param parent
+ * @param dataset
+ * @param view_id
+ * @param options
+ */
+ init: function(parent, dataset, view_id, options) {
+ this._super(parent);
+ this.dataset = dataset;
+ this.model = dataset.model;
+ this.view_id = view_id;
+
+ this.records = {};
+
+ this.options = _.extend({}, this.defaults, options || {});
+
+ _.bindAll(this, 'color_for');
+ },
+
+ view_loading: function(r) {
+ return this.load_tree(r);
+ },
+
+ /**
+ * 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;
+ },
+ load_tree: function (fields_view) {
+ var self = this;
+ var has_toolbar = !!fields_view.arch.attrs.toolbar;
+ // field name in OpenERP is kinda stupid: this is the name of the field
+ // holding the ids to the children of the current node, why call it
+ // field_parent?
+ this.children_field = fields_view.field_parent;
+ this.fields_view = fields_view;
+ _(this.fields_view.arch.children).each(function (field) {
+ if (field.attrs.modifiers) {
+ field.attrs.modifiers = JSON.parse(field.attrs.modifiers);
+ }
+ });
+ this.fields = fields_view.fields;
+ 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.$el.find('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({});
+
+ if (!this.fields_view.arch.attrs.colors) {
+ return;
+ }
+ 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();
+ },
+ /**
+ * 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.$el.find('[id=treerow_' + childid + '][data-row-parent-id='+ record_id +']').length ) {
+ if (self.$el.find('[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('oe_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.$el.find('#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('oe_open');
+ $curr_node.after(children_rows);
+ } else {
+ self.$el.find('tbody').html(children_rows);
+ }
+ });
+ },
+
+ // Get details in listview
+ activate: function(id) {
+ var self = this;
+ var local_context = {
+ active_model: self.dataset.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.dataset.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('oe_open', show);
+ _(this.records[record_id][this.children_field]).each(function (child_id) {
+ var $child_row = this.$el.find('[id=treerow_' + child_id + '][data-row-parent-id='+ curnode.data('id') +']');
+ if ($child_row.hasClass('oe_open')) {
+ $child_row.toggleClass('oe_open',show);
+ this.showcontent($child_row, child_id, false);
+ }
+ $child_row.toggle(show);
+ }, this);
+ },
+
+ do_show: function () {
+ this.$el.show();
+ this._super();
+ },
+
+ do_hide: function () {
+ this.$el.hide();
+ this.hidden = true;
+ this._super();
+ }
+});
+
+core.view_registry.add('tree', TreeView);
+
+return TreeView;
+
+});
+