summaryrefslogtreecommitdiff
path: root/addons/web/static/src
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
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')
-rw-r--r--addons/web/static/src/js/views/form_common.js49
-rw-r--r--addons/web/static/src/js/views/form_widgets.js158
-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
-rw-r--r--addons/web/static/src/less/domain_selector.less84
-rw-r--r--addons/web/static/src/less/model_field_selector.less97
-rw-r--r--addons/web/static/src/xml/base.xml158
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 &amp;&amp; !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 === '&amp;'">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="&amp;">All</a></li>
+ <li><a href="#" data-operator="|">Any</a></li>
+ </ul>
+ </div>
+ <strong t-else="">
+ <t t-if="widget.operator === '&amp;'">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 &amp;&amp; !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 === '=' &amp;&amp; widget.value === false || widget.operator === '!=' &amp;&amp; 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">