[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.
This commit is contained in:
@@ -28,7 +28,7 @@
|
||||
<field name="kind"/>
|
||||
<label for="filter_pre_id" attrs="{'invisible': [('kind', '!=', 'on_write')]}"/>
|
||||
<div attrs="{'invisible': [('kind', '!=', 'on_write')]}">
|
||||
<field name="filter_pre_domain" widget="char_domain" options="{'model_field': 'model'}"
|
||||
<field name="filter_pre_domain" widget="domain" options="{'model': 'model'}"
|
||||
class="o_row"/>
|
||||
<div class="oe_edit_only o_row">Set selection based on a search filter:
|
||||
<field name="filter_pre_id" domain="[('model_id', '=', model), '|', ('user_id', '=', uid), ('user_id', '=', False)]"
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<label for="filter_id"/>
|
||||
<div>
|
||||
<field name="filter_domain" widget="char_domain" options="{'model_field': 'model'}"
|
||||
<field name="filter_domain" widget="domain" options="{'model': 'model'}"
|
||||
class="o_row"/>
|
||||
<div class="oe_edit_only o_row">Set selection based on a search filter:
|
||||
<field name="filter_id" domain="[('model_id', '=', model), '|', ('user_id', '=', uid), ('user_id', '=', False)]"
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
<field name="period">once</field>
|
||||
<field name="visibility_mode">personal</field>
|
||||
<field name="report_message_frequency">never</field>
|
||||
<field name="user_domain" eval="str([('groups_id', '=', ref('base.group_user'))])" />
|
||||
<field name="user_domain" eval="str([('groups_id.id', '=', ref('base.group_user'))])" />
|
||||
<field name="state">inprogress</field>
|
||||
<field name="category">other</field>
|
||||
</record>
|
||||
@@ -172,7 +172,7 @@
|
||||
<field name="period">once</field>
|
||||
<field name="visibility_mode">personal</field>
|
||||
<field name="report_message_frequency">never</field>
|
||||
<field name="user_domain" eval="str([('groups_id', '=', ref('base.group_erp_manager'))])" />
|
||||
<field name="user_domain" eval="str([('groups_id.id', '=', ref('base.group_erp_manager'))])" />
|
||||
<field name="state">inprogress</field>
|
||||
<field name="category">other</field>
|
||||
</record>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</h1>
|
||||
<label for="user_domain" class="oe_edit_only" string="Assign Challenge To"/>
|
||||
<div>
|
||||
<field name="user_domain" widget="char_domain" options="{'model': 'res.users'}" />
|
||||
<field name="user_domain" widget="domain" options="{'model': 'res.users'}" />
|
||||
<field name="user_ids" groups="base.group_no_one" widget="many2many_tags" readonly="True" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -349,12 +349,10 @@
|
||||
<div>
|
||||
<field name="mailing_model" widget="radio" options="{'horizontal': true}"/>
|
||||
<field name="mailing_model_real" invisible="1"/>
|
||||
<field name="mailing_domain" widget="char_domain"
|
||||
placeholder="Select recipients"
|
||||
<field name="mailing_domain" widget="domain"
|
||||
attrs="{'invisible': [('mailing_model', '=', 'mail.mass_mailing.list')]}"
|
||||
options="{'model_field': 'mailing_model_real', 'model_domain': 'mailing_domain'}"
|
||||
options="{'model': 'mailing_model'}"
|
||||
context="{'search_default_not_opt_out':1}"/>
|
||||
|
||||
<div attrs="{'invisible': [('mailing_model', '<>', 'mail.mass_mailing.list')]}">
|
||||
<label for="contact_list_ids" string="Select mailing lists:" class="oe_edit_only"/>
|
||||
<field name="contact_list_ids" widget="many2many_tags"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
var domain = pyeval.eval("domain", this.get("value") || "[]");
|
||||
|
||||
// Recreate domain widget with new domain value
|
||||
if (!this._noDomainSelectorRender) {
|
||||
if (this.domainSelector) {
|
||||
this.domainSelector.destroy();
|
||||
}
|
||||
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'));
|
||||
this.domainSelector = new DomainSelector(this, this.model, domain, {
|
||||
readonly: this.get("effective_readonly"),
|
||||
fs_filters: this.options.fs_filters,
|
||||
debugMode: session.debug,
|
||||
});
|
||||
|
||||
if(this.debug) {
|
||||
this.$('.o_debug_input').val(this.get('value'));
|
||||
}
|
||||
} 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.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)
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -93,6 +93,8 @@
|
||||
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/webclient.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/datepicker.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/domain_selector.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/model_field_selector.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/progress_bar.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/dropdown.less"/>
|
||||
<link rel="stylesheet" type="text/less" href="/web/static/src/less/tooltip.less"/>
|
||||
@@ -153,7 +155,9 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/debug_manager.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/data_export.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/date_picker.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/domain_selector.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/loading.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/model_field_selector.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/notification.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/sidebar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/priority.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user