diff options
| author | qsm-odoo <qsm@odoo.com> | 2017-01-03 17:29:20 +0100 |
|---|---|---|
| committer | qsm-odoo <qsm@odoo.com> | 2017-01-03 18:38:03 +0100 |
| commit | e3f99885ca789b338a4cb9ab9a154db50dff500d (patch) | |
| tree | 5661dd542cc429b8b21b0ca2a5c5cf29ecffac08 /addons/web/static/src | |
| parent | be58a61717e5ea270d280f145de7b7a54bd34745 (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')
| -rw-r--r-- | addons/web/static/src/js/views/form_common.js | 49 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/form_widgets.js | 158 | ||||
| -rw-r--r-- | addons/web/static/src/js/widgets/domain_selector.js | 559 | ||||
| -rw-r--r-- | addons/web/static/src/js/widgets/model_field_selector.js | 318 | ||||
| -rw-r--r-- | addons/web/static/src/less/domain_selector.less | 84 | ||||
| -rw-r--r-- | addons/web/static/src/less/model_field_selector.less | 97 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 158 |
7 files changed, 1303 insertions, 120 deletions
diff --git a/addons/web/static/src/js/views/form_common.js b/addons/web/static/src/js/views/form_common.js index d99c7ed2d2e..273da34f678 100644 --- a/addons/web/static/src/js/views/form_common.js +++ b/addons/web/static/src/js/views/form_common.js @@ -945,6 +945,7 @@ var SelectCreateListView = ListView.extend({ this.popup.create_edit_record(); }, select_record: function(index) { + if (this.popup.options.readonly) return; this.popup.on_selected([this.dataset.ids[index]]); this.popup.close(); }, @@ -1090,53 +1091,6 @@ var SelectCreateDialog = ViewDialog.extend({ on_view_list_loaded: function() {}, }); -var DomainEditorDialog = SelectCreateDialog.extend({ - init: function(parent, options) { - options = _.defaults(options, {initial_facet: { - category: _t("Custom Filter"), - icon: 'fa-star', - field: { - get_context: function () { return options.context; }, - get_groupby: function () { return []; }, - get_domain: function () { return options.default_domain; }, - }, - values: [{label: _t("Selected domain"), value: null}], - }}); - - this._super(parent, options); - }, - - get_domain: function (selected_ids) { - var group_domain = []; - var domain; - if (this.$('.o_list_record_selector input').prop('checked')) { - if (this.view_list.grouped) { - 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 || []); - }, - - on_view_list_loaded: function() { - this.$('.o_list_record_selector input').prop('checked', true); - this.$footer.find(".o_selectcreatepopup_search_select").prop('disabled', false); - }, -}); - return { // mixins FieldManagerMixin: FieldManagerMixin, @@ -1156,7 +1110,6 @@ return { ViewDialog: ViewDialog, FormViewDialog: FormViewDialog, SelectCreateDialog: SelectCreateDialog, - DomainEditorDialog: DomainEditorDialog, }; }); diff --git a/addons/web/static/src/js/views/form_widgets.js b/addons/web/static/src/js/views/form_widgets.js index c628763ae80..a3c7bcaaff3 100644 --- a/addons/web/static/src/js/views/form_widgets.js +++ b/addons/web/static/src/js/views/form_widgets.js @@ -10,6 +10,7 @@ var dom_utils = require('web.dom_utils'); var Priority = require('web.Priority'); var ProgressBar = require('web.ProgressBar'); var Dialog = require('web.Dialog'); +var DomainSelector = require("web.DomainSelector"); var common = require('web.form_common'); var formats = require('web.formats'); var framework = require('web.framework'); @@ -383,87 +384,112 @@ var FieldFloat = FieldChar.extend({ } if (this.digits !== undefined && this.digits.length === 2) { value_ = utils.round_decimals(value_, this.digits[1]); - } + } this._super(value_); } }); -var FieldCharDomain = common.AbstractField.extend(common.ReinitializeFieldMixin, { - template: "FieldCharDomain", +/// The "Domain" field allows the user to construct a technical-prefix domain thanks to +/// a tree-like interface and see the selected records in real time. +/// In debug mode, an input is also there to be able to enter the prefix char domain +/// directly (or to build advanced domains the tree-like interface does not allow to). +var FieldDomain = common.AbstractField.extend(common.ReinitializeFieldMixin).extend({ + template: "FieldDomain", events: { - 'click button': 'on_click', - 'change .o_debug_input': function(e) { - this.set('value', $(e.target).val()); - } + "click .o_domain_show_selection_button": function (e) { + e.preventDefault(); + this._showSelection(); + }, }, - init: function() { + custom_events: { + "domain_changed": function (e) { + this.set_value(this.domainSelector.getDomain(), true); + }, + }, + init: function () { this._super.apply(this, arguments); + + this.valid = true; this.debug = session.debug; + this.options = _.defaults(this.options || {}, { + model: undefined, // this option is mandatory ! + fs_filters: {}, // Field selector filters (to only show a subset of available fields @see FieldSelector) + }); }, start: function() { - var self = this; - var tmp = this._super(); - if (this.options.model_field){ - this.field_manager.fields[this.options.model_field].on("change:value", this, function(){ - if (self.view && self.view.record_loaded.state == "resolved" && self.view.onchanges_mutex){ - self.view.onchanges_mutex.def.then(function(){ - self.render_value(); - }); - } - }); + this.model = _get_model.call(this); // TODO get the model another way ? + this.field_manager.on("view_content_has_changed", this, function () { + var currentModel = this.model; + this.model = _get_model.call(this); + if (currentModel !== this.model) { + this.render_value(); + } + }); + + return this._super.apply(this, arguments); + + function _get_model() { + if (this.field_manager.fields[this.options.model]) { + return this.field_manager.get_field_value(this.options.model); + } + return this.options.model; } - return tmp; + }, + initialize_content: function () { + this._super.apply(this, arguments); + this.$recordsCountDisplay = this.$(".o_domain_records_count"); + this.$showSelectionButton = this.$(".o_domain_show_selection_button"); + this.$errorMessage = this.$(".o_domain_error_message"); + }, + set_value: function (value, noDomainSelectorRender) { + this._noDomainSelectorRender = !!noDomainSelectorRender; + this._super.apply(this, arguments); + this._noDomainSelectorRender = false; }, render_value: function() { - var self = this; + this._super.apply(this, arguments); - if (this.get('value')) { - var model = this.options.model || this.field_manager.get_field_value(this.options.model_field); - try{ - var domain = pyeval.eval('domain', this.get('value')); - } - catch(e){ - this.do_warn(_t('Error: Bad domain'), _t('The domain is wrong.')); - return; - } - var ds = new data.DataSetStatic(self, model, self.build_context()); - ds.call('search_count', [domain]).then(function (results) { - self.$('.o_count').text(results + _t(' selected records')); - if (self.get('effective_readonly')) { - self.$('button').text(_t('See selection ')); - } - else { - self.$('button').text(_t('Change selection ')); - } - self.$('button').append($("<span/>").addClass('fa fa-arrow-right')); - }); + var domain = pyeval.eval("domain", this.get("value") || "[]"); - if(this.debug) { - this.$('.o_debug_input').val(this.get('value')); + // Recreate domain widget with new domain value + if (!this._noDomainSelectorRender) { + if (this.domainSelector) { + this.domainSelector.destroy(); } - } else { - this.$('.o_form_input').val(''); - this.$('.o_count').text(_t('No selected record')); - var $arrow = this.$('button span').detach(); - this.$('button').text(_t('Select records ')).append($("<span/>").addClass('fa fa-arrow-right')); + this.domainSelector = new DomainSelector(this, this.model, domain, { + readonly: this.get("effective_readonly"), + fs_filters: this.options.fs_filters, + debugMode: session.debug, + }); + this.domainSelector.prependTo(this.$el); } - }, - on_click: function(event) { - event.preventDefault(); - var self = this; - var dialog = new common.DomainEditorDialog(this, { - res_model: this.options.model || this.field_manager.get_field_value(this.options.model_field), - default_domain: this.get('value'), - title: this.get('effective_readonly') ? _t('Selected records') : _t('Select records...'), - readonly: this.get('effective_readonly'), - disable_multiple_selection: this.get('effective_readonly'), - no_create: this.get('effective_readonly'), - on_selected: function(selected_ids) { - if (!self.get('effective_readonly')) { - self.set_value(dialog.get_domain(selected_ids)); - } - } + // Show number of selected records + new Model(this.model).call("search_count", [domain], { + context: this.build_context(), + }).then((function (data) { + this.valid = true; + return data; + }).bind(this), (function (error, e) { + e.preventDefault(); + this.valid = false; + }).bind(this)).always((function (data) { + this.$recordsCountDisplay.text(data || 0); + this.$showSelectionButton.toggleClass("hidden", !this.valid); + this.$errorMessage.toggleClass("hidden", this.valid); + }).bind(this)); + }, + is_syntax_valid: function() { + return this.field_manager.get("actual_mode") === "view" || this.valid; + }, + _showSelection: function() { + return new common.SelectCreateDialog(this, { + title: _t("Selected records"), + res_model: this.model, + domain: this.get("value"), + no_create: true, + readonly: true, + disable_multiple_selection: true, }).open(); }, }); @@ -1315,7 +1341,7 @@ var FieldStatus = common.AbstractField.extend({ render_value: function() { var self = this; var content = QWeb.render("FieldStatus.content", { - 'widget': self, + 'widget': self, 'value_folded': _.find(self.selection.folded, function(i){return i[0] === self.get('value');}) }); self.$el.html(content); @@ -1506,7 +1532,7 @@ var FieldMonetary = FieldFloat.extend({ /** 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 + 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({ @@ -1684,7 +1710,7 @@ core.form_widget_registry .add('email', FieldEmail) .add('url', FieldUrl) .add('text',FieldText) - .add('char_domain', FieldCharDomain) + .add('domain', FieldDomain) .add('date', FieldDate) .add('datetime', FieldDatetime) .add('selection', FieldSelection) 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; +}); diff --git a/addons/web/static/src/less/domain_selector.less b/addons/web/static/src/less/domain_selector.less new file mode 100644 index 00000000000..7dced900b21 --- /dev/null +++ b/addons/web/static/src/less/domain_selector.less @@ -0,0 +1,84 @@ + +.o_domain_node { + @o-domain-selector-vspace: 8px; + @o-domain-selector-indent: 32px; + @o-domain-selector-panel-space: 128px; + + .o_domain_node_control_panel { + .o-position-absolute(@right: 0); + } + + &.o_domain_tree { + .o_domain_tree_operator_caret::after { + .o-caret-down(); + } + + > .o_domain_node_children_container { + padding-left: @o-domain-selector-indent; + + > div { + margin-top: @o-domain-selector-vspace; + } + } + + &.o_domain_selector { + &.o_edit_mode { + position: relative; + padding-right: @o-domain-selector-panel-space; + } + + > .o_domain_node_children_container { + padding-left: 0; + } + } + } + + &.o_domain_leaf { + &.o_read_mode { + display: inline-block; + margin-right: 4px; + } + + > .o_domain_leaf_info { + background: @odoo-brand-lightsecondary; + border: 1px solid darken(@odoo-brand-lightsecondary, 10%); + padding: 2px 4px; + + .o_domain_leaf_chain, .o_domain_leaf_value { + font-weight: 700; + } + .o_domain_leaf_operator { + font-style: italic; + } + } + + > .o_domain_leaf_edition { + .o-flex-display(); + .o-align-items(flex-end); + + > * { + width: percentage(1/3); + + + * { + margin-left: 4px; + } + } + + .o_domain_leaf_value_tags { + .o-flex-display(); + + > * { + .o-flex(0, 0, auto); + } + > input { + .o-flex(1, 1, auto); + width: 0; + min-width: 50px; + } + .o_domain_leaf_value_remove_tag_button { + cursor: pointer; + } + } + } + } +} diff --git a/addons/web/static/src/less/model_field_selector.less b/addons/web/static/src/less/model_field_selector.less new file mode 100644 index 00000000000..1dc953bf970 --- /dev/null +++ b/addons/web/static/src/less/model_field_selector.less @@ -0,0 +1,97 @@ + +.o_field_selector { + position: relative; + + .o_field_selector_controls { + .o-position-absolute(0, 0, 1px); + .o-flex-display(); + .o-align-items(center); + + &::after { + .o-caret-down(); + cursor: pointer; + } + } + .o_field_selector_popover { + @o-field-selector-arrow-height: 7px; + .o-position-absolute(@top: 100%, @left: 0); + z-index: 1051; + width: 265px; + margin-top: @o-field-selector-arrow-height; + background: white; + box-shadow: 0 3px 10px rgba(0,0,0,.4); + + &:focus { + outline: none; + } + + .o_field_selector_popover_header { + color: white; + background: @brand-primary; + font-weight: bold; + padding: 5px 0 5px 0.4em; + + .o_field_selector_title { + width: 100%; + .o-text-overflow(); + padding: 0px 35px; + text-align: center; + } + .o_field_selector_popover_option { + .o-position-absolute(@top: 0); + padding: 7px 8px 8px 6px; + + &.o_prev_page { + left: 0; + border-right: 1px solid darken(@brand-primary, 10%); + } + &.o_field_selector_close { + right: 0; + border-left: 1px solid darken(@brand-primary, 10%); + } + &:hover { + background: darken(@brand-primary, 10%); + } + } + &:before { + .o-position-absolute(@top: -@o-field-selector-arrow-height, @left: @o-field-selector-arrow-height); + content: ""; + border-left: @o-field-selector-arrow-height solid rgba(0, 0, 0, 0); + border-right: @o-field-selector-arrow-height solid rgba(0, 0, 0, 0); + border-bottom: @o-field-selector-arrow-height solid @brand-primary; + } + } + .o_field_selector_popover_body { + .o_field_selector_page { + position: relative; + max-height: 320px; + overflow: auto; + margin: 0; + padding: 0; + + > .o_field_selector_item { + list-style: none; + position: relative; + padding: 5px 0 5px 0.4em; + cursor: pointer; + font-family: Arial; + font-size: 13px; + color: #444; + border-bottom: 1px solid #eee; + &.active { + background: #f5f5f5; + } + .o_field_selector_item_title { + font-size: 12px; + } + .o_field_selector_relation_icon { + .o-position-absolute(@top: 0, @right: 0, @bottom: 0); + .o-flex-display(); + .o-align-items(center); + padding: 10px; + } + } + } + } + } +} diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index c203b0171c2..dc3119908fe 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -668,14 +668,160 @@ t-att-placeholder="!widget.get('effective_readonly') ? widget.node.attrs.placeholder : ''" t-att-maxlength="widget.field.size"/> </t> -<t t-name="FieldCharDomain"> - <div> - <span class="o_count"/> - <button class="btn btn-default" type="button" - t-att-accesskey="widget.node.attrs.accesskey"/> - <input t-if="widget.debug" t-att-readonly="widget.get('effective_readonly')" type="text" class="o_form_input o_debug_input"/> + +<div t-name="FieldDomain" class="o_form_field_domain"> + <!-- domain selector will be instantiated here --> + <div class="mt8"> + <i class="fa fa-arrow-right"/> + <span><span class="o_domain_records_count"/> Records Selected</span> + <button class="btn btn-default btn-xs o_domain_show_selection_button" type="button">See selection</button> + <span class="text-warning o_domain_error_message hidden"><i class="fa fa-exclamation-triangle"/> Invalid domain</span> + </div> +</div> + +<t t-name="DomainNode.ControlPanel"> + <div t-if="!widget.readonly && !widget.noControlPanel" class="btn-group btn-group-sm pull-right o_domain_node_control_panel"> + <button class="btn btn-default o_domain_delete_node_button"><i class="fa fa-minus"/></button> + <button class="btn btn-default o_domain_add_node_button"><i class="fa fa-plus"/></button> + <button class="btn btn-default o_domain_add_node_button" t-att-data-branch="1"><i class="fa fa-ellipsis-h"/></button> </div> </t> +<t t-name="DomainTree.OperatorSelector"> + <div t-if="!widget.readonly" class="btn-group o_domain_tree_operator_selector"> + <button class="btn btn-xs btn-primary o_domain_tree_operator_caret" data-toggle="dropdown"> + <t t-if="widget.operator === '&'">All</t> + <t t-if="widget.operator === '|'">Any</t> + <t t-if="widget.operator === '!'">None</t> + </button> + <ul class="dropdown-menu"> + <li><a href="#" data-operator="&">All</a></li> + <li><a href="#" data-operator="|">Any</a></li> + </ul> + </div> + <strong t-else=""> + <t t-if="widget.operator === '&'">ALL</t> + <t t-if="widget.operator === '|'">ANY</t> + <t t-if="widget.operator === '!'">NONE</t> + </strong> +</t> +<div t-name="DomainSelector" t-attf-class="o_domain_node o_domain_tree o_domain_selector #{widget.readonly ? 'o_read_mode' : 'o_edit_mode'}"> + <t t-if="widget.children.length === 0"> + <span>Match <strong>all records</strong></span> + <button t-if="!widget.readonly" class="btn btn-sm btn-primary o_domain_add_first_node_button"><i class="fa fa-plus"/> Add filter</button> + </t> + <t t-else=""> + <div> + <t t-if="widget.children.length === 1">Match the records with the following rule:</t> + <t t-else=""> + <span>Match the records with</span> + <t t-call="DomainTree.OperatorSelector"/> + <span>of the following rules:</span> + </t> + </div> + + <div class="o_domain_node_children_container"/> + </t> + + <input t-if="widget.debug && !widget.readonly" type="text" class="o_domain_debug_input mt16"/> +</div> +<div t-name="DomainTree" class="o_domain_node o_domain_tree"> + <t t-call="DomainNode.ControlPanel"/> + <t t-call="DomainTree.OperatorSelector"/> + <span>of:</span> + + <div class="o_domain_node_children_container"/> +</div> +<div t-name="DomainLeaf" t-attf-class="o_domain_node o_domain_leaf #{widget.readonly ? 'o_read_mode' : 'o_edit_mode'}"> + <t t-call="DomainNode.ControlPanel"/> + + <div t-if="!widget.readonly" class="o_domain_leaf_edition"> + <!-- field selector will be instantiated here --> + <select class="o_domain_leaf_operator_select"> + <option t-foreach="widget.operators" t-as="key" + t-att-value="key" + t-att-selected="widget.displayOperator === key ? 'selected' : None"> + <t t-esc="key_value"/> + </option> + </select> + <div t-attf-class="o_ds_value_cell#{_.contains(['set', 'not set'], widget.displayOperator) ? ' hidden' : ''}"> + <t t-if="widget.selectionChoices !== null"> + <select class="o_domain_leaf_value_input"> + <option t-foreach="widget.selectionChoices" t-as="val" + t-att-value="val[0]" + t-att-selected="_.contains(val, widget.displayValue) ? 'selected' : None"> + <t t-esc="val[1]"/> + </option> + </select> + </t> + <t t-else=""> + <t t-if="_.contains(['in', 'not in'], widget.operator)"> + <div class="o_domain_leaf_value_input"> + <span class="badge" t-foreach="widget.displayValue" t-as="val"> + <t t-esc="val"/> <i class="o_domain_leaf_value_remove_tag_button fa fa-times" t-att-data-value="val"/> + </span> + </div> + <div class="o_domain_leaf_value_tags"> + <input placeholder="Add new value" type="text"/> + <button class="btn btn-xs btn-primary fa fa-plus o_domain_leaf_value_add_tag_button"/> + </div> + </t> + <t t-else=""> + <input class="o_domain_leaf_value_input" type="text" t-att-value="widget.displayValue"/> + </t> + </t> + </div> + </div> + <div t-else="" class="o_domain_leaf_info"> + <span class="o_domain_leaf_chain"><t t-esc="widget.chain"/></span> + <t t-if="_.isString(widget.value)"> + <span class="o_domain_leaf_operator"><t t-esc="widget.operator_mapping[widget.operator]"/></span> + <span class="o_domain_leaf_value text-primary">"<t t-esc="widget.value"/>"</span> + </t> + <t t-if="_.isArray(widget.value)"> + <span class="o_domain_leaf_operator"><t t-esc="widget.operator_mapping[widget.operator]"/></span> + <t t-foreach="widget.value" t-as="v"> + <span class="o_domain_leaf_value text-primary">"<t t-esc="v"/>"</span> + <t t-if="!v_last"> or </t> + </t> + </t> + <t t-if="_.isNumber(widget.value)"> + <span class="o_domain_leaf_operator"><t t-esc="widget.operator_mapping[widget.operator]"/></span> + <span class="o_domain_leaf_value text-primary"><t t-esc="widget.value"></t></span> + </t> + <t t-if="_.isBoolean(widget.value)"> + is + <t t-if="widget.operator === '=' && widget.value === false || widget.operator === '!=' && widget.value === true">not</t> + set + </t> + </div> +</div> + +<div t-name="FieldSelector" class="o_field_selector"> + <input type="text" t-att-value="widget.chain" t-att-readonly="!(widget.debug) and 1 or None"/> + <div class="o_field_selector_controls"> + <i class="fa fa-exclamation-triangle o_field_selector_warning hidden" title="Invalid field chain"/> + </div> + <div class="o_field_selector_popover hidden" tabindex="0"> + <div class="o_field_selector_popover_header text-center"> + <i class="fa fa-arrow-left o_field_selector_popover_option o_field_selector_prev_page"/> + <div class="o_field_selector_title"/> + <i class="fa fa-times o_field_selector_popover_option o_field_selector_close"/> + </div> + <div class="o_field_selector_popover_body"> + <ul class="o_field_selector_page"/> + </div> + </div> +</div> +<ul t-name="FieldSelector.page" t-attf-class="o_field_selector_page #{animation}"> + <li t-foreach="lines" t-as="line" + t-attf-class="o_field_selector_item #{line.relation and 'o_field_selector_next_page' or 'o_field_selector_select_button'}#{line_index == 0 and ' active' or ''}" + t-att-data-name="line.name"> + <t t-esc="line.string"/> + <div t-if="debug" class="text-muted o_field_selector_item_title"><t t-esc="line.name"/> (<t t-esc="line.type"/>)</div> + <i t-if="line.relation" class="fa fa-chevron-right o_field_selector_relation_icon" t-att-data-name="line.name"/> + </li> +</ul> + <t t-name="web.datepicker"> <t t-set="placeholder" t-value="widget.getParent().node and widget.getParent().node.attrs.placeholder"/> <div class="o_datepicker"> |
