summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/widgets
diff options
context:
space:
mode:
authorqsm-odoo <qsm@odoo.com>2017-01-03 17:29:20 +0100
committerqsm-odoo <qsm@odoo.com>2017-01-03 18:38:03 +0100
commite3f99885ca789b338a4cb9ab9a154db50dff500d (patch)
tree5661dd542cc429b8b21b0ca2a5c5cf29ecffac08 /addons/web/static/src/js/widgets
parentbe58a61717e5ea270d280f145de7b7a54bd34745 (diff)
[IMP] web, *: add new Domain and Field selection widgets
* base_action_rule, gamification, mass_mailing 1) Create the independant DomainSelector widget - Allow to build a prefix char domain 2) Replace old FieldCharDomain with a new FieldDomain which uses the new DomainSelector widget 3) Create the independant ModelFieldSelector widget - Allow to build a valid field chain for a specific model (e.g 'partner_id.name' for a 'res.users') - Used by DomainSelector widget --- The DomainSelector widget uses a tree-like representation with user-friendly buttons (ALL/ANY/+/-/...). Any domain can be built, except those containing the "!" (not) operator or some operators like "like", "child_of" (@see DomainSelector). However, if the user enters these in the debug input, the widget can handle them. Note: these widgets had to do unusual stuff the widget API does not allow to handle efficiently right now but it will be adapted to the new widget/field API in a further update. Note2: the widgets also have possibility of other improvements like supporting m2o input instantiation in DomainLeaf, etc. --- This work is an adaptation of the original code made by @pga-odoo.
Diffstat (limited to 'addons/web/static/src/js/widgets')
-rw-r--r--addons/web/static/src/js/widgets/domain_selector.js559
-rw-r--r--addons/web/static/src/js/widgets/model_field_selector.js318
2 files changed, 877 insertions, 0 deletions
diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js
new file mode 100644
index 00000000000..5f4287a8d8d
--- /dev/null
+++ b/addons/web/static/src/js/widgets/domain_selector.js
@@ -0,0 +1,559 @@
+odoo.define("web.DomainSelector", function (require) {
+"use strict";
+
+var core = require("web.core");
+var datepicker = require("web.datepicker");
+var formats = require ("web.formats");
+var ModelFieldSelector = require("web.ModelFieldSelector");
+var pyeval = require("web.pyeval");
+var Widget = require("web.Widget");
+
+var _t = core._t;
+var _lt = core._lt;
+
+// "child_of", "parent_of", "like", "not like", "=like", "=ilike"
+// are only used if user entered them manually or if got from demo data
+var operator_mapping = {
+ "=": _lt("is equal to"),
+ "!=": _lt("is not equal to"),
+ ">": _lt("greater than"),
+ "<": _lt("less than"),
+ ">=": _lt("greater than or equal to"),
+ "<=": _lt("less than or equal to"),
+ "ilike": _lt("contains"),
+ "not ilike": _lt("not contains"),
+ "in": _lt("in"),
+ "not in": _lt("not in"),
+
+ "child_of": _lt("child of"),
+ "parent_of": _lt("parent of"),
+ "like": "like",
+ "not like": "not like",
+ "=like": "=like",
+ "=ilike": "=ilike",
+
+ // custom
+ "set": _lt("is set"),
+ "not set": _lt("is not set"),
+};
+
+/// The DomainNode Widget is an abstraction for widgets which can represent and allow
+/// edition of a domain (part).
+var DomainNode = Widget.extend({
+ events: {
+ /// If click on the node add or delete button, notify the parent and let it handle the addition/removal
+ "click .o_domain_delete_node_button": function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.trigger_up("delete_node_clicked", {child: this});
+ },
+ "click .o_domain_add_node_button": function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.trigger_up("add_node_clicked", {newBranch: !!$(e.target).data("branch"), child: this});
+ },
+ },
+ /// A DomainNode needs a model and domain to work. It can also receives a set of options
+ /// @param model - a string with the model name
+ /// @param domain - an array of the prefix representation of the domain
+ /// @param options - an object with possible values:
+ /// - readonly, a boolean to indicate if the widget is readonly or not (default to true)
+ /// - fs_filters, an object with a series of filters to use for the field selector (see @FieldSelector)
+ init: function (parent, model, domain, options) {
+ this._super.apply(this, arguments);
+
+ this.model = model;
+ this.options = _.extend({
+ readonly: true,
+ fs_filters: {},
+ debugMode: false,
+ }, options || {});
+
+ this.readonly = this.options.readonly;
+ this.fs_filters = this.options.fs_filters;
+ this.debug = this.options.debugMode;
+ },
+ /// The getDomain method is an abstract method which should returns the prefix domain
+ /// the widget is currently representing (an array).
+ getDomain: function () {},
+});
+/// The DomainTree is a DomainNode which can handle subdomains (a domain which is composed
+/// of multiple parts). It thus will be composed of other DomainTree instances and/or leaf parts
+/// of a domain (@see DomainLeaf).
+var DomainTree = DomainNode.extend({
+ template: "DomainTree",
+ events: _.extend({}, DomainNode.prototype.events, {
+ "click .o_domain_tree_operator_selector > ul > li > a": function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.changeOperator($(e.target).data("operator"));
+ },
+ }),
+ custom_events: {
+ /// If a domain child sends a request to add a child or remove one, call the appropriate methods.
+ /// Propagates the event until success.
+ "delete_node_clicked": function (e) {
+ e.stopped = this.removeChild(e.data.child);
+ },
+ "add_node_clicked": function (e) {
+ var domain = [["id", "=", 1]];
+ if (e.data.newBranch) {
+ domain = [this.operator === "&" ? "|" : "&"].concat(domain).concat(domain);
+ }
+ e.stopped = this.addChild(domain, e.data.child);
+ },
+ },
+ /// @see DomainNode.init
+ /// The initialization of a DomainTree creates a "children" array attribute which will contain the
+ /// the DomainNode children. It also deduces the operator from the domain (default to "&").
+ /// @see DomainTree._addFlattenedChildren
+ init: function (parent, model, domain, options) {
+ this._super.apply(this, arguments);
+ this._initialize(domain);
+ },
+ /// @see DomainTree.init
+ _initialize: function (domain) {
+ this.children = [];
+
+ // As the domain is under prefix representation, the operator is the first element
+ // If not, odoo considers that the default is "&"
+ this.operator = domain[0];
+ var start = 1;
+ if (!_.contains(["&", "|", "!"], this.operator)) {
+ this.operator = "&";
+ start = 0;
+ }
+
+ // Add flattened children by search the appropriate number of children in the rest
+ // of the domain (after the operator)
+ var childrenDomains = domain.slice(start);
+ var nbLeafsToFind = 1;
+ for (var i = 0 ; i < childrenDomains.length ; i++) {
+ if (_.contains(["&", "|"], childrenDomains[i])) {
+ nbLeafsToFind++;
+ } else if (childrenDomains[i] !== "!") {
+ nbLeafsToFind--;
+ }
+
+ if (!nbLeafsToFind) {
+ var partLeft = childrenDomains.slice(0, i+1);
+ var partRight = childrenDomains.slice(i+1);
+ if (partLeft.length) {
+ this._addFlattenedChildren(partLeft);
+ }
+ if (partRight.length) {
+ this._addFlattenedChildren(partRight);
+ }
+ break;
+ }
+ }
+
+ // Mark "!" tree children so that they do not allow to add other children around them
+ if (this.operator === "!") {
+ this.children[0].noControlPanel = true;
+ }
+ },
+ start: function () {
+ this._postRender();
+ return $.when(this._super.apply(this, arguments), this._renderChildrenTo(this.$childrenContainer));
+ },
+ _postRender: function () {
+ this.$childrenContainer = this.$("> .o_domain_node_children_container");
+ },
+ _renderChildrenTo: function ($to) {
+ var $div = $("<div/>");
+ return $.when.apply($, _.map(this.children, (function (child) {
+ return child.appendTo($div);
+ }).bind(this))).then((function () {
+ _.each(this.children, function (child) {
+ child.$el.appendTo($to); // Forced to do it this way so that the children are not misordered
+ });
+ }).bind(this));
+ },
+ getDomain: function () {
+ var childDomains = [];
+ var nbChildren = 0;
+ _.each(this.children, function (child) {
+ var childDomain = child.getDomain();
+ if (childDomain.length) {
+ nbChildren++;
+ childDomains = childDomains.concat(child.getDomain());
+ }
+ });
+ var nbChildRequired = this.operator === "!" ? 1 : 2;
+ var operators = _.times(nbChildren - nbChildRequired + 1, _.constant(this.operator));
+ return operators.concat(childDomains);
+ },
+ changeOperator: function (operator) {
+ this.operator = operator;
+ this.trigger_up("domain_changed", {child: this});
+ },
+ /// The addChild method adds a domain part to the widget.
+ /// @param domain - an array of the prefix-like domain to build and add to the widget
+ /// @param afterNode - the node after which the new domain part must be added (at the end if not given)
+ /// @trigger_up domain_changed if the child is added
+ /// @return true if the part was added, false otherwise (the afterNode was not found)
+ addChild: function (domain, afterNode) {
+ var i = afterNode ? _.indexOf(this.children, afterNode) : this.children.length;
+ if (i < 0) return false;
+
+ this.children.splice(i+1, 0, instantiateNode(this, this.model, domain, this.options));
+ this.trigger_up("domain_changed", {child: this});
+ return true;
+ },
+ /// The removeChild method removes a given child from the widget.
+ /// @param oldChild - the child instance to remove
+ /// @trigger_up domain_changed if the child is removed
+ /// @return true if the child was removed, false otherwise (the widget does not own the child)
+ removeChild: function (oldChild) {
+ var i = _.indexOf(this.children, oldChild);
+ if (i < 0) return false;
+
+ this.children[i].destroy();
+ this.children.splice(i, 1);
+ this.trigger_up("domain_changed", {child: this});
+ return true;
+ },
+ /// The private _addFlattenedChildren method adds a child which represents the given
+ /// domain. If the child has children and that the child main domain operator is the
+ /// same as the current widget one, the 2-children prefix hierarchy is then simplified
+ /// by making the child children the widget own children.
+ /// @param domain - the domain of the child to add and simplify
+ _addFlattenedChildren: function (domain) {
+ var node = instantiateNode(this, this.model, domain, this.options);
+ if (node === null) {
+ return;
+ }
+ if (!node.children || node.operator !== this.operator) {
+ this.children.push(node);
+ return;
+ }
+ _.each(node.children, (function (child) {
+ child.setParent(this);
+ this.children.push(child);
+ }).bind(this));
+ node.destroy();
+ },
+ /// This method is ugly but achieves the right behavior without flickering.
+ /// It will be refactored alongside the new views/widget API.
+ _redraw: function (domain) {
+ var oldChildren = this.children.slice();
+ this._initialize(domain || this.getDomain());
+ return this._renderChildrenTo($("<div/>")).then((function () {
+ this.renderElement();
+ this._postRender();
+ _.each(this.children, (function (child) { child.$el.appendTo(this.$childrenContainer); }).bind(this));
+ _.each(oldChildren, function (child) { child.destroy(); });
+ }).bind(this));
+ },
+});
+/// The DomainSelector widget can be used to build prefix char domain. It is the DomainTree
+/// specialization to use to have a fully working widget.
+///
+/// Known limitations:
+/// - Some operators like "child_of", "parent_of", "like", "not like", "=like", "=ilike"
+/// will come only if you use them from demo data or debug input.
+/// - Some kind of domain can not be build right now e.g ("country_id", "in", [1,2,3,4])
+/// but you can insert from debug input.
+var DomainSelector = DomainTree.extend({
+ template: "DomainSelector",
+ events: _.extend({}, DomainTree.prototype.events, {
+ "click .o_domain_add_first_node_button": function (e) {
+ this.addChild([["id", "=", 1]]);
+ },
+ /// When the debug input changes, the string prefix domain is read. If it is syntax-valid
+ /// the widget is re-rendered and notifies the parents. If not, a warning is shown to the
+ /// user and the input is ignored.
+ "change .o_domain_debug_input": function (e) {
+ try {
+ var domain = pyeval.eval("domain", $(e.currentTarget).val() || "[]");
+ } catch (err) {
+ this.do_warn(_t("Syntax error"), _t("The domain you entered is not properly formed"));
+ return;
+ }
+ this._redraw(domain).then((function () {
+ this.trigger_up("domain_changed", {child: this, alreadyRedrawn: true});
+ }).bind(this));
+ },
+ }),
+ custom_events: _.extend({}, DomainTree.prototype.custom_events, {
+ /// If a subdomain notifies that it underwent some modifications, the DomainSelector
+ /// catches the message and performs a full re-rendering.
+ "domain_changed": function (e) {
+ e.stopped = false;
+ if (!e.data.alreadyRedrawn) {
+ this._redraw();
+ }
+ },
+ }),
+ _postRender: function () {
+ this._super.apply(this, arguments);
+
+ // Display technical domain if in debug mode
+ this.$debugInput = this.$(".o_domain_debug_input");
+ if (this.$debugInput.length) {
+ var strValue = this.getDomain() || "[]";
+ if (!_.isString(strValue)) { // TODO there is no "pyuneval" function
+ strValue = JSON.stringify(strValue);
+ strValue = strValue.replace(/false/g, "False").replace(/true/g, "True");
+ }
+ this.$debugInput.val(strValue);
+ }
+ },
+});
+/// The DomainLeaf widget is a DomainNode which handles a domain which cannot be split in
+/// another subdomains, i.e. composed of a field chain, an operator and a value.
+var DomainLeaf = DomainNode.extend({
+ template: "DomainLeaf",
+ events: _.extend({}, DomainNode.prototype.events, {
+ "change .o_domain_leaf_operator_select": function (e) {
+ this.onOperatorChange($(e.currentTarget).val());
+ },
+ "change .o_domain_leaf_value_input": function (e) {
+ if (e.currentTarget !== e.target) return;
+ this.onValueChange($(e.currentTarget).val());
+ },
+
+ // Handle the tags widget part (TODO should be an independant widget)
+ "click .o_domain_leaf_value_add_tag_button": "on_add_tag",
+ "keyup .o_domain_leaf_value_tags input": "on_add_tag",
+ "click .o_domain_leaf_value_remove_tag_button": "on_remove_tag",
+ }),
+ custom_events: {
+ "field_chain_changed": function (e) {
+ this.onChainChange(e.data.chain);
+ },
+ },
+ /// @see DomainNode.init
+ init: function (parent, model, domain, options) {
+ this._super.apply(this, arguments);
+
+ this.chain = domain[0][0];
+ this.operator = domain[0][1];
+ this.value = domain[0][2];
+
+ this.operator_mapping = operator_mapping;
+ },
+ willStart: function () {
+ var defs = [this._super.apply(this, arguments)];
+
+ if (!this.readonly) {
+ // In edit mode, instantiate a field selector. This is done here in willStart and prepared by
+ // appending it to a dummy element because the DomainLeaf rendering need some information which
+ // cannot be computed before the ModelFieldSelector is fully rendered (TODO).
+ this.fieldSelector = new ModelFieldSelector(this, this.model, this.chain, this.options);
+ defs.push(this.fieldSelector.appendTo($("<div/>")).then((function () {
+ var wDefs = [];
+
+ // Set list of operators according to field type
+ this.operators = this._getOperatorsFromType(this.fieldSelector.selectedField.type);
+ if (_.contains(["child_of", "parent_of", "like", "not like", "=like", "=ilike"], this.operator)) {
+ // In case user entered manually or from demo data
+ this.operators[this.operator] = operator_mapping[this.operator];
+ } else if (!this.operators[this.operator]) {
+ this.operators[this.operator] = "?"; // In case the domain uses an unsupported operator for the field type
+ }
+
+ // Set list of values according to field type
+ this.selectionChoices = null;
+ if (this.fieldSelector.selectedField.type === "boolean") {
+ this.selectionChoices = [["1", "set (true)"], ["0", "not set (false)"]];
+ } else if (this.fieldSelector.selectedField.type === "selection") {
+ this.selectionChoices = this.fieldSelector.selectedField.selection;
+ }
+
+ // Adapt display value and operator for rendering
+ this.displayValue = this.value;
+ try {
+ var f = this.fieldSelector.selectedField;
+ if (!f.relation) { // TODO in this case, the value should be m2o input, etc...
+ this.displayValue = formats.format_value(this.value, this.fieldSelector.selectedField);
+ }
+ } catch (err) {/**/}
+ this.displayOperator = this.operator;
+ if (this.fieldSelector.selectedField.type === "boolean") {
+ this.displayValue = this.value ? "1" : "0";
+ } else if ((this.operator === "!=" || this.operator === "=") && this.value === false) {
+ this.displayOperator = this.operator === "!=" ? "set" : "not set";
+ }
+
+ // TODO the value could be a m2o input, etc...
+ if (_.contains(["date", "datetime"], this.fieldSelector.selectedField.type)) {
+ this.valueWidget = new (this.fieldSelector.selectedField.type === "datetime" ? datepicker.DateTimeWidget : datepicker.DateWidget)(this);
+ wDefs.push(this.valueWidget.appendTo("<div/>").then((function () {
+ this.valueWidget.$el.addClass("o_domain_leaf_value_input");
+ this.valueWidget.set_value(this.value);
+ this.valueWidget.on("datetime_changed", this, function () {
+ this.onValueChange(this.valueWidget.get_value());
+ });
+ }).bind(this)));
+ }
+
+ return $.when.apply($, wDefs);
+ }).bind(this)));
+ }
+
+ return $.when.apply($, defs);
+ },
+ start: function () {
+ if (!this.readonly) { // In edit mode ...
+ this.fieldSelector.$el.prependTo(this.$("> .o_domain_leaf_edition")); // ... place the field selector
+ if (this.valueWidget) { // ... and place the value widget if any
+ this.$(".o_domain_leaf_value_input").replaceWith(this.valueWidget.$el);
+ }
+ }
+ return this._super.apply(this, arguments);
+ },
+ getDomain: function () {
+ return [[this.chain, this.operator, this.value]];
+ },
+ /// The onChainChange method handles a field chain change in the domain. In that case, the operator
+ /// should be adapted to a valid one for the new field and the value should also be adapted to the
+ /// new field and/or operator.
+ /// @param chain - the new field chain (string)
+ /// @param silent - true if the method call should not trigger_up a domain_changed event
+ /// @trigger_up domain_changed event to ask for a re-rendering
+ onChainChange: function (chain, silent) {
+ this.chain = chain;
+
+ var operators = this._getOperatorsFromType(this.fieldSelector.selectedField.type);
+ if (operators[this.operator] === undefined) {
+ this.onOperatorChange("=", true);
+ }
+
+ this.onValueChange(this.value, true);
+
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ },
+ /// The onOperatorChange method handles an operator change in the domain. In that case, the value
+ /// should be adapted to a valid one for the new operator.
+ /// @param operator - the new operator
+ /// @param silent - true if the method call should not trigger_up a domain_changed event
+ /// @trigger_up domain_changed event to ask for a re-rendering
+ onOperatorChange: function (operator, silent) {
+ this.operator = operator;
+
+ if (_.contains(["set", "not set"], this.operator)) {
+ this.operator = this.operator === "not set" ? "=" : "!=";
+ this.value = false;
+ } else if (_.contains(["in", "not in"], this.operator)) {
+ this.value = _.isArray(this.value) ? this.value : this.value ? ("" + this.value).split(",") : [];
+ } else {
+ if (_.isArray(this.value)) {
+ this.value = this.value.join(",");
+ }
+ this.onValueChange(this.value, true);
+ }
+
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ },
+ /// The onValueChange method handles a formatted value change in the domain. In that case, the value
+ /// should be adapted to a valid technical one.
+ /// @param value - the new formatted value
+ /// @param silent - true if the method call should not trigger_up a domain_changed event
+ /// @trigger_up domain_changed event to ask for a re-rendering
+ onValueChange: function (value, silent) {
+ var couldNotParse = false;
+ try {
+ this.value = formats.parse_value(value, this.fieldSelector.selectedField);
+ } catch (err) {
+ this.value = value;
+ couldNotParse = true;
+ }
+
+ if (this.fieldSelector.selectedField.type === "boolean") {
+ if (!_.isBoolean(this.value)) { // Convert boolean-like value to boolean
+ this.value = !!parseFloat(this.value);
+ }
+ } else if (this.fieldSelector.selectedField.type === "selection") {
+ if (!_.some(this.fieldSelector.selectedField.selection, (function (option) { return option[0] === this.value; }).bind(this))) {
+ this.value = this.fieldSelector.selectedField.selection[0][0];
+ }
+ } else if (_.contains(["date", "datetime"], this.fieldSelector.selectedField.type)) {
+ if (couldNotParse || _.isBoolean(this.value)) {
+ this.value = formats.parse_value(formats.format_value(Date.now(), this.fieldSelector.selectedField), this.fieldSelector.selectedField);
+ }
+ } else {
+ if (_.isBoolean(this.value)) { // Never display "true" or "false" strings from boolean value
+ this.value = "";
+ }
+ }
+
+ if (!silent) this.trigger_up("domain_changed", {child: this});
+ },
+ /// The private _getOperatorsFromType returns the mapping of "technical operator" to "display operator value"
+ /// of the operators which are available for the given field type.
+ _getOperatorsFromType: function (type) {
+ switch (type) {
+ case "boolean":
+ return {
+ "=": _t("is"),
+ "!=": _t("is not"),
+ };
+
+ case "char":
+ case "text":
+ case "html":
+ return _.pick(operator_mapping, "=", "!=", "ilike", "not ilike", "set", "not set", "in", "not in");
+
+ case "many2many":
+ case "one2many":
+ case "many2one":
+ return _.pick(operator_mapping, "=", "!=", "ilike", "not ilike", "set", "not set");
+
+ case "integer":
+ case "float":
+ case "monetary":
+ return _.pick(operator_mapping, "=", "!=", ">", "<", ">=", "<=", "ilike", "not ilike", "set", "not set");
+
+ case "selection":
+ return _.pick(operator_mapping, "=", "!=", "set", "not set");
+
+ case "date":
+ case "datetime":
+ return _.pick(operator_mapping, "=", "!=", ">", "<", ">=", "<=", "set", "not set");
+
+ default:
+ return _.extend({}, operator_mapping);
+ }
+ },
+
+ on_add_tag: function (e) {
+ if (e.type === "keyup" && e.which !== $.ui.keyCode.ENTER) return;
+ if (!_.contains(["not in", "in"], this.operator)) return;
+
+ var values = _.isArray(this.value) ? this.value.slice() : [];
+
+ var $input = this.$(".o_domain_leaf_value_tags input");
+ var val = $input.val().trim();
+ if (val && values.indexOf(val) < 0) {
+ values.push(val);
+ _.defer(this.onValueChange.bind(this, values));
+ $input.focus();
+ }
+ },
+ on_remove_tag: function (e) {
+ var values = _.isArray(this.value) ? this.value.slice() : [];
+ var val = this.$(e.currentTarget).data("value");
+
+ var index = values.indexOf(val);
+ if (index >= 0) {
+ values.splice(index, 1);
+ _.defer(this.onValueChange.bind(this, values));
+ }
+ },
+});
+
+/// The instantiateNode function instantiates a DomainTree if the given domain contains
+/// several parts and a DomainLeaf if it only contains one part. Returns null otherwise.
+function instantiateNode(parent, model, domain, options) {
+ if (domain.length > 1) {
+ return new DomainTree(parent, model, domain, options);
+ } else if (domain.length === 1) {
+ return new DomainLeaf(parent, model, domain, options);
+ }
+ return null;
+}
+
+return DomainSelector;
+});
diff --git a/addons/web/static/src/js/widgets/model_field_selector.js b/addons/web/static/src/js/widgets/model_field_selector.js
new file mode 100644
index 00000000000..109b7ead7df
--- /dev/null
+++ b/addons/web/static/src/js/widgets/model_field_selector.js
@@ -0,0 +1,318 @@
+odoo.define("web.ModelFieldSelector", function (require) {
+"use strict";
+
+var core = require("web.core");
+var Model = require("web.DataModel");
+var Widget = require("web.Widget");
+
+/// The ModelFieldSelector widget can be used to select a particular field chain from a given model.
+var ModelFieldSelector = Widget.extend({
+ template: "FieldSelector",
+ events: {
+ // Handle popover opening and closing
+ "focusin input, .o_field_selector_popover": function (e) {
+ if (this._hidePopoverTimeout !== undefined) {
+ clearTimeout(this._hidePopoverTimeout);
+ this._hidePopoverTimeout = undefined;
+ return;
+ }
+ if (this.$input.is(e.currentTarget)) {
+ this.showPopover();
+ }
+ },
+ "focusout input, .o_field_selector_popover": function () {
+ if (this._hidePopoverTimeout !== undefined) {
+ return;
+ }
+ this._hidePopoverTimeout = setTimeout((function () {
+ this.hidePopover();
+ this._hidePopoverTimeout = undefined;
+ }).bind(this), 100);
+ },
+ "click .o_field_selector_close": function () {
+ if (this._hidePopoverTimeout !== undefined) {
+ clearTimeout(this._hidePopoverTimeout);
+ this._hidePopoverTimeout = undefined;
+ }
+ this.hidePopover();
+ },
+
+ // Handle popover field navigation
+ "click .o_field_selector_prev_page": "goToPrevPage",
+ "click .o_field_selector_next_page": function (e) {
+ e.stopPropagation();
+ this.goToNextPage(this._getLastPageField($(e.currentTarget).data("name")));
+ },
+ "click li.o_field_selector_select_button": function (e) {
+ this.selectField(this._getLastPageField($(e.currentTarget).data("name")));
+ },
+
+ // Handle a direct change in the debug input
+ "change input": function() {
+ this.setChain(this.$input.val());
+ this.validate(true);
+ this._prefill().then(this.displayPage.bind(this, ""));
+ this.trigger_up("field_chain_changed", {chain: this.chain});
+ },
+
+ // Handle keyboard and mouse navigation to build the field chain
+ "mouseover li.o_field_selector_item": function (e) {
+ this.$("li.o_field_selector_item").removeClass("active");
+ $(e.currentTarget).addClass("active");
+ },
+ "keydown": function (e) {
+ if (!this.$popover.is(":visible")) return;
+ var inputHasFocus = this.$input.is(":focus");
+
+ switch (e.which) {
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ e.preventDefault();
+ var $active = this.$("li.o_field_selector_item.active");
+ var $to = $active[e.which === $.ui.keyCode.DOWN ? "next" : "prev"](".o_field_selector_item");
+ if ($to.length) {
+ $active.removeClass("active");
+ $to.addClass("active");
+ this.$popover.focus();
+
+ var $page = $to.closest(".o_field_selector_page");
+ var full_height = $page.height();
+ var el_position = $to.position().top;
+ var el_height = $to.outerHeight();
+ var current_scroll = $page.scrollTop();
+ if (el_position < 0) {
+ $page.scrollTop(current_scroll - el_height);
+ } else if (full_height < el_position + el_height) {
+ $page.scrollTop(current_scroll + el_height);
+ }
+ }
+ break;
+ case $.ui.keyCode.RIGHT:
+ if (inputHasFocus) break;
+ e.preventDefault();
+ var name = this.$("li.o_field_selector_item.active").data("name");
+ if (name) {
+ var field = this._getLastPageField(name);
+ if (field.relation) {
+ this.goToNextPage(field);
+ }
+ }
+ break;
+ case $.ui.keyCode.LEFT:
+ if (inputHasFocus) break;
+ e.preventDefault();
+ this.goToPrevPage();
+ break;
+ case $.ui.keyCode.ESCAPE:
+ e.stopPropagation();
+ this.hidePopover();
+ break;
+ case $.ui.keyCode.ENTER:
+ if (inputHasFocus) break;
+ e.preventDefault();
+ this.selectField(this._getLastPageField(this.$("li.o_field_selector_item.active").data("name")));
+ break;
+ }
+ },
+ },
+ /// The ModelFieldSelector requires a model and a initial field chain to work with.
+ /// @param model - a string with the model name (e.g. "res.partner")
+ /// @param chain - a string with the initial field chain (e.g. "company_id.name")
+ /// @param options - an object with several options:
+ /// - filters: an object which contains a series of filters to use
+ /// - debugMode: a boolean which is true if the widget is in debug mode
+ init: function (parent, model, chain, options) {
+ this._super.apply(this, arguments);
+
+ this.model = model;
+ this.chain = chain;
+ this.options = _.extend({
+ filters: {},
+ debugMode: false,
+ }, options || {});
+
+ this.pages = [];
+ this.selectedField = false;
+ this.isSelected = true;
+ this.dirty = false;
+
+ this.debug = this.options.debugMode;
+ },
+ willStart: function () {
+ return $.when(
+ this._super.apply(this, arguments),
+ this._prefill()
+ );
+ },
+ start: function () {
+ this.$input = this.$("input");
+ this.$popover = this.$(".o_field_selector_popover");
+ this.displayPage();
+
+ return this._super.apply(this, arguments);
+ },
+ /// The setChain method saves a new field chain string and displays it in the DOM input element.
+ /// @param chain - the new field chain string
+ setChain: function (chain) {
+ this.chain = chain;
+ this.$input.val(this.chain);
+ },
+ /// The addChainNode method adds a field name to the current field chain.
+ /// @param fieldName - the new field name to add at the end of the current field chain
+ addChainNode: function (fieldName) {
+ this.dirty = true;
+ if (this.isSelected) {
+ this.removeChainNode();
+ this.isSelected = false;
+ }
+ if (!this.valid) {
+ this.setChain("");
+ this.validate(true);
+ }
+ this.setChain((this.chain ? (this.chain + ".") : "") + fieldName);
+ },
+ /// The removeChainNode method removes the last field name at the end of the current field chain.
+ removeChainNode: function () {
+ this.setChain(this.chain.substring(0, this.chain.lastIndexOf(".")));
+ },
+ /// The validate method toggles the valid status of the widget and display the error message if it
+ /// is not valid.
+ /// @param valid - a boolean which is true if the widget is valid
+ validate: function (valid) {
+ this.$(".o_field_selector_warning").toggleClass("hidden", valid);
+ this.valid = valid;
+ },
+ /// The showPopover method shows the popover to select the field chain. It prepares the popover pages
+ /// before actually showing it.
+ showPopover: function () {
+ this._prefill().then((function () {
+ this.displayPage();
+ this.$popover.removeClass("hidden");
+ }).bind(this));
+ },
+ /// The hidePopover method closes the popover and mark the field as selected. If the field chain changed,
+ /// it notifies its parents.
+ hidePopover: function () {
+ this.$popover.addClass("hidden");
+ this.isSelected = true;
+ if (this.dirty) {
+ this.trigger_up("field_chain_changed", {chain: this.chain});
+ this.dirty = false;
+ }
+ },
+ /// The private _prefill method prepares the popover by filling its pages according to the current field chain.
+ /// @return a deferred which is resolved once the last page is shown
+ _prefill: function () {
+ this.pages = [];
+ return this._pushPageData(this.model).then((function() {
+ return (this.chain ? processChain.call(this, this.chain.split(".").reverse()) : $.when());
+ }).bind(this));
+
+ function processChain(chain) {
+ var field = this._getLastPageField(chain.pop());
+ if (field && field.relation && chain.length > 0) { // Fetch next chain node if any and possible
+ return this._pushPageData(field.relation).then(processChain.bind(this, chain));
+ } else if (field && chain.length === 0) { // Last node fetched, save it
+ this.selectedField = field;
+ this.validate(true);
+ } else { // Wrong node chain
+ this.validate(false);
+ }
+ return $.when();
+ }
+ },
+ /// The private _pushPageData method gets the field of a particular model and adds them for the new
+ /// last popover page.
+ /// @param model - the model name whose fields have to be fetched
+ /// @return a deferred which is resolved once the fields have been added
+ _pushPageData: function (model) {
+ return fieldsCache.getFields(model, this.options.filters).then((function (fields) {
+ this.pages.push(fields);
+ }).bind(this));
+ },
+ /// The displayPage method shows the last page content of the popover. It also adapts the title according
+ /// to the previous page.
+ /// @param animation - an optional animation class to add to the page
+ displayPage: function (animation) {
+ this.$(".o_field_selector_prev_page").toggleClass("hidden", this.pages.length === 1);
+
+ var page = _.last(this.pages);
+ var title = "";
+ if (this.pages.length > 1) {
+ var chainParts = this.chain.split(".");
+ var prevField = _.findWhere(this.pages[this.pages.length - 2], {
+ name: this.isSelected ? chainParts[chainParts.length - 2] : _.last(chainParts),
+ });
+ if (prevField) title = prevField.string;
+ }
+ this.$(".o_field_selector_popover_header .o_field_selector_title").text(title);
+ this.$(".o_field_selector_page").replaceWith(core.qweb.render("FieldSelector.page", {lines: page, animation: animation, debug: this.debug}));
+ },
+ /// The goToPrevPage method removes the last page, adapts the field chain and displays the new last page.
+ goToPrevPage: function () {
+ if (this.pages.length <= 1) return;
+ this.pages.pop();
+ this.removeChainNode();
+ this.displayPage("o_animate_slide_left");
+ },
+ /// The goToNextPage method adds a new page to the popover following the given field relation and adapts
+ /// the chain node according to this given field.
+ /// @param field - the field to add to the chain node
+ goToNextPage: function (field) {
+ this.addChainNode(field.name);
+ this.selectedField = field;
+ this._pushPageData(field.relation).then(this.displayPage.bind(this, "o_animate_slide_right"));
+ },
+ /// The selectField method selects the given field and adapts the chain node according to it. It also closes
+ /// the popover and thus notifies the parents about the change.
+ /// @param field - the field to select
+ selectField: function (field) {
+ this.addChainNode(field.name);
+ this.selectedField = field;
+ this.hidePopover();
+ },
+ /// The private _getLastPageField search a field in the last page by its name.
+ /// @return the field data (an object) found in the last popover page thanks to its name
+ _getLastPageField: function (name) {
+ return _.findWhere(_.last(this.pages), {
+ name: name,
+ });
+ },
+});
+
+/// Field Selector Cache
+///
+/// * Stores fields per model used in field selector
+/// * Apply filters on the fly
+var fieldsCache = {
+ cache: {},
+ cacheDefs: {},
+ getFields: function (model, filters) {
+ return (this.cacheDefs[model] ? this.cacheDefs[model] : this.updateCache(model)).then((function () {
+ return this.filter(model, filters);
+ }).bind(this));
+ },
+ updateCache: function (model) {
+ this.cacheDefs[model] = new Model(model).call("fields_get", [
+ false,
+ ["store", "searchable", "type", "string", "relation", "selection", "related"],
+ ]).then((function (fields) {
+ var field_data = [];
+ _.each(_.sortBy(_.keys(fields), function (f) { return fields[f].string; }), function (key) {
+ fields[key]["name"] = key;
+ field_data.push(fields[key]);
+ });
+ this.cache[model] = field_data;
+ }).bind(this));
+ return this.cacheDefs[model];
+ },
+ filter: function (model, filters) {
+ filters = _.defaults(filters || {}, {searchable: true});
+ return _.filter(this.cache[model], function (f) {
+ return !filters.searchable || f.searchable;
+ });
+ },
+};
+
+return ModelFieldSelector;
+});